package wand import ( "code.squareroundforest.org/arpio/bind" "code.squareroundforest.org/arpio/docreflect" . "code.squareroundforest.org/arpio/textfmt" "fmt" "github.com/iancoleman/strcase" "golang.org/x/term" "io" "os" "reflect" "regexp" "sort" "strings" "time" ) const ( defaultHelpWidth = 72 minPrintWidth = 42 maxPrintWidth = 360 markdownWrapWidth = 112 ) const ( noOptionHints = iota commandSpecificHints allRelevantHints ) var sentenceDelimiter = regexp.MustCompile("[.?!]") func help(cmd Cmd) Cmd { h := Cmd{ name: "help", helpFor: &cmd, shortForms: cmd.shortForms, } cmd.subcommands = append(cmd.subcommands, h) return cmd } func insertHelp(cmd Cmd) Cmd { var hasHelpCmd bool for i, sc := range cmd.subcommands { if sc.name == "help" { hasHelpCmd = true continue } cmd.subcommands[i] = insertHelp(sc) } if hasHelpCmd || cmd.version != "" { return cmd } return help(cmd) } func hasHelpSubcommand(cmd Cmd) bool { for _, sc := range cmd.subcommands { if sc.helpFor != nil { return true } } return false } func hasCustomHelpOption(cmd Cmd) bool { if cmd.impl == nil { return false } mf := mapFields(cmd.impl) _, has := mf["help"] return has } func suggestHelp(out io.Writer, cmd Cmd, fullCommand []string) { if hasHelpSubcommand(cmd) { fmt.Fprintf(out, "Show help:\n%s help\n", strings.Join(fullCommand, " ")) return } if !hasCustomHelpOption(cmd) { fmt.Fprintf(out, "Show help:\n%s --help\n", strings.Join(fullCommand, " ")) return } } func allNonHelpOptions(cmd Cmd) []bind.Field { var options []bind.Field if cmd.impl != nil { f := fields(cmd.impl) for _, fi := range f { options = append(options, fi) } } for _, sc := range cmd.subcommands { options = append(options, allNonHelpOptions(sc)...) } return options } func allConfigFiles(conf Config) []Config { if conf.file != nil || conf.fromOption { return []Config{conf} } var files []Config for _, c := range conf.merge { files = append(files, allConfigFiles(c)...) } return files } func functionParams(v any, indices []int) ([]string, []string) { var names []string r := reflect.ValueOf(v) allNames := docreflect.FunctionParams(r) if len(allNames) == r.Type().NumIn() { for _, i := range indices { names = append(names, allNames[i]) } } else { names = make([]string, len(indices)) } for i := range names { names[i] = strings.TrimSpace(names[i]) if names[i] == "" { names[i] = fmt.Sprintf("arg%d", i+1) } } var types []string for _, i := range indices { t := r.Type().In(i) types = append(types, scalarTypeStringOf(t)) } return names, types } func paragraphs(text string) []string { l := strings.Split(text, "\n") for i := range l { l[i] = strings.TrimSpace(l[i]) } var ( pl []string p []string ) for _, li := range l { if li == "" && len(pl) > 0 { p = append(p, strings.Join(pl, " ")) pl = nil continue } if li == "" { continue } pl = append(pl, li) } if len(pl) > 0 { p = append(p, strings.Join(pl, " ")) } return p } func firstSentence(text string) string { s := sentenceDelimiter.Split(text, 2) s[0] = strings.TrimSpace(s[0]) return s[0] } func wrapWidth() int { width := defaultHelpWidth fd := int(os.Stdin.Fd()) if !term.IsTerminal(fd) { return width } w, _, err := term.GetSize(fd) if err != nil { return width } width = w - 8 if width < minPrintWidth { width = minPrintWidth } if width > maxPrintWidth { width = maxPrintWidth } return width } func implementationCommand(cmd Cmd) Cmd { if cmd.impl != nil { return cmd } for _, subCmd := range cmd.subcommands { if subCmd.isDefault { return subCmd } } return cmd } func trimName(doc, name string) string { firstWord, rest, found := strings.Cut(doc, " ") if !found { return doc } firstWord = strings.TrimSpace(firstWord) if strcase.ToKebab(firstWord) != strcase.ToKebab(name) { return doc } return strings.TrimSpace(rest) } func docCommandNameText(cmd Cmd, fullCommand []string) []string { command := strings.Join(fullCommand, " ") fdoc := docreflect.Function(reflect.ValueOf(cmd.impl)) fdoc = strings.TrimSpace(fdoc) fdoc = trimName(fdoc, cmd.name) fdocp := paragraphs(fdoc) if len(fdocp) == 0 { return []string{command} } return []string{command, "-", firstSentence(fdocp[0])} } func docCommandName(cmd Cmd, fullCommand []string) Entry { s := docCommandNameText(cmd, fullCommand) txt := make([]Txt, len(s)) for i := range s { txt[i] = Text(s[i]) } return Paragraph(Cat(txt...)) } func docSynopsis(cmd Cmd, fullCommand []string, titleLevel, wrapWidth int) []Entry { implCmd := implementationCommand(cmd) command := strings.Join(fullCommand, " ") synopsisTitle := Title(titleLevel, "Synopsis:") if implCmd.impl == nil { synopsis := Syntax(Choice(Sequence(Symbol(command), Required(Symbol("subcommand"))))) return []Entry{synopsisTitle, Indent(synopsis, 4, 0)} } pi := positionalIndices(implCmd.impl) names, types := functionParams(implCmd.impl, pi) if len(names) == 0 { synopsis := Syntax( Choice( Sequence(Symbol(command), ZeroOrMore(Symbol("options"))), Sequence(Symbol(command), Required(Symbol("subcommand"))), ), ) return []Entry{synopsisTitle, Indent(synopsis, 4, 0)} } var args []SyntaxItem _, variadic := positional(implCmd.impl) for i := range names[:len(names)-1] { args = append(args, Required(Sequence(Symbol(names[i]), Symbol(types[i])))) } lastArity := Required switch { case variadic && implCmd.minPositional >= len(pi): lastArity = OneOrMore case variadic: lastArity = ZeroOrMore } last := len(names) - 1 args = append(args, lastArity(Sequence(Symbol(names[last]), Symbol(types[last])))) var argCountHint Entry needsArgCountHint := implCmd.impl != nil && (implCmd.minPositional > 0 || implCmd.maxPositional > 0) if needsArgCountHint { var hint string switch { case implCmd.minPositional > 0 && implCmd.maxPositional > 0: hint = fmt.Sprintf( "Expecting min %d and max %d total number of arguments.", implCmd.minPositional, implCmd.maxPositional, ) case implCmd.minPositional > 0: hint = fmt.Sprintf( "Expecting min %d total number of arguments.", implCmd.minPositional, ) case implCmd.maxPositional > 0: hint = fmt.Sprintf( "Expecting max %d total number of arguments.", implCmd.maxPositional, ) } argCountHint = Paragraph(Text(hint)) } if len(args) > 0 { args = append([]SyntaxItem{Optional(Symbol("--"))}, args...) } synopsis := Syntax( Choice( Sequence(append([]SyntaxItem{Symbol(command), ZeroOrMore(Symbol("options"))}, args...)...), Sequence(Symbol(command), Required(Symbol("subcommand"))), ), ) if !needsArgCountHint { return []Entry{synopsisTitle, Indent(synopsis, 4, 0)} } return []Entry{synopsisTitle, Indent(synopsis, 4, 0), Wrap(argCountHint, wrapWidth)} } func docs(cmd Cmd) []Entry { implCmd := implementationCommand(cmd) if implCmd.impl == nil { return nil } fdoc := docreflect.Function(reflect.ValueOf(implCmd.impl)) fdoc = strings.TrimSpace(fdoc) fdoc = trimName(fdoc, implCmd.name) fdocp := paragraphs(fdoc) if len(fdocp) == 0 { return nil } var e []Entry for _, p := range fdocp { e = append(e, Paragraph(Text(p))) } return e } func docOptions(cmd Cmd, conf Config, titleLevel, wrapWidth, hintStyle int) []Entry { implCmd := implementationCommand(cmd) optionsTitle := Title(titleLevel, "Options:") fields := make(map[string][]bind.Field) docs := make(map[string]string) structs := structParameters(implCmd.impl) for _, s := range structs { f := structFields(s) for _, fi := range f { fields[fi.Name()] = append(fields[fi.Name()], fi) if d := docs[fi.Name()]; d == "" { fdocs := docreflect.Field(s, fi.Path()...) fdocs = trimName(fdocs, fi.Name()) docs[fi.Name()] = fdocs } } } if len(fields) == 0 { var optionItems []DefinitionItem if _, ok := fields["config"]; !ok && hasConfigFromOption(conf) { optionItems = append( optionItems, Definition(Text("--config"), Text("Configuration file."), NoBullet()), ) } optionItems = append( optionItems, Definition( Text("--help"), Text("Show help."), NoBullet(), ), ) return []Entry{ optionsTitle, Wrap(Indent(DefinitionList(optionItems...), 4, 0), wrapWidth), } } var ( names []string optionItems []DefinitionItem hasAcceptsMultiple, hasBoolOptions, hasGrouping bool ) for n := range fields { names = append(names, n) } sort.Strings(names) s2l := shortFormsToLong(implCmd.shortForms) l2s := longFormsToShort(implCmd.shortForms) for _, name := range names { var def string f := fields[name] sf := l2s[name] if len(sf) == 0 { def = fmt.Sprintf("--%s %s", name, scalarTypeString(f[0].Type())) } else { sfs := make([]string, len(sf)) copy(sfs, sf) for i := range sfs { sfs[i] = fmt.Sprintf("-%s", sfs[i]) } def = fmt.Sprintf( "--%s, %s %s", name, strings.Join(sfs, ", "), scalarTypeString(f[0].Type()), ) } for _, fi := range f { if fi.List() { def = fmt.Sprintf("%s [*]", def) hasAcceptsMultiple = true } } if f[0].Type() == bind.Bool { hasBoolOptions = true if len(s2l) > 1 && len(sf) > 0 { hasGrouping = true } } optionItems = append( optionItems, Definition(Text(def), Text(docs[name]), NoBullet()), ) } if _, ok := fields["config"]; !ok && hasConfigFromOption(conf) { optionItems = append( optionItems, Definition(Text("--config"), Text("Configuration file."), NoBullet()), ) } var ( helpShortForms []string helpShortFormsString string ) _, hasHelpOption := fields["help"] if !hasHelpOption { hsf := make([]string, len(l2s["help"])) copy(hsf, l2s["help"]) sort.Strings(hsf) helpShortForms = append(helpShortForms, hsf...) } if len(helpShortForms) > 0 { for i := range helpShortForms { helpShortForms[i] = fmt.Sprintf("-%s", helpShortForms[i]) } helpShortFormsString = strings.Join(helpShortForms, ", ") } if helpShortFormsString != "" { optionItems = append( optionItems, Definition( Text(fmt.Sprintf("--help, %s", helpShortFormsString)), Text("Show help."), NoBullet(), ), ) } else if !hasHelpOption { optionItems = append( optionItems, Definition(Text("--help"), Text("Show help."), NoBullet()), ) } if hintStyle == noOptionHints { return []Entry{ optionsTitle, Wrap(Indent(DefinitionList(optionItems...), 4, 0), wrapWidth), } } switch hintStyle { case allRelevantHints: var numShortForm int allOptions := allNonHelpOptions(cmd) for _, o := range allOptions { hasAcceptsMultiple = hasAcceptsMultiple || o.List() hasBoolOptions = hasBoolOptions || o.Type() == bind.Bool if _, hasShortForm := l2s[o.Name()]; hasShortForm { numShortForm++ } hasGrouping = hasGrouping || o.Type() == bind.Bool && numShortForm > 1 } } var hints []string if hasAcceptsMultiple { hints = append(hints, docListOptions) } if hasBoolOptions { hints = append(hints, docBoolOptions) } if hasGrouping { hints = append(hints, docOptionGrouping) } hints = append(hints, docOptionValues) if len(hints) == 1 { return []Entry{ optionsTitle, Wrap(Indent(DefinitionList(optionItems...), 4, 0), wrapWidth), Wrap(Paragraph(Text(hints[0])), wrapWidth), } } items := make([]ListItem, len(hints)) for i := range hints { items[i] = Item(Text(hints[i])) } return []Entry{ optionsTitle, Wrap(Indent(DefinitionList(optionItems...), 4, 0), wrapWidth), Wrap(Paragraph(Text("Hints:")), wrapWidth), Wrap(List(items...), wrapWidth), } } func docListSubcommands(cmd Cmd, fullCommand []string, level, wrapWidth int) []Entry { subcommandsTitle := Title(level+1, "Subcommands:") var ( items []DefinitionItem hasNonHelp bool ) scs := make([]Cmd, len(cmd.subcommands)) copy(scs, cmd.subcommands) sort.Slice(scs, func(i, j int) bool { return scs[i].name < scs[j].name }) for _, sc := range scs { var description string name := sc.name if sc.helpFor != nil { description = "Show help." } else if sc.version != "" { description = "Show version information." } else { if sc.isDefault { name = fmt.Sprintf("%s (default)", name) } hasNonHelp = true description = docreflect.Function(reflect.ValueOf(sc.impl)) description = strings.TrimSpace(description) description = trimName(description, sc.name) descriptionP := paragraphs(description) if len(descriptionP) > 0 { description = firstSentence(descriptionP[0]) } } items = append(items, Definition(Text(name), Text(description), NoBullet())) } if hasNonHelp { command := strings.Join(fullCommand, " ") hint := Paragraph(Text(fmt.Sprintf(docSubcommandHelpFmt, command, command))) return []Entry{ subcommandsTitle, Wrap(Indent(DefinitionList(items...), 4, 0), wrapWidth), Wrap(hint, wrapWidth), } } return []Entry{ subcommandsTitle, Wrap(Indent(DefinitionList(items...), 4, 0), wrapWidth), } } func docFullSubcommands(cmd Cmd, conf Config, level int) []Entry { var e []Entry e = append( e, Title(level+1, "Subcommands:"), Paragraph(Text(docSubcommandHint)), ) type subcommandDef struct { fullCommand []string command Cmd } var ( allSubcommands []subcommandDef collectSubcommands func(cmd Cmd) []subcommandDef ) collectSubcommands = func(cmd Cmd) []subcommandDef { var defs []subcommandDef for _, sc := range cmd.subcommands { if sc.impl != nil || sc.version != "" { defs = append(defs, subcommandDef{fullCommand: []string{cmd.name, sc.name}, command: sc}) } scDefs := collectSubcommands(sc) for i := range scDefs { scDefs[i].fullCommand = append([]string{cmd.name}, scDefs[i].fullCommand...) } defs = append(defs, scDefs...) } return defs } allSubcommands = collectSubcommands(cmd) for _, sc := range allSubcommands { command := strings.Join(sc.fullCommand, " ") if sc.command.isDefault { command = fmt.Sprintf("%s (default)", command) } e = append(e, Indent(Title(level+2, command), 2, 0)) if sc.command.version != "" { e = append(e, Indent(Paragraph(Text("Show version.")), 4, 0)) continue } synopsis := docSynopsis(sc.command, sc.fullCommand, 3, 0) for i := range synopsis { synopsis[i] = Indent(synopsis[i], 4, 0) } e = append(e, synopsis...) if docs := docs(sc.command); len(docs) > 0 { e = append(e, Indent(Title(level+3, "Description:"), 4, 0)) for i := range docs { docs[i] = Indent(docs[i], 4, 0) } e = append(e, docs...) } options := docOptions(sc.command, conf, 3, 0, noOptionHints) for i := range options { options[i] = Indent(options[i], 4, 0) } e = append(e, options...) } return e } func docEnv(cmd Cmd, level int) []Entry { // env will not work if the app has a non-standard name: if !commandNameExpression.MatchString(cmd.name) { return nil } options := allNonHelpOptions(cmd) if len(options) == 0 { return nil } e := []Entry{Title(level+1, "Environment variables:")} p := paragraphs(envDocs) for _, pi := range p { e = append(e, Indent(Paragraph(Text(pi)), 4, 0)) } e = append(e, Indent(Title(level+2, "Example environment variable:"), 4, 0)) option := options[0] name := strcase.ToScreamingSnake(fmt.Sprintf("%s-%s", cmd.name, option.Name())) value := "42" if option.Type() == bind.Bool { value = "true" } e = append(e, Indent(CodeBlock(fmt.Sprintf("%s=%s", name, value)), 4, 0)) return e } func docConfig(cmd Cmd, conf Config, level int) []Entry { options := allNonHelpOptions(cmd) if len(options) == 0 { return nil } configFiles := allConfigFiles(conf) if len(configFiles) == 0 { return nil } e := []Entry{Title(level+1, "Configuration:")} p := paragraphs(configDocs) for _, pi := range p { e = append(e, Indent(Paragraph(Text(pi)), 4, 0)) } var items []ListItem for _, cf := range configFiles { if cf.fromOption { items = append( items, Item(Text("zero or more configuration files defined by the --config option")), ) continue } text := cf.file(cmd).filename if cf.optional { text = fmt.Sprintf("%s (optional)", text) } items = append(items, Item(Text(text))) } e = append( e, Indent(Title(level+2, "Configuration files:"), 4, 0), Indent(List(items...), 8, 0), ) option := options[0] name := option.Name() value := "42" if option.Type() == bind.Bool { value = "true" } exampleCode := fmt.Sprintf( "# Default value for --%s:\n%s = %s", name, strcase.ToSnake(name), value, ) e = append( e, Indent(Title(level+2, "Example configuration entry:"), 4, 0), Indent(CodeBlock(exampleCode), 4, 0), ) discardExample := fmt.Sprintf( "# Discarding an inherited entry:\n%s", strcase.ToSnake(name), ) e = append( e, Indent(Title(level+2, "Example for discarding an inherited entry:"), 4, 0), Indent(CodeBlock(discardExample), 4, 0), ) return e } func helpCommandName(cmd Cmd, fullCommand []string) []Entry { return []Entry{docCommandName(cmd, fullCommand)} } func helpSynopsis(cmd Cmd, fullCommand []string, width int) []Entry { return docSynopsis(cmd, fullCommand, 1, width) } func helpDocs(cmd Cmd, width int) []Entry { paragraphs := docs(cmd) for i := range paragraphs { paragraphs[i] = Wrap(paragraphs[i], width) } return paragraphs } func helpOptions(cmd Cmd, conf Config, width int) []Entry { return docOptions(cmd, conf, 1, width, commandSpecificHints) } func helpSubcommands(cmd Cmd, fullCommand []string, width int) []Entry { return docListSubcommands(cmd, fullCommand, 0, width) } func manTitle(cmd Cmd, date time.Time, version string) []Entry { if version == "" { for _, sc := range cmd.subcommands { if sc.version != "" { version = sc.version break } } } return []Entry{ Title( 0, cmd.name, ManCategory("User Commands"), ManSection(1), ReleaseDate(date), ReleaseVersion(version), ), } } func manCommandName(cmd Cmd) []Entry { title := Title(1, "Name:") name := docCommandName(cmd, []string{cmd.name}) name = Indent(name, 4, 0) return []Entry{title, name} } func manSynopsis(cmd Cmd) []Entry { return docSynopsis(cmd, []string{cmd.name}, 1, 0) } func manDocs(cmd Cmd) []Entry { paragraphs := docs(cmd) if len(paragraphs) == 0 { return nil } for i := range paragraphs { paragraphs[i] = Indent(paragraphs[i], 4, 0) } return append([]Entry{Title(1, "Description:")}, paragraphs...) } func manOptions(cmd Cmd, conf Config) []Entry { e := docOptions(cmd, conf, 1, 0, allRelevantHints) for i := 1; i < len(e); i++ { e[i] = Indent(e[i], 4, 0) } return e } func manSubcommands(cmd Cmd, conf Config) []Entry { var hasSubcommands bool for _, sc := range cmd.subcommands { if sc.helpFor != nil || sc.version != "" { continue } hasSubcommands = true break } if hasSubcommands { return docFullSubcommands(cmd, conf, 0) } return docListSubcommands(cmd, []string{cmd.name}, 0, 0) } func manEnv(cmd Cmd) []Entry { return docEnv(cmd, 0) } func manConfig(cmd Cmd, conf Config) []Entry { return docConfig(cmd, conf, 0) } func markdownTitle(cmd Cmd, level int) []Entry { txt := docCommandNameText(cmd, []string{cmd.name}) return []Entry{Title(level, strings.Join(txt, " "))} } func markdownSynopsis(cmd Cmd, level int) []Entry { return docSynopsis(cmd, []string{cmd.name}, level+1, markdownWrapWidth) } func markdownDocs(cmd Cmd, level int) []Entry { paragraphs := docs(cmd) if len(paragraphs) == 0 { return nil } for i := range paragraphs { paragraphs[i] = Wrap(paragraphs[i], markdownWrapWidth) } return append([]Entry{Title(level+1, "Description:")}, paragraphs...) } func markdownOptions(cmd Cmd, conf Config, level int) []Entry { e := docOptions(cmd, conf, level+1, 0, allRelevantHints) for i := 1; i < len(e); i++ { e[i] = Wrap(e[i], markdownWrapWidth) } return e } func markdownSubcommands(cmd Cmd, conf Config, level int) []Entry { var hasSubcommands bool for _, sc := range cmd.subcommands { if sc.helpFor != nil || sc.version != "" { continue } hasSubcommands = true break } var e []Entry if hasSubcommands { e = docFullSubcommands(cmd, conf, level) } else { e = docListSubcommands(cmd, []string{cmd.name}, level, 0) } for i := range e { e[i] = Wrap(e[i], markdownWrapWidth) } return e } func markdownEnv(cmd Cmd, level int) []Entry { e := docEnv(cmd, level) for i := range e { e[i] = Wrap(e[i], markdownWrapWidth) } return e } func markdownConfig(cmd Cmd, conf Config, level int) []Entry { e := docConfig(cmd, conf, level) for i := range e { e[i] = Wrap(e[i], markdownWrapWidth) } return e } func showHelp(out io.Writer, cmd Cmd, conf Config, fullCommand []string) error { var e []Entry width := wrapWidth() e = append(e, helpCommandName(cmd, fullCommand)...) e = append(e, helpSynopsis(cmd, fullCommand, width)...) e = append(e, helpDocs(cmd, width)...) e = append(e, helpOptions(cmd, conf, width)...) e = append(e, helpSubcommands(cmd, fullCommand, width)...) return Teletype(out, Doc(e...)) } func generateMan(out io.Writer, cmd Cmd, conf Config, date time.Time, version string) error { var e []Entry e = append(e, manTitle(cmd, date, version)...) e = append(e, manCommandName(cmd)...) e = append(e, manSynopsis(cmd)...) e = append(e, manDocs(cmd)...) e = append(e, manOptions(cmd, conf)...) e = append(e, manSubcommands(cmd, conf)...) e = append(e, manEnv(cmd)...) e = append(e, manConfig(cmd, conf)...) return Runoff(out, Doc(e...)) } func generateMarkdown(out io.Writer, cmd Cmd, conf Config, level int) error { var e []Entry e = append(e, markdownTitle(cmd, level)...) e = append(e, markdownSynopsis(cmd, level)...) e = append(e, markdownDocs(cmd, level)...) e = append(e, markdownOptions(cmd, conf, level)...) e = append(e, markdownSubcommands(cmd, conf, level)...) e = append(e, markdownEnv(cmd, level)...) e = append(e, markdownConfig(cmd, conf, level)...) return Markdown(out, Doc(e...)) } func showVersion(out io.Writer, cmd Cmd) error { _, err := fmt.Fprintln(out, cmd.version) return err }