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"} // commandToken finds `summer ` and `./bin/ ` at the start // of a shell command (after an optional "$ " prompt). var commandToken = regexp.MustCompile(`^(?:\$\s+)?(summer|\./bin/[A-Za-z0-9._-]+)\s+(\S+)`) // 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) { m := commandToken.FindStringSubmatch(strings.TrimSpace(cmd)) if m == nil || strings.HasPrefix(m[2], "-") { continue } name := m[2] known := tool[name] if m[1] != "summer" { known = app[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) } lines := strings.Split(string(d.body), "\n") for _, f := range scanFences(lines) { lang, _, _ := strings.Cut(f.info, " ") if !slices.Contains(shellLangs, lang) { continue } end := f.close if end < 0 { end = len(lines) } for i := f.open + 1; i < end; i++ { check(d, d.line+i, lines[i]) } } } return problems, nil } // 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 }