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/ 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 }