package wand import ( "code.squareroundforest.org/arpio/docreflect" "fmt" "io" "reflect" "strings" ) const defaultWrap = 112 const envDocs = `Environment Variables Every command line option's value can also be provided as an environment variable. Environment variable names need to use snake casing like foo_bar_baz or FOO_BAR_BAZ, or other casing that doesn't include the '-' dash character. When both the environment variable and the command line option is defined, the command line option overrides the environment variable. Multiple values for the same environment variable can be defined by concatenating the values with the ':' separator character. When overriding multiple values with command line options, all the environment values of the same field are dropped.` const configOptionDocs = `the config option allows to define zero or more configuration files at arbitrary path` const configDocs = `Configuration Files Every command line option's value can also be provided as an entry in a configuration file. Configuration file entries can use keys with different casings, e.g. snake case foo_bar_baz, or kebab case foo-bar-baz. The keys of the entries can use a limited set of characters: [a-zA-Z0-9_-], and the first character needs to be one of [a-zA-Z_]. Entry values can consist of any characters, except for newline, control characters, " (quote) and \ (backslash), or the values can be quoted, in which case they can consist of any characters, spanning multiple lines, and only the " (quote) and \ (backslash) characters need to be escaped by the \ (backslash) character. Configuration files allow multiple entries with the same key, when if the associated command line option also allows multiple instances (marked with [*]). When an entry is defined multiple configuration files, the effective value is overridden in the order of the definition of the possible config files (see the listing order below). Entries in the config files are overridden by the environment variables, when defined, and by the command line options when defined. Config files marked as optional don't need to exist in the file system, but if they exist, then they must contain valid configuration syntax which is wand's flavor of .ini files (https://code.squareroundforest.org/arpio/ini.treerack).` type ( argumentSet struct { count int names []string variadic bool usesStdin bool usesStdout bool minPositional int maxPositional int } synopsis struct { command string hasOptions bool arguments argumentSet hasSubcommands bool } docOption struct { name string description string shortNames []string isBool bool acceptsMultiple bool } docConfig struct { fromOption bool optional bool fn string } doc struct { name string fullCommand string synopsis synopsis description string hasImplementation bool isHelp bool isDefault bool hasHelpSubcommand bool hasHelpOption bool hasConfigFromOption bool options []docOption arguments argumentSet subcommands []doc configFiles []docConfig } ) func help() Cmd { return Cmd{ name: "help", isHelp: true, } } func insertHelp(cmd Cmd) Cmd { var hasHelpCmd bool for i, sc := range cmd.subcommands { cmd.subcommands[i] = insertHelp(sc) if cmd.name == "help" { hasHelpCmd = true } } if !hasHelpCmd { cmd.subcommands = append(cmd.subcommands, help()) } return cmd } func hasHelpSubcommand(cmd Cmd) bool { for _, sc := range cmd.subcommands { if sc.isHelp { return true } } return false } func hasCustomHelpOption(cmd Cmd) bool { 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 hasOptions(cmd Cmd) bool { if cmd.impl == nil { return false } v := reflect.ValueOf(cmd.impl) t := v.Type() t = unpack(t) s := structParameters(t) return len(fields(s...)) > 0 } func functionParams(v reflect.Value, skip []int) []string { names := docreflect.FunctionParams(v) for _, i := range skip { names = append(names[:i], names[i+1:]...) } return names } func constructArguments(cmd Cmd) argumentSet { if cmd.impl == nil { return argumentSet{} } v := reflect.ValueOf(cmd.impl) t := unpack(v.Type()) p := positionalParameters(t) ior, iow := ioParameters(p) count := len(p) - len(ior) - len(iow) names := functionParams(v, append(ior, iow...)) if len(names) < count { names = nil for i := 0; i < count; i++ { names = append(names, fmt.Sprintf("arg%d", i)) } } return argumentSet{ count: count, names: names, variadic: t.IsVariadic(), usesStdin: len(ior) > 0, usesStdout: len(iow) > 0, minPositional: cmd.minPositional, maxPositional: cmd.maxPositional, } } func constructSynopsis(cmd Cmd, fullCommand []string) synopsis { return synopsis{ command: strings.Join(fullCommand, " "), hasOptions: hasOptions(cmd), arguments: constructArguments(cmd), hasSubcommands: len(cmd.subcommands) > 0, } } func constructDescription(cmd Cmd) string { if cmd.impl == nil { return "" } return strings.TrimSpace(docreflect.Function(reflect.ValueOf(cmd.impl))) } func constructOptions(cmd Cmd, hasConfigFromOption bool) []docOption { if cmd.impl == nil { return nil } sf := make(map[string][]string) for i := 0; i < len(cmd.shortForms); i += 2 { l, s := cmd.shortForms[i], cmd.shortForms[i+1] sf[l] = append(sf[l], s) } t := unpack(reflect.ValueOf(cmd.impl).Type()) s := structParameters(t) d := make(map[string]string) for _, si := range s { f := fields(si) for _, fi := range f { d[fi.name] = docreflect.Field(si, fi.path...) } } var o []docOption f := mapFields(cmd.impl) for name, fi := range f { opt := docOption{ name: name, description: d[name], shortNames: sf[name], isBool: fi[0].typ.Kind() == reflect.Bool, } for _, fii := range fi { if fii.acceptsMultiple { opt.acceptsMultiple = true } } o = append(o, opt) } if hasConfigFromOption { o = append(o, docOption{ name: "config", description: configOptionDocs, shortNames: sf["config"], acceptsMultiple: true, }) } return o } func constructConfigDocs(cmd Cmd, conf Config) []docConfig { var docs []docConfig if conf.file != nil { docs = append(docs, docConfig{fn: conf.file(cmd).filename, optional: conf.optional}) return docs } if conf.fromOption { docs = append(docs, docConfig{fromOption: true}) return docs } for _, m := range conf.merge { docs = append(docs, constructConfigDocs(cmd, m)...) } return docs } func constructDoc(cmd Cmd, conf Config, fullCommand []string, hasConfigFromOption bool) doc { var subcommands []doc for _, sc := range cmd.subcommands { subcommands = append(subcommands, constructDoc(sc, conf, append(fullCommand, sc.name), hasConfigFromOption)) } return doc{ name: fullCommand[len(fullCommand)-1], fullCommand: strings.Join(fullCommand, " "), synopsis: constructSynopsis(cmd, fullCommand), description: constructDescription(cmd), hasImplementation: cmd.impl != nil, isDefault: cmd.isDefault, isHelp: cmd.isHelp, hasHelpSubcommand: hasHelpSubcommand(cmd), hasHelpOption: hasCustomHelpOption(cmd), options: constructOptions(cmd, hasConfigFromOption), arguments: constructArguments(cmd), subcommands: subcommands, configFiles: constructConfigDocs(cmd, conf), } } func paragraphs(s string) string { var ( paragraph []string paragraphs [][]string ) l := strings.Split(s, "\n") for i := range l { l[i] = strings.TrimSpace(l[i]) if l[i] == "" { if len(paragraph) > 0 { paragraphs, paragraph = append(paragraphs, paragraph), nil } continue } paragraph = append(paragraph, l[i]) } paragraphs = append(paragraphs, paragraph) var cparagraphs []string for _, p := range paragraphs { cparagraphs = append(cparagraphs, strings.Join(p, " ")) } return strings.Join(cparagraphs, "\n\n") } func showHelp(out io.Writer, cmd Cmd, fullCommand []string, conf Config) error { doc := constructDoc(cmd, conf, fullCommand, hasConfigFromOption(conf)) return formatHelp(out, doc) } func generateMan(out io.Writer, cmd Cmd, conf Config) error { doc := constructDoc(cmd, conf, []string{cmd.name}, hasConfigFromOption(conf)) return formatMan(out, doc) } func generateMarkdown(out io.Writer, cmd Cmd, conf Config, level int) error { doc := constructDoc(cmd, conf, []string{cmd.name}, hasConfigFromOption(conf)) return formatMarkdown(out, doc, level) }