- .go src= targets must be in the default build and reached from a Test or an Example with output - go doc -c makes the identifier fallback case-sensitive - commandWord parses env prefixes, flags, go run and bin/ forms
270 lines
7.4 KiB
Go
270 lines
7.4 KiB
Go
package docsite
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Commands is the set of command names docs pages may show. The caller
|
|
// collects them from the real command constructors; the checker holds no
|
|
// hard-coded list.
|
|
type Commands struct {
|
|
// Tool lists the summer CLI command names.
|
|
Tool []string
|
|
// App lists the command names every application binary gets from the
|
|
// framework (migrations, queue, serve, admin and the rest).
|
|
App []string
|
|
}
|
|
|
|
// shellLangs are the fence languages read as shell sessions: their lines
|
|
// are checked as commands and highlighted with prompts.
|
|
var shellLangs = []string{"sh", "shell", "bash", "console"}
|
|
|
|
// commandSeparators split one shell line into its commands.
|
|
var commandSeparators = regexp.MustCompile(`&&|\|\||;|\|`)
|
|
|
|
// checkCommands verifies every summer and ./bin/<app> command name in shell
|
|
// fences and code spans of the pages. Application names may also come from
|
|
// bonfire.Command literals in docs/examples and examples.
|
|
func (s *site) checkCommands(docs []parsedDoc) ([]Problem, error) {
|
|
if s.opts.Commands == nil {
|
|
return []Problem{{File: s.rel(s.opts.Src), Rule: "command", Message: "no command set supplied"}}, nil
|
|
}
|
|
tool := map[string]bool{}
|
|
for _, n := range s.opts.Commands.Tool {
|
|
tool[n] = true
|
|
}
|
|
app := map[string]bool{}
|
|
for _, n := range s.opts.Commands.App {
|
|
app[n] = true
|
|
}
|
|
for _, dir := range []string{filepath.Join(s.opts.Src, "examples"), filepath.Join(s.opts.Root, "examples")} {
|
|
names, err := exampleCommandNames(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, n := range names {
|
|
app[n] = true
|
|
}
|
|
}
|
|
var problems []Problem
|
|
check := func(d parsedDoc, line int, text string) {
|
|
for _, cmd := range commandSeparators.Split(text, -1) {
|
|
name, isTool, ok := commandWord(cmd)
|
|
if !ok {
|
|
continue
|
|
}
|
|
known := app[name]
|
|
if isTool {
|
|
known = tool[name]
|
|
}
|
|
if !known {
|
|
problems = append(problems, Problem{File: d.file, Line: line, Rule: "command",
|
|
Message: fmt.Sprintf("%q is not a summer or application command", name)})
|
|
}
|
|
}
|
|
}
|
|
for _, d := range docs {
|
|
if d.page == nil {
|
|
continue
|
|
}
|
|
for _, span := range codeSpans(d.doc, d.body, d.line) {
|
|
check(d, span.line, span.text)
|
|
}
|
|
fences, err := collectFences(d.doc, d.body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, f := range fences {
|
|
if !slices.Contains(shellLangs, f.lang) {
|
|
continue
|
|
}
|
|
for _, line := range f.code {
|
|
check(d, d.line+line.line, line.text)
|
|
}
|
|
}
|
|
}
|
|
return problems, nil
|
|
}
|
|
|
|
// commandWord finds the summer or application command word in one shell
|
|
// command. tool is true for summer and `go run ./cmd/summer`, false for
|
|
// ./bin/{app} and bin/{app}. ok is false when the line is not one of
|
|
// those programs or when flags leave no command word. Flag handling
|
|
// matches cobra's stripFlags for a root whose only flag is the bool
|
|
// --help / -h: "--" ends the search, --help and -h take no value, any
|
|
// other --name or two-character -x consumes the next token, and a token
|
|
// that already contains "=" is skipped alone.
|
|
func commandWord(cmd string) (name string, tool bool, ok bool) {
|
|
fields := strings.Fields(strings.TrimSpace(cmd))
|
|
if len(fields) > 0 && fields[0] == "$" {
|
|
fields = fields[1:]
|
|
}
|
|
for len(fields) > 0 && isAssignment(fields[0]) {
|
|
fields = fields[1:]
|
|
}
|
|
switch {
|
|
case len(fields) == 0:
|
|
return "", false, false
|
|
case fields[0] == "summer":
|
|
tool = true
|
|
fields = fields[1:]
|
|
case len(fields) >= 3 && fields[0] == "go" && fields[1] == "run" && fields[2] == "./cmd/summer":
|
|
tool = true
|
|
fields = fields[3:]
|
|
case isAppBin(fields[0]):
|
|
fields = fields[1:]
|
|
default:
|
|
return "", false, false
|
|
}
|
|
for len(fields) > 0 {
|
|
s := fields[0]
|
|
switch {
|
|
case s == "--":
|
|
return "", tool, false
|
|
case strings.HasPrefix(s, "-") && strings.Contains(s, "="):
|
|
fields = fields[1:]
|
|
case s == "--help" || s == "-h":
|
|
fields = fields[1:]
|
|
case strings.HasPrefix(s, "--") || (strings.HasPrefix(s, "-") && len(s) == 2):
|
|
if len(fields) < 3 {
|
|
return "", tool, false
|
|
}
|
|
fields = fields[2:]
|
|
case strings.HasPrefix(s, "-"):
|
|
fields = fields[1:]
|
|
default:
|
|
return s, tool, true
|
|
}
|
|
}
|
|
return "", tool, false
|
|
}
|
|
|
|
// isAssignment reports a leading VAR=value token: a name of letters,
|
|
// digits and underscores that does not start with a digit.
|
|
func isAssignment(tok string) bool {
|
|
name, _, ok := strings.Cut(tok, "=")
|
|
if !ok || name == "" || (name[0] >= '0' && name[0] <= '9') {
|
|
return false
|
|
}
|
|
for _, r := range name {
|
|
if r != '_' && (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isAppBin reports ./bin/{app} and bin/{app}, with app in [A-Za-z0-9._-].
|
|
func isAppBin(tok string) bool {
|
|
rest, ok := strings.CutPrefix(tok, "./bin/")
|
|
if !ok {
|
|
rest, ok = strings.CutPrefix(tok, "bin/")
|
|
}
|
|
if !ok || rest == "" || strings.Contains(rest, "/") {
|
|
return false
|
|
}
|
|
for _, r := range rest {
|
|
if r != '.' && r != '_' && r != '-' && (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// exampleCommandNames returns the string-literal Name of every
|
|
// bonfire.Command composite literal (including the elided elements of a
|
|
// []bonfire.Command literal) in the non-test Go files under dir. It parses
|
|
// the files; the tool never imports example code.
|
|
func exampleCommandNames(dir string) ([]string, error) {
|
|
if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
var names []string
|
|
fset := token.NewFileSet()
|
|
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := d.Name()
|
|
if d.IsDir() {
|
|
if p != dir && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || name == "testdata" || name == "node_modules" || name == "vendor") {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
|
return nil
|
|
}
|
|
f, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution)
|
|
if err != nil {
|
|
return fmt.Errorf("docsite: parse %s: %w", p, err)
|
|
}
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
lit, ok := n.(*ast.CompositeLit)
|
|
if !ok {
|
|
return true
|
|
}
|
|
switch {
|
|
case isBonfireCommand(lit.Type):
|
|
names = appendCommandName(names, lit)
|
|
case isBonfireCommandSlice(lit.Type):
|
|
for _, e := range lit.Elts {
|
|
if el, ok := e.(*ast.CompositeLit); ok && el.Type == nil {
|
|
names = appendCommandName(names, el)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docsite: scan %s: %w", dir, err)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func isBonfireCommand(expr ast.Expr) bool {
|
|
sel, ok := expr.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
pkg, ok := sel.X.(*ast.Ident)
|
|
return ok && pkg.Name == "bonfire" && sel.Sel.Name == "Command"
|
|
}
|
|
|
|
func isBonfireCommandSlice(expr ast.Expr) bool {
|
|
arr, ok := expr.(*ast.ArrayType)
|
|
return ok && isBonfireCommand(arr.Elt)
|
|
}
|
|
|
|
func appendCommandName(names []string, lit *ast.CompositeLit) []string {
|
|
for _, e := range lit.Elts {
|
|
kv, ok := e.(*ast.KeyValueExpr)
|
|
if !ok {
|
|
continue
|
|
}
|
|
key, ok := kv.Key.(*ast.Ident)
|
|
if !ok || key.Name != "Name" {
|
|
continue
|
|
}
|
|
if bl, ok := kv.Value.(*ast.BasicLit); ok && bl.Kind == token.STRING {
|
|
if v, err := strconv.Unquote(bl.Value); err == nil {
|
|
names = append(names, v)
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|