wand/help.go
2025-08-24 04:46:54 +02:00

345 lines
8.9 KiB
Go

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