Files
summercms/modules/bonfire/list.go
Jakub Zych fb32886775 feat(bonfire): Artisan help, examples, JSON listing and error hints
- help <command> and <command> --help print Description, Usage,
  Arguments, Options with defaults, Global options, Help and Examples
- Command.Examples and Command.Hidden (appended, additive); list --all
  shows hidden commands, list --format=json prints a bonfire.Listing
- CheckDocs reports commands lacking description, long help, examples or
  argument and option descriptions
- Error block with the ERROR badge, WithHint explanation and Try lines
  ({bin} replaced), cobra suggestions as Try, -v error chain, messages
  scrubbed by sunscreen; Reported errors print nothing and exit 1
2026-10-21 12:21:24 +02:00

305 lines
9.7 KiB
Go

package bonfire
import (
"context"
"encoding/json"
"fmt"
"io"
"slices"
"strings"
"github.com/charmbracelet/colorprofile"
)
// builtinCommands describes the root's own commands, help and list. They are
// registered by NewRoot, so plugins cannot use their names.
func builtinCommands() []Command {
return []Command{
{
Name: "help",
Description: "Display help for a command",
Long: "Prints the description, usage, arguments, options, long help and examples of a command. Without a command name it prints the command list.",
Args: []Arg{{Name: "command_name", Description: "The command name"}},
Examples: []Example{
{Command: "help migrate", Description: "Help for the migrate command"},
},
},
{
Name: "list",
Description: "List commands",
Long: "Prints the commands grouped by namespace. --all includes hidden commands; --format=json prints every command with its arguments, options and examples.",
Flags: []Flag{
{Name: "all", Description: "Show hidden commands too", Bare: true},
{Name: "format", Description: "The output format (txt or json)", Default: "txt"},
},
Examples: []Example{
{Command: "list --all", Description: "Every command, hidden ones included"},
{Command: "list --format=json", Description: "The catalog for scripts and agents"},
},
},
}
}
func builtinCommand(name string) Command {
for _, c := range builtinCommands() {
if c.Name == name {
return c
}
}
return Command{Name: name}
}
// usageName is how a binary is invoked in usage lines and examples: summer
// for the tool, bin/<name> for an application binary.
func usageName(name string) string {
if name == "summer" {
return name
}
return "bin/" + name
}
// listCommand is the list command with its run function.
func (st *rootState) listCommand() Command {
c := builtinCommand("list")
c.Run = func(_ context.Context, in Input, _ Output) error {
all, _ := in.Flag("all")
format, _ := in.Flag("format")
switch format {
case "", "txt":
return st.renderList(st.globals(), all == "true")
case "json":
return st.renderJSON()
default:
return WithHint(Usage(fmt.Errorf("list: unknown format %q", format)),
"list prints txt (the default) or json.", "{bin} list --format=json")
}
}
return c
}
// helpCommand is the help command: the list without an argument, a
// command's help with one.
func (st *rootState) helpCommand() Command {
c := builtinCommand("help")
c.Run = func(_ context.Context, in Input, _ Output) error {
args := in.Args()
if len(args) == 0 {
return st.renderList(st.globals(), false)
}
name := args[0]
for _, cmd := range st.all() {
if cmd.Name == name {
return st.renderHelp(cmd, st.globals())
}
}
err := Usage(fmt.Errorf("Command %q is not defined.", name))
var try []string
for _, s := range st.root.SuggestionsFor(name) {
try = append(try, "{bin} help "+s)
}
if len(try) == 0 {
try = []string{"{bin} list"}
}
return WithHint(err, "", strings.Join(try, "\n"))
}
return c
}
// all returns the root's commands and its builtins.
func (st *rootState) all() []Command {
return append(append([]Command(nil), st.commands...), builtinCommands()...)
}
// renderList writes the Artisan list: the version header, usage, the global
// options and the commands, top-level names first and then one block per
// namespace (the text before the first colon). Hidden commands are left out
// unless all is true.
func (st *rootState) renderList(g Globals, all bool) error {
p := DefaultPalette()
var top []Command
groups := map[string][]Command{}
width := 0
for _, c := range st.all() {
if c.Hidden && !all {
continue
}
ns, _, namespaced := strings.Cut(c.Name, ":")
if namespaced {
groups[ns] = append(groups[ns], c)
} else {
top = append(top, c)
}
width = max(width, len(c.Name))
}
byName := func(a, b Command) int { return strings.Compare(a.Name, b.Name) }
slices.SortFunc(top, byName)
namespaces := make([]string, 0, len(groups))
for ns := range groups {
namespaces = append(namespaces, ns)
}
slices.Sort(namespaces)
var b strings.Builder
b.WriteString(p.Header.Render(st.version.Header(st.name)) + "\n\n")
b.WriteString(p.Header.Render("Usage:") + "\n")
b.WriteString(" " + usageName(st.name) + " <command> [options] [arguments]\n\n")
b.WriteString(p.Header.Render("Options:") + "\n")
writeGlobalOptions(&b, p)
b.WriteString("\n" + p.Header.Render("Available commands:") + "\n")
line := func(c Command) {
pad := strings.Repeat(" ", width-len(c.Name)+2)
b.WriteString(" " + p.Success.Render(c.Name) + pad + c.Description + "\n")
}
for _, c := range top {
line(c)
}
for _, ns := range namespaces {
b.WriteString(" " + p.Header.Render(ns) + "\n")
cmds := groups[ns]
slices.SortFunc(cmds, byName)
for _, c := range cmds {
line(c)
}
}
return writeStyled(st.out, g, b.String())
}
// renderJSON writes Describe of the root as indented JSON.
func (st *rootState) renderJSON() error {
data, err := json.MarshalIndent(Describe(st.name, st.all(), st.version), "", " ")
if err != nil {
return err
}
_, err = st.out.Write(append(data, '\n'))
return err
}
// writeGlobalOptions writes the global option lines, aligned.
func writeGlobalOptions(b *strings.Builder, p Palette) {
width := 0
for _, o := range globalOptions {
width = max(width, len(o.flags))
}
for _, o := range globalOptions {
pad := strings.Repeat(" ", width-len(o.flags)+2)
b.WriteString(" " + p.Success.Render(o.flags) + pad + o.description + "\n")
}
}
// writeStyled writes s to w through a colorprofile writer, so palette
// styles follow --ansi, --no-ansi, NO_COLOR and the terminal.
func writeStyled(w io.Writer, g Globals, s string) error {
cw := &colorprofile.Writer{Forward: w, Profile: DetectProfile(w, g.Color, nil)}
_, err := io.WriteString(cw, s)
return err
}
// Listing is the JSON catalog of a binary's commands (list --format=json).
type Listing struct {
Application ApplicationInfo `json:"application"`
GlobalOptions []OptionInfo `json:"global_options"`
Commands []CommandInfo `json:"commands"`
}
// ApplicationInfo names the binary and its versions in a Listing.
type ApplicationInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Framework string `json:"framework"`
}
// CommandInfo is one command of a Listing.
type CommandInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Long string `json:"long"`
Usage string `json:"usage"`
Hidden bool `json:"hidden"`
Passthrough bool `json:"passthrough"`
Arguments []ArgumentInfo `json:"arguments"`
Options []OptionInfo `json:"options"`
Examples []ExampleInfo `json:"examples"`
}
// ArgumentInfo is one positional argument of a CommandInfo.
type ArgumentInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Required bool `json:"required"`
}
// OptionInfo is one flag of a CommandInfo or a global option.
type OptionInfo struct {
Name string `json:"name"`
Shorthand string `json:"shorthand"`
Description string `json:"description"`
Default string `json:"default"`
Bare bool `json:"bare"`
Repeatable bool `json:"repeatable"`
}
// ExampleInfo is one example of a CommandInfo, the command line without the
// binary.
type ExampleInfo struct {
Command string `json:"command"`
Description string `json:"description"`
}
// globalOptionInfos are the global options as Listing entries.
var globalOptionInfos = []OptionInfo{
{Name: flagHelp, Shorthand: "h", Description: "Display help for the given command", Bare: true},
{Name: flagQuiet, Shorthand: "q", Description: "Do not output any message", Bare: true},
{Name: flagVerbose, Shorthand: "v", Description: "Increase verbosity (-v, -vv or -vvv)", Bare: true, Repeatable: true},
{Name: flagANSI, Description: "Force ANSI output", Bare: true},
{Name: flagNoANSI, Description: "Disable ANSI output", Bare: true},
{Name: flagNoInteraction, Shorthand: "n", Description: "Do not ask any interactive question", Bare: true},
{Name: flagEnv, Description: "The environment the command runs under"},
}
// Describe returns the Listing of a binary named name with the given
// commands, sorted by name: every command, hidden ones included, with its
// arguments, options and examples. NewRoot's list --format=json adds the
// list and help commands.
func Describe(name string, commands []Command, v Version) Listing {
l := Listing{
Application: ApplicationInfo{
Name: name,
Version: cleanVersion(v.App),
Framework: cleanVersion(v.Framework),
},
GlobalOptions: append([]OptionInfo(nil), globalOptionInfos...),
Commands: make([]CommandInfo, 0, len(commands)),
}
if v.Framework == "" {
l.Application.Framework = l.Application.Version
}
for _, c := range commands {
info := CommandInfo{
Name: c.Name,
Description: c.Description,
Long: c.Long,
Usage: helpUsage(c),
Hidden: c.Hidden,
Passthrough: c.Passthrough,
Arguments: []ArgumentInfo{},
Options: []OptionInfo{},
Examples: []ExampleInfo{},
}
for _, a := range c.Args {
info.Arguments = append(info.Arguments, ArgumentInfo{Name: a.Name, Description: a.Description, Required: a.Required})
}
for _, f := range c.Flags {
info.Options = append(info.Options, OptionInfo{
Name: f.Name, Shorthand: f.Shorthand, Description: f.Description,
Default: f.Default, Bare: f.Bare, Repeatable: f.Repeatable,
})
}
for _, e := range c.Examples {
info.Examples = append(info.Examples, ExampleInfo{Command: e.Command, Description: e.Description})
}
l.Commands = append(l.Commands, info)
}
slices.SortFunc(l.Commands, func(a, b CommandInfo) int { return strings.Compare(a.Name, b.Name) })
return l
}