From 60acf6e820228d86311b84f3db30e8c764f07333 Mon Sep 17 00:00:00 2001 From: Arpad Ryszka Date: Wed, 10 Dec 2025 20:31:10 +0100 Subject: [PATCH] help and docs --- Makefile | 5 +- apply.go | 6 +- command.go | 87 +- command_test.go | 27 + commandline.go | 27 +- config.go | 45 +- debug.go | 23 - doclets.go | 50 +- docreflect.gen.go | 52 +- docreflect_test.go | 25 +- exec.go | 14 +- exec_test.go | 4 + format.go | 202 ---- formathelp.go | 239 ----- formatman.go | 204 ---- formatmarkdown.go | 201 ---- go.mod | 11 +- go.sum | 18 +- help.go | 1086 +++++++++++++++---- help_test.go | 1832 +++++++++++++++++++++++++++++++++ input.go | 12 +- internal/tests/testlib/lib.go | 24 +- output_test.go | 4 +- reflect.go | 22 +- reflect_test.go | 5 +- tools/exec.go | 5 + tools/lib.go | 22 +- 27 files changed, 2995 insertions(+), 1257 deletions(-) delete mode 100644 debug.go delete mode 100644 format.go delete mode 100644 formathelp.go delete mode 100644 formatman.go delete mode 100644 formatmarkdown.go create mode 100644 help_test.go diff --git a/Makefile b/Makefile index f73078b..2bde93e 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,10 @@ docreflect_test.go: $(sources) mkdir -p .build .build/wand: $(sources) iniparser.gen.go docreflect.gen.go .build - go build -o .build/wand -ldflags "-X main.version=$(shell date +%Y-%m-%d)-$(shell git rev-parse --short HEAD)" ./cmd/wand + go build \ + -o .build/wand \ + -ldflags "-X main.version=$(git show -s --format=%cs HEAD)-$(shell git rev-parse --short HEAD)" \ + ./cmd/wand install: .build/wand cp .build/wand ~/bin diff --git a/apply.go b/apply.go index dc1086c..ab3b951 100644 --- a/apply.go +++ b/apply.go @@ -18,11 +18,7 @@ func bindKeyVals(receiver reflect.Value, keyVals map[string][]string) bool { } func bindOptions(receiver reflect.Value, shortForms []string, o []option) bool { - ms := make(map[string]string) - for i := 0; i < len(shortForms); i += 2 { - ms[shortForms[i]] = shortForms[i+1] - } - + ms := shortFormsToLong(shortForms) v := make(map[string][]any) for _, oi := range o { n := oi.name diff --git a/command.go b/command.go index 0a78ddc..de666ef 100644 --- a/command.go +++ b/command.go @@ -225,42 +225,44 @@ func validateShortFormsTree(cmd Cmd) (map[string]string, map[string]string, erro mf := mapFields(cmd.impl) _, helpDefined := mf["help"] - for i := 0; i < len(cmd.shortForms); i += 2 { - s, l := cmd.shortForms[i], cmd.shortForms[i+1] - if s == "h" && l == "help" && !helpDefined { - continue - } - - r := []rune(s) - if len(r) != 1 || r[0] < 'a' || r[0] > 'z' { - return nil, nil, fmt.Errorf("invalid short form: %s", s) - } - - if err := checkShortFormDefinition(mapped, s, l); err != nil { - return nil, nil, err - } - - if err := checkShortFormDefinition(unmapped, s, l); err != nil { - return nil, nil, err - } - - _, hasField := mf[l] - _, isMapped := mapped[s] - if !hasField && !isMapped { - if unmapped == nil { - unmapped = make(map[string]string) + l2s := longFormsToShort(cmd.shortForms) + for l, ss := range l2s { + for _, s := range ss { + if s == "h" && l == "help" && !helpDefined { + continue } - unmapped[s] = l - continue - } + r := []rune(s) + if len(r) != 1 || r[0] < 'a' || r[0] > 'z' { + return nil, nil, fmt.Errorf("invalid short form: %s", s) + } - if mapped == nil { - mapped = make(map[string]string) - } + if err := checkShortFormDefinition(mapped, s, l); err != nil { + return nil, nil, err + } - delete(unmapped, s) - mapped[s] = l + if err := checkShortFormDefinition(unmapped, s, l); err != nil { + return nil, nil, err + } + + _, hasField := mf[l] + _, isMapped := mapped[s] + if !hasField && !isMapped { + if unmapped == nil { + unmapped = make(map[string]string) + } + + unmapped[s] = l + continue + } + + if mapped == nil { + mapped = make(map[string]string) + } + + delete(unmapped, s) + mapped[s] = l + } } return mapped, unmapped, nil @@ -317,27 +319,10 @@ func boolOptions(cmd Cmd) []string { n = append(n, "help") } - sfm := make(map[string][]string) - for i := 0; i < len(cmd.shortForms); i += 2 { - s, l := cmd.shortForms[i], cmd.shortForms[i+1] - sfm[l] = append(sfm[l], s) - } - var sf []string + l2s := longFormsToShort(cmd.shortForms) for _, ni := range n { - sf = append(sf, sfm[ni]...) - } - - var hasHelpShortForm bool - for i := 0; i < len(cmd.shortForms); i += 2 { - if cmd.shortForms[i] == "h" { - hasHelpShortForm = true - break - } - } - - if !hasHelp && !hasHelpShortForm { - sf = append(sf, "h") + sf = append(sf, l2s[ni]...) } return append(n, sf...) diff --git a/command_test.go b/command_test.go index 9dbee11..aa47c16 100644 --- a/command_test.go +++ b/command_test.go @@ -238,6 +238,33 @@ func TestCommand(t *testing.T) { testExec(testCase{impl: cmd, command: "foo"}, "same short form for different options", ""), ) + f3 := func(o struct{ Foo map[string]string }) string { return o.Foo["bar"] } + cmd = Command("foo", f3) + t.Run( + "free form", + testExec(testCase{impl: cmd, command: "foo --foo-bar baz"}, "option not supported"), + ) + + cmd = ShortForm(Command("foo", f3), "b", "foo-bar") + t.Run( + "short form for free form", + testExec(testCase{impl: cmd, command: "foo -b baz"}, "unmapped short form"), + ) + + f4 := func(o map[string]string) string { return o["foo"] } + cmd = ShortForm(Command("foo", f4), "f", "foo") + t.Run( + "short form for free form root", + testExec(testCase{impl: cmd, command: "foo -f baz"}, "unmapped short form"), + ) + + f5 := func(o struct{ Foo struct{ Bar string } }) string { return o.Foo.Bar } + cmd = ShortForm(Command("foo", f5), "b", "foo-bar") + t.Run( + "short form for child field", + testExec(testCase{impl: cmd, command: "foo -b baz"}, "", "baz"), + ) + cmd = Command( "foo", f0, diff --git a/commandline.go b/commandline.go index b096d02..c68870f 100644 --- a/commandline.go +++ b/commandline.go @@ -273,17 +273,34 @@ func readArgs(boolOptions, args []string) commandLine { return c } +func shortFormsToLong(sf []string) map[string]string { + s2l := make(map[string]string) + for i := 0; i < len(sf); i += 2 { + s2l[sf[i]] = sf[i+1] + } + + return s2l +} + +func longFormsToShort(sf []string) map[string][]string { + l2s := make(map[string][]string) + for i := 0; i < len(sf); i += 2 { + l2s[sf[i+1]] = append( + l2s[sf[i+1]], + sf[i], + ) + } + + return l2s +} + func hasHelpOption(cmd Cmd, o []option) bool { var mf map[string][]bind.Field if cmd.impl != nil { mf = mapFields(cmd.impl) } - sf := make(map[string]string) - for i := 0; i < len(cmd.shortForms); i += 2 { - sf[cmd.shortForms[i]] = cmd.shortForms[i+1] - } - + sf := shortFormsToLong(cmd.shortForms) for _, oi := range o { if !oi.value.isBool { continue diff --git a/config.go b/config.go index 42d2cf0..dde607f 100644 --- a/config.go +++ b/config.go @@ -2,6 +2,7 @@ package wand import ( "bytes" + "code.squareroundforest.org/arpio/textedit" "errors" "fmt" "github.com/iancoleman/strcase" @@ -62,27 +63,33 @@ func (f *file) Close() error { } func unescapeConfig(s string) string { - var ( - u []rune - escaped bool + var b bytes.Buffer + w := textedit.New( + &b, + textedit.Func( + func(r rune, escaped bool) ([]rune, bool) { + if escaped { + return []rune{r}, false + } + + if r == '\\' { + return nil, true + } + + return []rune{r}, false + }, + func(escaped bool) []rune { + if escaped { + return []rune{'\\'} + } + + return nil + }, + ), ) - r := []rune(s) - for _, ri := range r { - if escaped { - u = append(u, ri) - continue - } - - if ri == '\\' { - escaped = true - continue - } - - u = append(u, ri) - } - - return string(u) + w.Write([]byte(s)) + return b.String() } func unquoteConfig(s string) string { diff --git a/debug.go b/debug.go deleted file mode 100644 index 64b7aa9..0000000 --- a/debug.go +++ /dev/null @@ -1,23 +0,0 @@ -package wand - -import ( - "code.squareroundforest.org/arpio/notation" - "os" - "testing" -) - -func debug(a ...any) { - if !testing.Testing() { - return - } - - notation.Fprintln(os.Stderr, a...) -} - -func debugw(a ...any) { - if !testing.Testing() { - return - } - - notation.Fprintlnw(os.Stderr, a...) -} diff --git a/doclets.go b/doclets.go index 42cce4a..f911fcd 100644 --- a/doclets.go +++ b/doclets.go @@ -2,26 +2,44 @@ package wand const envDocs = `Every command line option's value can also be provided as an environment variable. Environment variable names need to use snake casing like myapp_foo_bar_baz or MYAPP_FOO_BAR_BAZ, or other casing that doesn't include the '-' dash -character, and they need to be prefixed with the name of the application, as in the base name of the command. 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, and they need to be prefixed with the name of the application, as in the base name of the command. + +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 = `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). To discard values defined in the overridden config files without defining new ones, we can -set entries with only the key, omitting the = key/value separator. 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 be present 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).` +const configDocs = `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). To discard values defined in the overridden +config files without defining new ones, we can set entries with only the key, omitting the = key/value separator. 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 be present 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/wand/src/branch/main/ini.treerack).` const versionDocs = `Print the version of the current binary release.` + +const ( + docListOptions = `Options marked with [*] accept any number of instances.` + docBoolOptions = `Bool options can be used with implicit true values.` + docOptionValues = `Option values can be set both via = or just separated by space.` +) + +const docOptionGrouping = `The short form of bool options can be combined. ` + + `The last short form does not need to be a bool option. E.g. -abc=42.` + +const docSubcommandHelpFmt = `Show help for each subcommand by calling %s help or %s --help.` + +const docSubcommandHint = `Show help for each subcommand by calling help or --help.` diff --git a/docreflect.gen.go b/docreflect.gen.go index 45379f1..43dc6eb 100644 --- a/docreflect.gen.go +++ b/docreflect.gen.go @@ -2,30 +2,34 @@ Generated with https://code.squareroundforest.org/arpio/docreflect */ - package wand + import "code.squareroundforest.org/arpio/docreflect" + func init() { -docreflect.Register("code.squareroundforest.org/arpio/wand/tools", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.Docreflect", "\nfunc(out, packageName, gopaths)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.Exec", "\nfunc(o, stdin, args)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.CacheDir", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.ClearCache", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.Import", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.InlineImport", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.NoCache", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.Man", "\nfunc(out, commandDir)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.Markdown", "\nfunc(out, o, commandDir)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.MarkdownOptions", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.MarkdownOptions.Level", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.commandReader", "\nfunc(in)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execCommandDir", "\nfunc(out, commandDir, env)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execInternal", "\nfunc(command, args)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execTransparent", "\nfunc(command, args)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execWand", "\nfunc(o, args)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execc", "\nfunc(stdin, stdout, stderr, command, args, env)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.hash", "\nfunc(expression, imports, inlineImports)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.printGoFile", "\nfunc(fn, expression, imports, inlineImports)") -docreflect.Register("code.squareroundforest.org/arpio/wand/tools.readExec", "\nfunc(o, stdin)") -} \ No newline at end of file + docreflect.Register("code.squareroundforest.org/arpio/wand/tools", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.Docreflect", "\nfunc(out, packageName, gopaths)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.Exec", "\nfunc(o, stdin, args)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.CacheDir", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.ClearCache", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.Import", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.InlineImport", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ExecOptions.NoCache", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.Man", "\nfunc(out, o, commandDir)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ManOptions", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ManOptions.DateString", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.ManOptions.Version", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.Markdown", "\nfunc(out, o, commandDir)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.MarkdownOptions", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.MarkdownOptions.Level", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.commandReader", "\nfunc(in)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execCommandDir", "\nfunc(out, commandDir, env)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execInternal", "\nfunc(command, args)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execTransparent", "\nfunc(command, args)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execWand", "\nfunc(o, args)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.execc", "\nfunc(stdin, stdout, stderr, command, args, env)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.hash", "\nfunc(expression, imports, inlineImports)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.printGoFile", "\nfunc(fn, expression, imports, inlineImports)") + docreflect.Register("code.squareroundforest.org/arpio/wand/tools.readExec", "\nfunc(o, stdin)") +} diff --git a/docreflect_test.go b/docreflect_test.go index 40d978d..70f9d69 100644 --- a/docreflect_test.go +++ b/docreflect_test.go @@ -2,16 +2,21 @@ Generated with https://code.squareroundforest.org/arpio/docreflect */ - package wand + import "code.squareroundforest.org/arpio/docreflect" + func init() { -docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Bar", "\nfunc(out, a, b, c)") -docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Baz", "\nfunc(o)") -docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Foo", "Foo sums three numbers.\n\nfunc(a, b, c)") -docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options.Duration", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options.Foo", "") -docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options.Time", "") -} \ No newline at end of file + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Bar", "\nfunc(out, a, b, c)") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Baz", "\nfunc(o)") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.CustomHelp", "\nfunc(o)") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Foo", "Foo sums three numbers.\nIt prints the sum to stdout.\n\nThe input numbers can be any integer.\n\nfunc(a, b, c)") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.OptionWithHelp", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.OptionWithHelp.Help", "Custom help.\n") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options", "") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options.Bar", "Bars, any number.\n") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options.Duration", "Duration is another option.\n") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options.Foo", "Foo is an option.\n") + docreflect.Register("code.squareroundforest.org/arpio/wand/internal/tests/testlib.Options.Time", "Time is the third option here.\n") +} diff --git a/exec.go b/exec.go index f956b14..d111406 100644 --- a/exec.go +++ b/exec.go @@ -6,6 +6,7 @@ import ( "io" "path/filepath" "strconv" + "time" ) func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, conf Config, env, args []string) { @@ -16,8 +17,14 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co return } + cmd = insertHelp(cmd) + + // build time documentation generator mode: if getenv(env, "_wandgenerate") == "man" { - if err := generateMan(stdout, cmd, conf); err != nil { + dateString := getenv(env, "_wandgeneratedate") + date, _ := time.Parse(time.DateOnly, dateString) + version := getenv(env, "_wandgenerateversion") + if err := generateMan(stdout, cmd, conf, date, version); err != nil { fmt.Fprintln(stderr, err) exit(1) } @@ -25,6 +32,7 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co return } + // build time documentation generator mode: if getenv(env, "_wandgenerate") == "markdown" { level, _ := strconv.Atoi(getenv(env, "_wandmarkdownlevel")) if err := generateMarkdown(stdout, cmd, conf, level); err != nil { @@ -35,13 +43,11 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co return } - cmd = insertHelp(cmd) - // will need root command for the config and the env: rootCmd := cmd cmd, fullCmd, args := selectCommand(cmd, args[1:]) if cmd.helpFor != nil { - if err := showHelp(stdout, cmd, conf, fullCmd); err != nil { + if err := showHelp(stdout, *cmd.helpFor, conf, fullCmd[:len(fullCmd)-1]); err != nil { fmt.Fprintln(stderr, err) exit(1) } diff --git a/exec_test.go b/exec_test.go index b95632e..f29d268 100644 --- a/exec_test.go +++ b/exec_test.go @@ -118,3 +118,7 @@ func testExec(test testCase, err string, expect ...string) func(*testing.T) { } } } + +func execTest(t *testing.T, test testCase, err string, expect ...string) { + testExec(test, err, expect...)(t) +} diff --git a/format.go b/format.go deleted file mode 100644 index 85c605e..0000000 --- a/format.go +++ /dev/null @@ -1,202 +0,0 @@ -package wand - -import ( - "fmt" - "io" - "sort" - "strings" -) - -func printer(out io.Writer) (printf func(f string, args ...any), println func(args ...any), finish func() error) { - var err error - printf = func(f string, args ...any) { - if err != nil { - return - } - - _, err = fmt.Fprintf(out, f, args...) - } - - println = func(args ...any) { - if err != nil { - return - } - - _, err = fmt.Fprintln(out, args...) - } - - finish = func() error { - return err - } - - return -} - -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]) - } - - if len(paragraph) > 0 { - paragraphs = append(paragraphs, paragraph) - } - - var cparagraphs []string - for _, p := range paragraphs { - cparagraphs = append(cparagraphs, strings.Join(p, " ")) - } - - return strings.Join(cparagraphs, "\n\n") -} - -func lines(s string) string { - p := paragraphs(s) - pp := strings.Split(p, "\n\n") - return strings.Join(pp, "\n") -} - -func manParagraphs(s string) string { - p := paragraphs(s) - pp := strings.Split(p, "\n\n") - for i := range pp { - pp[i] = fmt.Sprintf(".PP\n%s", pp[i]) - } - - return strings.Join(pp, "\n") -} - -func manLines(s string) string { - l := lines(s) - ll := strings.Split(l, "\n") - for i := range ll { - ll[i] = fmt.Sprintf(".br\n%s", ll[i]) - } - - return strings.Join(ll, "\n") -} - -func escapeTeletype(s string) string { - r := []rune(s) - for i := range r { - if r[i] >= 0x00 && r[i] <= 0x1f && r[i] != '\n' && r[i] != '\t' { - r[i] = 0xb7 - } - - if r[i] >= 0x7f && r[i] <= 0x9f { - r[i] = 0xb7 - } - } - - return string(r) -} - -func escapeRoff(s string) string { - var ( - rr []rune - lastNewline bool - ) - - r := []rune(s) - for _, ri := range r { - switch ri { - case '\\', '-', '"': - rr = append(rr, '\\', ri) - case '.', '\'': - if lastNewline { - rr = append(rr, '\\') - } - - rr = append(rr, ri) - case 0x2013: - rr = append(rr, []rune("\\(en")...) - case 0x2014: - rr = append(rr, []rune("\\(em")...) - case 0x201c: - rr = append(rr, []rune("\\(lq")...) - case 0x201d: - rr = append(rr, []rune("\\(rq")...) - case 0x2018: - rr = append(rr, []rune("\\(oq")...) - case 0x2019: - rr = append(rr, []rune("\\(cq")...) - default: - rr = append(rr, ri) - } - - lastNewline = ri == '\n' - } - - return string(rr) -} - -func escapeMD(s string) string { - var ( - rr []rune - lastDigit bool - ) - - r := []rune(s) - for _, ri := range r { - switch ri { - case '*', '_', '#', '-', '+', '[', ']', '`', '<', '>', '|', '\\': - rr = append(rr, '\\', ri) - case '.': - if lastDigit { - rr = append(rr, '\\') - } - - rr = append(rr, ri) - default: - rr = append(rr, ri) - } - - lastDigit = ri >= 0 && ri <= 9 - } - - return string(rr) -} - -func prepareOptions(o []docOption) (names []string, descriptions map[string]string) { - for _, oi := range o { - ons := []string{fmt.Sprintf("--%s", oi.name)} - for _, sn := range oi.shortNames { - ons = append(ons, fmt.Sprintf("-%s", sn)) - } - - n := strings.Join(ons, ", ") - if oi.isBool { - n = fmt.Sprintf("%s [b]", n) - } else { - n = fmt.Sprintf("%s %s", n, oi.typ) - } - - if oi.acceptsMultiple { - n = fmt.Sprintf("%s [*]", n) - } - - names = append(names, n) - if descriptions == nil { - descriptions = make(map[string]string) - } - - descriptions[n] = oi.description - } - - sort.Strings(names) - return -} diff --git a/formathelp.go b/formathelp.go deleted file mode 100644 index 2c997d7..0000000 --- a/formathelp.go +++ /dev/null @@ -1,239 +0,0 @@ -package wand - -import ( - "fmt" - "github.com/iancoleman/strcase" - "io" - "sort" - "strings" -) - -func formatHelp(out io.Writer, doc doc) error { - printf, println, finish := printer(out) - printf(escapeTeletype(doc.fullCommand)) - println() - if doc.hasImplementation || doc.synopsis.hasSubcommands { - println() - printf("Synopsis") - println() - } - - if doc.hasImplementation { - println() - printf(escapeTeletype(doc.synopsis.command)) - if doc.synopsis.hasOptions { - printf(" [options...]") - } - - for i := range doc.synopsis.arguments.names { - printf( - " [%s %s]", - doc.synopsis.arguments.names[i], - doc.synopsis.arguments.types[i], - ) - } - - if doc.synopsis.arguments.variadic { - printf("...") - min := doc.synopsis.arguments.minPositional - max := doc.synopsis.arguments.maxPositional - switch { - case min > 0 && max > 0: - printf("\nmin %d and max %d total positional arguments", min, max) - case min > 0: - printf("\nmin %d total positional arguments", min) - case max > 0: - printf("\nmax %d total positional arguments", max) - } - } - - println() - } - - if doc.synopsis.hasSubcommands { - println() - printf("%s [options or args...]", escapeTeletype(doc.synopsis.command)) - println() - println() - printf("(For the details about the available subcommands, see the related section below.)") - println() - } - - if doc.description != "" { - println() - printf(escapeTeletype(paragraphs(doc.description))) - println() - } - - if len(doc.options) > 0 { - println() - printf("Options") - if doc.hasBoolOptions || doc.hasListOptions { - println() - if doc.hasBoolOptions { - println() - printf("[b]: booelan flag, true or false, or no argument means true") - } - - if doc.hasListOptions { - println() - printf("[*]: accepts multiple instances of the same option") - } - } - - println() - println() - names, od := prepareOptions(doc.options) - - var max int - for _, n := range names { - if len(n) > max { - max = len(n) - } - } - - for i := range names { - pad := strings.Join(make([]string, max-len(names[i])+1), " ") - names[i] = fmt.Sprintf("%s:%s", names[i], pad) - } - - for _, n := range names { - printf(n) - if od[n] != "" { - printf(" %s", escapeTeletype(lines(od[n]))) - } - - println() - } - } - - if len(doc.subcommands) > 0 { - println() - printf("Subcommands") - println() - println() - - var names []string - cd := make(map[string]string) - for _, sc := range doc.subcommands { - name := sc.name - if sc.isDefault { - name = fmt.Sprintf("%s (default)", name) - } - - d := sc.description - if sc.isHelp { - d = fmt.Sprintf("Show this help. %s", d) - } - - if sc.hasHelpSubcommand { - d = fmt.Sprintf("%s - For help, see: %s %s help", d, escapeTeletype(doc.name), sc.name) - } else if sc.hasHelpOption { - d = fmt.Sprintf("%s - For help, see: %s %s --help", d, escapeTeletype(doc.name), sc.name) - } - - cd[name] = d - } - - sort.Strings(names) - - var max int - for _, n := range names { - if len(n) > max { - max = len(n) - } - } - - for i := range names { - pad := strings.Join(make([]string, max-len(names[i])+1), " ") - names[i] = fmt.Sprintf("%s%s", names[i], pad) - } - - for _, n := range names { - printf(n) - if cd[n] != "" { - printf(": %s", escapeTeletype(paragraphs(cd[n]))) - } - - println() - } - } - - if len(doc.options) > 0 && commandNameExpression.MatchString(doc.appName) { - println("Environment Variables") - println() - printf(escapeTeletype(paragraphs(envDocs))) - println() - println() - o := doc.options[0] - printf("Example environment variable:") - println() - println() - printf(strcase.ToSnake(fmt.Sprintf("%s-%s", doc.appName, o.name))) - printf("=") - if o.isBool { - printf("true") - } else { - printf("42") - } - - println() - } - - if len(doc.options) > 0 && len(doc.configFiles) > 0 { - println("Configuration Files") - println() - printf(escapeTeletype(paragraphs(configDocs))) - println() - println() - printf("Config files:") - println() - println() - for _, cf := range doc.configFiles { - if cf.fromOption { - printf("zero or more configuration files defined by the --config option") - println() - continue - } - - if cf.fn != "" { - printf(cf.fn) - if cf.optional { - printf(" (optional)") - } - - println() - continue - } - } - - println() - o := doc.options[0] - printf("Example configuration entry:") - println() - println() - printf("# default for --") - printf(o.name) - printf(":") - println() - printf(strcase.ToSnake(o.name)) - printf(" = ") - if o.isBool { - printf("true") - } else { - printf("42") - } - - println() - println() - printf("Example for discarding an inherited entry:") - println() - println() - printf("# discarding an inherited entry:") - println() - printf(strcase.ToSnake(o.name)) - println() - } - - return finish() -} diff --git a/formatman.go b/formatman.go deleted file mode 100644 index b90d901..0000000 --- a/formatman.go +++ /dev/null @@ -1,204 +0,0 @@ -package wand - -import ( - "fmt" - "github.com/iancoleman/strcase" - "io" - "strings" - "time" -) - -func formatManCommand(printf func(string, ...any), println func(...any), doc doc) { - println(".SH Synopsis") - printf(".B %s", escapeRoff(doc.synopsis.command)) - if doc.synopsis.hasOptions { - printf(" [options...]") - } - - for i := range doc.synopsis.arguments.names { - printf( - " [%s %s]", - doc.synopsis.arguments.names[i], - doc.synopsis.arguments.types[i], - ) - } - - min := doc.synopsis.arguments.minPositional - max := doc.synopsis.arguments.maxPositional - if doc.synopsis.arguments.variadic { - println("...") - if min > 0 || max > 0 { - println(".PP") - } - - switch { - case min > 0 && max > 0: - printf("min %d and max %d total positional arguments\n", min, max) - case min > 0: - printf("min %d total positional arguments\n", min) - case max > 0: - printf("max %d total positional arguments\n", max) - } - } - - if doc.synopsis.hasSubcommands { - if min > 0 || max > 0 { - println(".PP") - } - - for i, sc := range doc.subcommands { - if i > 0 { - println(".br") - } - - printf("%s %s\n", escapeRoff(doc.name), sc.name) - } - } - - if doc.description != "" { - println(".SH Description") - println(manParagraphs(escapeRoff(doc.description))) - } - - if len(doc.options) > 0 { - println(".SH Options") - if doc.hasBoolOptions || doc.hasListOptions { - println(".PP") - } - - if doc.hasBoolOptions { - println(".B [b]:") - println("booelan flag, true or false, or no argument means true") - } - - if doc.hasListOptions { - if doc.hasBoolOptions { - println(".br") - } - - println(".B [*]:") - println("accepts multiple instances of the same option") - } - - names, descriptions := prepareOptions(doc.options) - for _, n := range names { - println(".TP") - printf(".B %s\n", escapeRoff(n)) - if descriptions[n] != "" { - println(manLines(escapeRoff(descriptions[n]))) - } - } - } - - if len(doc.options) > 0 && commandNameExpression.MatchString(doc.appName) { - println(".SH Environment Variables") - println(manParagraphs(escapeRoff(envDocs))) - println(".PP Example environment variable:") - o := doc.options[0] - println(".TP") - printf(strcase.ToSnake(fmt.Sprintf("%s-%s", doc.appName, o.name))) - printf("=") - if o.isBool { - printf("true") - } else { - printf("42") - } - - println() - } - - if len(doc.options) > 0 && len(doc.configFiles) > 0 { - println(".SH Configuration Files") - println(manParagraphs(escapeRoff(configDocs))) - println(".PP Config files:") - for i, cf := range doc.configFiles { - if i > 0 { - println(".br") - } - - if cf.fromOption { - println(escapeRoff("zero or more configuration files defined by the --config option")) - continue - } - - if cf.fn != "" { - printf(escapeRoff(cf.fn)) - if cf.optional { - printf(" (optional)") - } - - println() - continue - } - } - - println(".PP Example configuration entry:") - println(".PP") - o := doc.options[0] - printf(escapeRoff(fmt.Sprintf("# default for --%s:\n", o.name))) - println(".br") - printf(escapeRoff(strcase.ToSnake(o.name))) - printf(" = ") - if o.isBool { - printf("true") - } else { - printf("42") - } - - println() - println(".PP Example for discarding an inherited entry:") - println(".PP") - println("# discarding an inherited entry:") - println(".br") - println(escapeRoff(strcase.ToSnake(o.name))) - } -} - -func formatManMultiCommand(out io.Writer, doc doc) error { - printf, println, finish := printer(out) - printf(".TH %s 1 %s \"%s\"\n", escapeRoff(doc.appName), escapeRoff(doc.date.Format(time.DateOnly)), escapeRoff(doc.appName)) - printf(".SH Name\n%s\n", escapeRoff(doc.appName)) - println(".SH Provides several commands:") - println(".PP") - allCommands := allCommands(doc) - for i, c := range allCommands { - if i > 0 { - println(".br") - } - - println(escapeRoff(c.fullCommand)) - } - - for _, c := range allCommands { - printf(".SH %s\n", escapeRoff(strings.ToUpper(c.fullCommand))) - formatManCommand(printf, println, c) - } - - return finish() -} - -func formatManSingleCommand(out io.Writer, doc doc) error { - printf, println, finish := printer(out) - printf(".TH %s 1 %s \"%s\"\n", escapeRoff(doc.appName), escapeRoff(doc.date.Format(time.DateOnly)), escapeRoff(doc.appName)) - printf(".SH Name\n%s\n", escapeRoff(doc.appName)) - formatManCommand(printf, println, doc) - return finish() -} - -func formatMan(out io.Writer, doc doc) error { - var hasSubcommands bool - for _, sc := range doc.subcommands { - if !sc.isHelp && !sc.isVersion { - continue - } - - hasSubcommands = true - break - } - - if hasSubcommands { - return formatManMultiCommand(out, doc) - } - - return formatManSingleCommand(out, doc) -} diff --git a/formatmarkdown.go b/formatmarkdown.go deleted file mode 100644 index d82fafa..0000000 --- a/formatmarkdown.go +++ /dev/null @@ -1,201 +0,0 @@ -package wand - -import ( - "fmt" - "github.com/iancoleman/strcase" - "io" - "strings" -) - -func header(level int) string { - s := make([]string, level+2) - return strings.Join(s, "#") -} - -func formatMarkdownCommand(printf func(string, ...any), println func(...any), doc doc, level int) { - printf("%s Synopsis\n\n", header(level)) - println("```") - printf(doc.synopsis.command) - if doc.synopsis.hasOptions { - printf(" [options...]") - } - - for i := range doc.synopsis.arguments.names { - printf( - " [%s %s]", - doc.synopsis.arguments.names[i], - doc.synopsis.arguments.types[i], - ) - } - - if doc.synopsis.arguments.variadic { - printf("...") - } - - println() - println("```") - - min := doc.synopsis.arguments.minPositional - max := doc.synopsis.arguments.maxPositional - if doc.synopsis.arguments.variadic { - if min > 0 || max > 0 { - println() - } - - switch { - case min > 0 && max > 0: - printf("min %d and max %d total positional arguments\n", min, max) - case min > 0: - printf("min %d total positional arguments\n", min) - case max > 0: - printf("max %d total positional arguments\n", max) - } - } - - if doc.synopsis.hasSubcommands { - println() - println("```") - for _, sc := range doc.subcommands { - printf("%s %s\n", escapeMD(doc.name), sc.name) - } - - println("```") - } - - if doc.description != "" { - printf("\n%s Description\n\n", header(level)) - println(escapeMD(paragraphs(doc.description))) - } - - if len(doc.options) > 0 { - printf("\n%s Options\n\n", header(level)) - if doc.hasBoolOptions { - printf("- [b]: booelan flag, true or false, or no argument means true\n") - } - - if doc.hasListOptions { - printf("- [*]: accepts multiple instances of the same option\n") - } - - if doc.hasBoolOptions || doc.hasListOptions { - println() - } - - names, descriptions := prepareOptions(doc.options) - for _, n := range names { - printf("- **%s**: %s\n", escapeMD(n), escapeMD(lines(descriptions[n]))) - } - } - - if len(doc.options) > 0 && commandNameExpression.MatchString(doc.appName) { - printf("\n.%s Environment Variables\n\n", header(level)) - println(escapeMD(paragraphs(envDocs))) - println() - println("Example environment variable:") - println() - o := doc.options[0] - println("```") - printf(strcase.ToSnake(fmt.Sprintf("%s-%s", doc.appName, o.name))) - printf("=") - if o.isBool { - printf("true") - } else { - printf("42") - } - - println() - println("```") - } - - if len(doc.options) > 0 && len(doc.configFiles) > 0 { - printf("\n.Configuration Files\n\n") - println(escapeMD(paragraphs(configDocs))) - println() - println("Config files:") - println() - for _, cf := range doc.configFiles { - if cf.fromOption { - println("- zero or more configuration files defined by the --config option\n") - continue - } - - if cf.fn != "" { - printf("- %s", cf.fn) - if cf.optional { - printf(" (optional)") - } - - println() - continue - } - } - - println() - println("Example configuration entry:") - println() - o := doc.options[0] - println("```") - printf("# default for --%s:\n", o.name) - printf(strcase.ToSnake(o.name)) - printf(" = ") - if o.isBool { - printf("true") - } else { - printf("42") - } - - println() - println("```") - println() - println("Example for discarding an inherited entry:") - println() - println("```") - println("# discarding an inherited entry:") - println(strcase.ToSnake(o.name)) - println("```") - } -} - -func formatMarkdownMultiCommand(out io.Writer, doc doc, level int) error { - printf, println, finish := printer(out) - printf("%s %s\n\n", header(level), escapeMD(doc.appName)) - println("Provides several commands:") - println() - allCommands := allCommands(doc) - for _, c := range allCommands { - printf("- %s\n", escapeMD(c.fullCommand)) - } - - println() - for _, c := range allCommands { - printf("%s %s\n\n", header(level+1), escapeMD(c.fullCommand)) - formatMarkdownCommand(printf, println, c, level+2) - } - - return finish() -} - -func formatMarkdownSingleCommand(out io.Writer, doc doc, level int) error { - printf, println, finish := printer(out) - printf("%s %s\n\n", header(level), doc.appName) - formatMarkdownCommand(printf, println, doc, level+1) - return finish() -} - -func formatMarkdown(out io.Writer, doc doc, level int) error { - var hasSubcommands bool - for _, sc := range doc.subcommands { - if !sc.isHelp && !sc.isVersion { - continue - } - - hasSubcommands = true - break - } - - if hasSubcommands { - return formatMarkdownMultiCommand(out, doc, level) - } - - return formatMarkdownSingleCommand(out, doc, level) -} diff --git a/go.mod b/go.mod index cdd5f03..3dbd4c5 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,20 @@ module code.squareroundforest.org/arpio/wand -go 1.25.0 +go 1.25.3 require ( code.squareroundforest.org/arpio/bind v0.0.0-20251105181644-3443251be2d5 code.squareroundforest.org/arpio/docreflect v0.0.0-20251031192707-01c5ff18fab1 code.squareroundforest.org/arpio/notation v0.0.0-20251101123932-5f5c05ee0239 + code.squareroundforest.org/arpio/textedit v0.0.0-20251207224821-c75c3965789f + code.squareroundforest.org/arpio/textfmt v0.0.0-20251207234108-fed32c8bbe18 code.squareroundforest.org/arpio/treerack v0.0.0-20251031193114-4f1c219052ae github.com/iancoleman/strcase v0.3.0 + golang.org/x/term v0.37.0 ) -require golang.org/x/mod v0.27.0 // indirect +require ( + code.squareroundforest.org/arpio/html v0.0.0-20251103020946-e262eca50ac9 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sys v0.38.0 // indirect +) diff --git a/go.sum b/go.sum index 03925f9..9e06bb2 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,22 @@ -code.squareroundforest.org/arpio/bind v0.0.0-20250905213330-4591a086be1e h1:DkOYkD12OWMAczreQESVQF7b1KsyBQq4G700oGxNy08= -code.squareroundforest.org/arpio/bind v0.0.0-20250905213330-4591a086be1e/go.mod h1:tTCmCwFABKNm3PO0Dclsp4zWhNQFTfg9+uSrgoarZFI= code.squareroundforest.org/arpio/bind v0.0.0-20251105181644-3443251be2d5 h1:SIgLIawD6Vv7rAvUobpVshLshdwFEJ0NOUrWpheS088= code.squareroundforest.org/arpio/bind v0.0.0-20251105181644-3443251be2d5/go.mod h1:tTCmCwFABKNm3PO0Dclsp4zWhNQFTfg9+uSrgoarZFI= -code.squareroundforest.org/arpio/docreflect v0.0.0-20250904132730-afd27063724e h1:f7wtGAmuTYH/VTn92sBTtKhs463q+DTtW2yKgst2kl8= -code.squareroundforest.org/arpio/docreflect v0.0.0-20250904132730-afd27063724e/go.mod h1:/3xQI36oJG8qLBxT2fSS61P5/+i1T64fTX9GHRh8XhA= code.squareroundforest.org/arpio/docreflect v0.0.0-20251031192707-01c5ff18fab1 h1:bJi41U5yGQykg6jVlD2AdWiznvx3Jg7ZpzEU85syOXw= code.squareroundforest.org/arpio/docreflect v0.0.0-20251031192707-01c5ff18fab1/go.mod h1:/3xQI36oJG8qLBxT2fSS61P5/+i1T64fTX9GHRh8XhA= -code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 h1:S4mjQHL70CuzFg1AGkr0o0d+4M+ZWM0sbnlYq6f0b3I= -code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY= +code.squareroundforest.org/arpio/html v0.0.0-20251103020946-e262eca50ac9 h1:b7voJlwe0jKH568X+O7b/JTAUrHLTSKNSSL+hhV2Q/Q= +code.squareroundforest.org/arpio/html v0.0.0-20251103020946-e262eca50ac9/go.mod h1:hq+2CENEd4bVSZnOdq38FUFOJJnF3OTQRv78qMGkNlE= code.squareroundforest.org/arpio/notation v0.0.0-20251101123932-5f5c05ee0239 h1:JvLVMuvF2laxXkIZbHC1/0xtKyKndAwIHbIIWkHqTzc= code.squareroundforest.org/arpio/notation v0.0.0-20251101123932-5f5c05ee0239/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY= -code.squareroundforest.org/arpio/treerack v0.0.0-20250820014405-1d956dcc6610 h1:I0jebdyQQfqJcwq2lT/TkUPBU8secHa5xZ+VzOdYVsw= -code.squareroundforest.org/arpio/treerack v0.0.0-20250820014405-1d956dcc6610/go.mod h1:9XhPcVt1Y1M609z02lHvEcp00dwPD9NUCoVxS2TpcH8= +code.squareroundforest.org/arpio/textedit v0.0.0-20251207224821-c75c3965789f h1:gomu8xTD953IkL3M528qVEuZ2z93C2I6Hr4vyIwE7kI= +code.squareroundforest.org/arpio/textedit v0.0.0-20251207224821-c75c3965789f/go.mod h1:nXdFdxdI69JrkIT97f+AEE4OgplmxbgNFZC5j7gsdqs= +code.squareroundforest.org/arpio/textfmt v0.0.0-20251207234108-fed32c8bbe18 h1:2aa62CYm9ld5SNoFxWzE2wUN0xjVWQ+xieoeFantdg4= +code.squareroundforest.org/arpio/textfmt v0.0.0-20251207234108-fed32c8bbe18/go.mod h1:+0G3gufMAP8SCEIrDT1D/DaVOSfjS8EwPTBs5vfxqQg= code.squareroundforest.org/arpio/treerack v0.0.0-20251031193114-4f1c219052ae h1:D28IunhepRhRSp3U2z84e3WtxbYMRzi/FwEEZg54ULM= code.squareroundforest.org/arpio/treerack v0.0.0-20251031193114-4f1c219052ae/go.mod h1:9XhPcVt1Y1M609z02lHvEcp00dwPD9NUCoVxS2TpcH8= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= diff --git a/help.go b/help.go index 8829b85..93e42b0 100644 --- a/help.go +++ b/help.go @@ -3,77 +3,43 @@ 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" ) -type ( - argumentSet struct { - count int - names []string - types []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 - typ string - description string - shortNames []string - isBool bool - acceptsMultiple bool - } - - docConfig struct { - fromOption bool - optional bool - fn string - } - - doc struct { - name string - appName string - fullCommand string - synopsis synopsis - description string - hasImplementation bool - isHelp bool - isVersion bool - isDefault bool - hasHelpSubcommand bool - hasHelpOption bool - hasConfigFromOption bool - options []docOption - hasBoolOptions bool - hasListOptions bool - arguments argumentSet - subcommands []doc - configFiles []docConfig - date time.Time - } +const ( + defaultHelpWidth = 72 + minPrintWidth = 42 + maxPrintWidth = 360 + markdownWrapWidth = 112 ) +const ( + noOptionHints = iota + commandSpecificHints + allRelevantHints +) + +var sentenceDelimiter = regexp.MustCompile("[.?!]") + func help(cmd Cmd) Cmd { - return Cmd{ + h := Cmd{ name: "help", helpFor: &cmd, shortForms: cmd.shortForms, } + + cmd.subcommands = append(cmd.subcommands, h) + return cmd } func insertHelp(cmd Cmd) Cmd { @@ -87,11 +53,11 @@ func insertHelp(cmd Cmd) Cmd { cmd.subcommands[i] = insertHelp(sc) } - if !hasHelpCmd && cmd.version == "" { - cmd.subcommands = append(cmd.subcommands, help(cmd)) + if hasHelpCmd || cmd.version != "" { + return cmd } - return cmd + return help(cmd) } func hasHelpSubcommand(cmd Cmd) bool { @@ -126,225 +92,899 @@ func suggestHelp(out io.Writer, cmd Cmd, fullCommand []string) { } } -func hasOptions(cmd Cmd) bool { - if cmd.impl == nil { - return false +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) + } } - return len(fields(cmd.impl)) > 0 + for _, sc := range cmd.subcommands { + options = append(options, allNonHelpOptions(sc)...) + } + + return options } -func allCommands(cmd doc) []doc { - commands := []doc{cmd} - for _, sc := range cmd.subcommands { - commands = append(commands, allCommands(sc)...) +func allConfigFiles(conf Config) []Config { + if conf.file != nil || conf.fromOption { + return []Config{conf} } - sort.Slice(commands, func(i, j int) bool { - return commands[i].fullCommand < commands[j].fullCommand - }) + var files []Config + for _, c := range conf.merge { + files = append(files, allConfigFiles(c)...) + } - return commands + return files } func functionParams(v any, indices []int) ([]string, []string) { var names []string r := reflect.ValueOf(v) allNames := docreflect.FunctionParams(r) - for _, i := range indices { - names = append(names, allNames[i]) + if len(allNames) == r.Type().NumIn() { + for _, i := range indices { + names = append(names, allNames[i]) + } + } else { + names = make([]string, len(indices)) } - var types []reflect.Kind - for _, i := range indices { - types = append(types, r.Type().In(i).Kind()) - } - - var stypes []string - for _, t := range types { - stypes = append(stypes, strings.ToLower(fmt.Sprint(t))) - } - - return names, stypes -} - -func constructArguments(cmd Cmd) argumentSet { - if cmd.impl == nil { - return argumentSet{} - } - - p, variadic := positional(cmd.impl) - pi := positionalIndices(cmd.impl) - ior, iow := ioParameters(cmd.impl) - names, types := functionParams(cmd.impl, pi) - if len(names) < len(p) { - names = nil - for i := 0; i < len(p); i++ { - names = append(names, fmt.Sprintf("arg%d", i)) + for i := range names { + names[i] = strings.TrimSpace(names[i]) + if names[i] == "" { + names[i] = fmt.Sprintf("arg%d", i+1) } } - return argumentSet{ - count: len(p), - names: names, - types: types, - variadic: variadic, - usesStdin: len(ior) > 0, - usesStdout: len(iow) > 0, - minPositional: cmd.minPositional, - maxPositional: cmd.maxPositional, + var types []string + for _, i := range indices { + t := r.Type().In(i) + types = append(types, scalarTypeStringOf(t)) } + + return names, types } -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 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 constructDescription(cmd Cmd) string { - if cmd.version != "" { - return versionDocs - } - - if cmd.impl == nil { - return "" - } - - return strings.TrimSpace(docreflect.Function(reflect.ValueOf(cmd.impl))) +func firstSentence(text string) string { + s := sentenceDelimiter.Split(text, 2) + s[0] = strings.TrimSpace(s[0]) + return s[0] } -func constructOptions(cmd Cmd, hasConfigFromOption bool) []docOption { - if cmd.impl == nil { +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 docCommandNameText(cmd Cmd, fullCommand []string) []string { + command := strings.Join(fullCommand, " ") + fdoc := docreflect.Function(reflect.ValueOf(cmd.impl)) + fdoc = strings.TrimSpace(fdoc) + 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 } - 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) + fdoc := docreflect.Function(reflect.ValueOf(implCmd.impl)) + fdoc = strings.TrimSpace(fdoc) + fdocp := paragraphs(fdoc) + if len(fdocp) == 0 { + return nil } - s := structParameters(cmd.impl) - d := make(map[string]string) - for _, si := range s { - f := structFields(si) + 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 { - d[fi.Name()] = docreflect.Field(si, fi.Path()...) + fields[fi.Name()] = append(fields[fi.Name()], fi) + if d := docs[fi.Name()]; d == "" { + docs[fi.Name()] = docreflect.Field(s, fi.Path()...) + } } } - var o []docOption - f := mapFields(cmd.impl) - for name, fi := range f { - opt := docOption{ - name: name, - typ: scalarTypeString(fi[0].Type()), - description: d[name], - shortNames: sf[name], - isBool: fi[0].Type() == bind.Bool, + if len(fields) == 0 { + var optionItems []DefinitionItem + if _, ok := fields["config"]; !ok && hasConfigFromOption(conf) { + optionItems = append( + optionItems, + Definition(Text("--config"), Text("Configuration file."), NoBullet()), + ) } - for _, fii := range fi { - if fii.List() { - opt.acceptsMultiple = true + 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 } } - 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) doc { - var subcommands []doc - for _, sc := range cmd.subcommands { - subcommands = append(subcommands, constructDoc(sc, conf, append(fullCommand, sc.name))) - } - - var hasBoolOptions, hasListOptions bool - options := constructOptions(cmd, hasConfigFromOption(conf)) - for _, o := range options { - if o.isBool { + if f[0].Type() == bind.Bool { hasBoolOptions = true + if len(s2l) > 1 && len(sf) > 0 { + hasGrouping = true + } } - if o.acceptsMultiple { - hasListOptions = 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), } } - return doc{ - name: cmd.name, - appName: fullCommand[0], - fullCommand: strings.Join(fullCommand, " "), - synopsis: constructSynopsis(cmd, fullCommand), - description: constructDescription(cmd), - hasImplementation: cmd.impl != nil, - isDefault: cmd.isDefault, - isHelp: cmd.helpFor != nil, - isVersion: cmd.version != "", - hasHelpSubcommand: hasHelpSubcommand(cmd), - hasHelpOption: !hasCustomHelpOption(cmd), - options: options, - hasBoolOptions: hasBoolOptions, - hasListOptions: hasListOptions, - arguments: constructArguments(cmd), - subcommands: subcommands, - configFiles: constructConfigDocs(cmd, conf), - date: time.Now(), + 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, wrapWidth int) []Entry { + subcommandsTitle := Title(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) + 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) []Entry { + var e []Entry + e = append( + e, + Title(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(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(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) []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(1, "Environment variables:")} + p := paragraphs(envDocs) + for _, pi := range p { + e = append(e, Indent(Paragraph(Text(pi)), 4, 0)) + } + + e = append(e, Indent(Title(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) []Entry { + options := allNonHelpOptions(cmd) + if len(options) == 0 { + return nil + } + + configFiles := allConfigFiles(conf) + if len(configFiles) == 0 { + return nil + } + + e := []Entry{Title(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(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(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(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, width) +} + +func manTitle(cmd Cmd, date time.Time, version string) []Entry { + 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) + } + + return docListSubcommands(cmd, []string{cmd.name}, 0) +} + +func manEnv(cmd Cmd) []Entry { + return docEnv(cmd) +} + +func manConfig(cmd Cmd, conf Config) []Entry { + return docConfig(cmd, conf) +} + +func markdownTitle(cmd Cmd) []Entry { + txt := docCommandNameText(cmd, []string{cmd.name}) + return []Entry{Title(0, strings.Join(txt, " "))} +} + +func markdownSynopsis(cmd Cmd) []Entry { + return docSynopsis(cmd, []string{cmd.name}, 1, markdownWrapWidth) +} + +func markdownDocs(cmd Cmd) []Entry { + paragraphs := docs(cmd) + if len(paragraphs) == 0 { + return nil + } + + for i := range paragraphs { + paragraphs[i] = Wrap(paragraphs[i], markdownWrapWidth) + } + + return append([]Entry{Title(1, "Description:")}, paragraphs...) +} + +func markdownOptions(cmd Cmd, conf Config) []Entry { + e := docOptions(cmd, conf, 1, 0, allRelevantHints) + for i := 1; i < len(e); i++ { + e[i] = Wrap(e[i], markdownWrapWidth) + } + + return e +} + +func markdownSubcommands(cmd Cmd, conf Config) []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) + } else { + e = docListSubcommands(cmd, []string{cmd.name}, 0) + } + + for i := range e { + e[i] = Wrap(e[i], markdownWrapWidth) + } + + return e +} + +func markdownEnv(cmd Cmd) []Entry { + e := docEnv(cmd) + for i := range e { + e[i] = Wrap(e[i], markdownWrapWidth) + } + + return e +} + +func markdownConfig(cmd Cmd, conf Config) []Entry { + e := docConfig(cmd, conf) + for i := range e { + e[i] = Wrap(e[i], markdownWrapWidth) + } + + return e } func showHelp(out io.Writer, cmd Cmd, conf Config, fullCommand []string) error { - if cmd.helpFor != nil { - cmd = *cmd.helpFor - } - - doc := constructDoc(cmd, conf, fullCommand) - return formatHelp(out, doc) + 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) error { - doc := constructDoc(cmd, conf, []string{cmd.name}) - return formatMan(out, doc) +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 { - doc := constructDoc(cmd, conf, []string{cmd.name}) - return formatMarkdown(out, doc, level) + var e []Entry + e = append(e, markdownTitle(cmd)...) + e = append(e, markdownSynopsis(cmd)...) + e = append(e, markdownDocs(cmd)...) + e = append(e, markdownOptions(cmd, conf)...) + e = append(e, markdownSubcommands(cmd, conf)...) + e = append(e, markdownEnv(cmd)...) + e = append(e, markdownConfig(cmd, conf)...) + return Markdown(out, Doc(e...)) } func showVersion(out io.Writer, cmd Cmd) error { diff --git a/help_test.go b/help_test.go new file mode 100644 index 0000000..e6a9235 --- /dev/null +++ b/help_test.go @@ -0,0 +1,1832 @@ +package wand + +import ( + "code.squareroundforest.org/arpio/wand/internal/tests/testlib" + "testing" +) + +func TestSuggestHelp(t *testing.T) { + t.Run("help command", func(t *testing.T) { + const expect = `Show help: +foo help` + + cmd := Command("foo", func() {}) + execTest(t, testCase{impl: cmd, command: "foo bar"}, expect) + }) + + t.Run("help option", func(t *testing.T) { + const expect = `Show help: +foo --help` + + cmd := Command("foo", func() {}, Command("help", func() {})) + execTest(t, testCase{impl: cmd, command: "foo bar"}, expect) + }) + + t.Run("no implementation", func(t *testing.T) { + const expect = `Show help: +foo --help` + + cmd := Group("foo", Command("help", func() {})) + execTest(t, testCase{impl: cmd, command: "foo baz"}, expect) + }) +} + +func TestHelp(t *testing.T) { + t.Run("no impl", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo + +Options: + + --help: Show help. + +Subcommands: + + bar: + help: Show help. + +Show help for each subcommand by calling foo help or foo + --help.` + + cmd := Group("foo", Command("bar", func() {})) + execTest(t, testCase{impl: cmd, command: "foo help"}, "", expect) + }) + + t.Run("no impl option", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo + +Options: + + --help: Show help. + +Subcommands: + + bar: + help: Show help. + +Show help for each subcommand by calling foo help or foo + --help.` + + cmd := Group("foo", Command("bar", func() {})) + execTest(t, testCase{impl: cmd, command: "foo --help"}, "", expect) + }) + + t.Run("empty", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + f := func() {} + execTest(t, testCase{impl: f, command: "foo help"}, "", expect) + }) + + t.Run("empty option", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + f := func() {} + execTest(t, testCase{impl: f, command: "foo --help"}, "", expect) + }) + + t.Run("args", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... [--] + foo + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + f := func(a, b int, c string) {} + execTest(t, testCase{impl: f, command: "foo help"}, "", expect) + }) + + t.Run("args option", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... [--] + foo + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + f := func(a, b int, c string) {} + execTest(t, testCase{impl: f, command: "foo --help"}, "", expect) + }) + + t.Run("variadic args", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... [--] [arg2 int]... + foo + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + f := func(a int, b ...int) {} + execTest(t, testCase{impl: f, command: "foo help"}, "", expect) + }) + + t.Run("min variadic args", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... [--] ... + foo + +Expecting min 2 total number of arguments. + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + cmd := Args(Command("foo", func(a int, b ...int) {}), 2, 0) + execTest(t, testCase{impl: cmd, command: "foo help"}, "", expect) + }) + + t.Run("max variadic args", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... [--] [arg2 int]... + foo + +Expecting max 3 total number of arguments. + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + cmd := Args(Command("foo", func(a int, b ...int) {}), 0, 3) + execTest(t, testCase{impl: cmd, command: "foo help"}, "", expect) + }) + + t.Run("min and max variadic args", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... [--] ... + foo + +Expecting min 2 and max 3 total number of arguments. + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + cmd := Args(Command("foo", func(a int, b ...int) {}), 2, 3) + execTest(t, testCase{impl: cmd, command: "foo help"}, "", expect) + }) + + t.Run("options", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --bar string [*]: Bars, any number. + --duration duration: Duration is another option. + --foo int: Foo is an option. + --time time: Time is the third option here. + --help: Show help. + +Hints: + +- Options marked with [*] accept any number of instances. +- Option values can be set both via = or just separated by space. + +Subcommands: + + help: Show help.` + + execTest(t, testCase{impl: testlib.Baz, command: "foo help"}, "", expect) + }) + + t.Run("options option", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --bar string [*]: Bars, any number. + --duration duration: Duration is another option. + --foo int: Foo is an option. + --time time: Time is the third option here. + --help: Show help. + +Hints: + +- Options marked with [*] accept any number of instances. +- Option values can be set both via = or just separated by space. + +Subcommands: + + help: Show help.` + + execTest(t, testCase{impl: testlib.Baz, command: "foo --help"}, "", expect) + }) + + t.Run("help option shadowed", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --help bool: Custom help. + +Hints: + +- Bool options can be used with implicit true values. +- Option values can be set both via = or just separated by space. + +Subcommands: + + help: Show help.` + + execTest(t, testCase{impl: testlib.CustomHelp, command: "foo help"}, "", expect) + }) + + t.Run("config option", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --config: Configuration file. + --help: Show help. + +Subcommands: + + help: Show help.` + + execTest( + t, + testCase{impl: func() {}, mergeConfTyped: []Config{ConfigFromOption()}, command: "foo help"}, + "", + expect, + ) + }) + + t.Run("grouped options", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --bar, -b string: + --foo, -f bool: + --help: Show help. + +Hints: + +- Bool options can be used with implicit true values. +- The short form of bool options can be combined. The last short form + does not need to be a bool option. E.g. -abc=42. +- Option values can be set both via = or just separated by space. + +Subcommands: + + help: Show help.` + + execTest( + t, + testCase{ + impl: ShortForm( + Command("foo", func(struct { + Foo bool + Bar string + }) {}), + "f", "foo", "b", "bar", + ), + command: "foo help", + }, + "", + expect, + ) + }) + + t.Run("single option hint", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --foo int: + --help: Show help. + +Option values can be set both via = or just separated by space. + +Subcommands: + + help: Show help.` + + execTest( + t, + testCase{ + impl: func(struct{ Foo int }) {}, + command: "foo help", + }, + "", + expect, + ) + }) + + t.Run("description", func(t *testing.T) { + const expect = `foo - Foo sums three numbers + +Synopsis: + + foo [options]... [--] + foo + +Foo sums three numbers. It prints the sum to stdout. + +The input numbers can be any integer. + +Options: + + --help: Show help. + +Subcommands: + + help: Show help.` + + execTest(t, testCase{impl: testlib.Foo, command: "foo help"}, "", expect) + }) + + t.Run("help subcommand shadowed", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... + foo + +Options: + + --help: Show help. + +Subcommands: + + help: + +Show help for each subcommand by calling foo help or foo + --help.` + + execTest( + t, + testCase{impl: Command("foo", func() {}, Command("help", func() {})), command: "foo --help"}, + "", + expect, + ) + }) + + t.Run("default subcommand", func(t *testing.T) { + const expect = `foo + +Synopsis: + + foo [options]... [--] + foo + +Options: + + --help: Show help. + +Subcommands: + + bar (default): + help: Show help. + +Show help for each subcommand by calling foo help or foo + --help.` + + execTest( + t, + testCase{impl: Group("foo", Default(Command("bar", func(a int) {}))), command: "foo help"}, + "", + expect, + ) + }) + + t.Run("subcommand description", func(t *testing.T) { + const expect = `bar + +Synopsis: + + bar + +Options: + + --help: Show help. + +Subcommands: + + foo: Foo sums three numbers + help: Show help. + version: Show version information. + +Show help for each subcommand by calling bar help or bar + --help.` + + execTest( + t, + testCase{impl: Version(Group("bar", Command("foo", testlib.Foo)), "v1"), command: "bar help"}, + "", + expect, + ) + }) +} + +func TestMan(t *testing.T) { + t.Run("basic", func(t *testing.T) { + const expect = `.TH "foo" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo - Foo sums three numbers +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo [options]... [--] + foo +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBDescription:\fR +.br +.sp 1v +.in 4 +.ti 4 +Foo sums three numbers. It prints the sum to stdout. +.br +.sp 1v +.in 4 +.ti 4 +The input numbers can be any integer. +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 10 +.ti 4 +help:\~Show help.` + + execTest( + t, + testCase{ + impl: testlib.Foo, + command: "foo", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + }, + "", + expect, + ) + }) + + t.Run("with subcommand", func(t *testing.T) { + const expect = `.TH "foo" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo [options]... + foo +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 0 +.ti 0 +Show help for each subcommand by calling help or --help. +.br +.sp 1v +.in 2 +.ti 2 +\fBfoo bar\fR +.br +.sp 1v +.in 4 +.ti 4 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo bar [options]... + foo bar +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help.` + + execTest( + t, + testCase{ + impl: Command("foo", func() {}, Command("bar", func() {})), + command: "foo", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + }, + "", + expect, + ) + }) + + t.Run("types of subcommand", func(t *testing.T) { + const expect = `.TH "foo" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo [options]... + foo +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 0 +.ti 0 +Show help for each subcommand by calling help or --help. +.br +.sp 1v +.in 2 +.ti 2 +\fBfoo bar (default)\fR +.br +.sp 1v +.in 4 +.ti 4 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo bar [options]... + foo bar +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help. +.br +.sp 1v +.in 2 +.ti 2 +\fBfoo real-foo\fR +.br +.sp 1v +.in 4 +.ti 4 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo real-foo [options]... [--] + foo real-foo +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBDescription:\fR +.br +.sp 1v +.in 4 +.ti 4 +Foo sums three numbers. It prints the sum to stdout. +.br +.sp 1v +.in 4 +.ti 4 +The input numbers can be any integer. +.br +.sp 1v +.in 4 +.ti 4 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help. +.br +.sp 1v +.in 2 +.ti 2 +\fBfoo version\fR +.br +.sp 1v +.in 4 +.ti 4 +Show version.` + + execTest( + t, + testCase{ + impl: Version( + Group("foo", Default(Command("bar", func() {})), Command("real-foo", testlib.Foo)), + "v1", + ), + command: "foo", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + }, + "", + expect, + ) + }) + + t.Run("sub-subcommands", func(t *testing.T) { + const expect = `.TH "foo" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo [options]... + foo +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 0 +.ti 0 +Show help for each subcommand by calling help or --help. +.br +.sp 1v +.in 2 +.ti 2 +\fBfoo bar\fR +.br +.sp 1v +.in 4 +.ti 4 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo bar [options]... + foo bar +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help. +.br +.sp 1v +.in 2 +.ti 2 +\fBfoo bar baz\fR +.br +.sp 1v +.in 4 +.ti 4 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo bar baz [options]... + foo bar baz +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help.` + + execTest( + t, + testCase{ + impl: Command("foo", func() {}, Command("bar", func() {}, Command("baz", func() {}))), + command: "foo", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + }, + "", + expect, + ) + }) + + t.Run("options", func(t *testing.T) { + const expect = `.TH "foo" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo [options]... + foo +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 25 +.ti 4 +--bar string [*]:\~\~\~\~Bars, any number. +.br +.in 25 +.ti 4 +--duration duration:\~Duration is another option. +.br +.in 25 +.ti 4 +--foo int:\~\~\~\~\~\~\~\~\~\~\~Foo is an option. +.br +.in 25 +.ti 4 +--time time:\~\~\~\~\~\~\~\~\~Time is the third option here. +.br +.in 25 +.ti 4 +--config:\~\~\~\~\~\~\~\~\~\~\~\~Configuration file. +.br +.in 25 +.ti 4 +--help:\~\~\~\~\~\~\~\~\~\~\~\~\~\~Show help. +.br +.sp 1v +.in 4 +.ti 4 +Hints: +.br +.sp 1v +.in 6 +.ti 4 +\(bu\~Options marked with [*] accept any number of instances. +.br +.in 6 +.ti 4 +\(bu\~Option values can be set both via = or just separated by space. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 0 +.ti 0 +Show help for each subcommand by calling help or --help. +.br +.sp 1v +.in 2 +.ti 2 +\fBfoo bar\fR +.br +.sp 1v +.in 4 +.ti 4 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo bar [options]... + foo bar +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBOptions:\fR +.br +.sp 1v +.in 15 +.ti 4 +--bar int:\~ +.br +.in 15 +.ti 4 +--config:\~\~Configuration file. +.br +.in 15 +.ti 4 +--help:\~\~\~\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBEnvironment variables:\fR +.br +.sp 1v +.in 4 +.ti 4 +Every command line option's value can also be provided as an environment variable. Environment variable names need to use snake casing like myapp_foo_bar_baz or MYAPP_FOO_BAR_BAZ, or other casing that doesn't include the '-' dash character, and they need to be prefixed with the name of the application, as in the base name of the command. +.br +.sp 1v +.in 4 +.ti 4 +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. +.br +.sp 1v +.in 4 +.ti 4 +\fBExample environment variable:\fR +.br +.sp 1v +.nf + FOO_FOO=42 +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBConfiguration:\fR +.br +.sp 1v +.in 4 +.ti 4 +Every command line option's value can also be provided as an entry in a configuration file. +.br +.sp 1v +.in 4 +.ti 4 +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. +.br +.sp 1v +.in 4 +.ti 4 +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). To discard values defined in the overridden config files without defining new ones, we can set entries with only the key, omitting the = key/value separator. Entries in the config files are overridden by the environment variables, when defined, and by the command line options when defined. +.br +.sp 1v +.in 4 +.ti 4 +Config files marked as optional don't need to be present 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/wand/src/branch/main/ini.treerack). +.br +.sp 1v +.in 4 +.ti 4 +\fBConfiguration files:\fR +.br +.sp 1v +.in 10 +.ti 8 +\(bu\~zero or more configuration files defined by the --config option +.br +.sp 1v +.in 4 +.ti 4 +\fBExample configuration entry:\fR +.br +.sp 1v +.nf + # Default value for --foo: + foo = 42 +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBExample for discarding an inherited entry:\fR +.br +.sp 1v +.nf + # Discarding an inherited entry: + foo +.fi` + + execTest( + t, + testCase{ + impl: Command("foo", testlib.Baz, Command("bar", func(struct{ Bar int }) {})), + command: "foo", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + mergeConfTyped: []Config{ConfigFromOption()}, + }, + "", + expect, + ) + }) + + t.Run("short form grouping", func(t *testing.T) { + const expect = `.TH "foo" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo [options]... + foo +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 20 +.ti 4 +--bar, -b int:\~\~ +.br +.in 20 +.ti 4 +--foo, -f bool:\~ +.br +.in 20 +.ti 4 +--help:\~\~\~\~\~\~\~\~\~Show help. +.br +.sp 1v +.in 4 +.ti 4 +Hints: +.br +.sp 1v +.in 6 +.ti 4 +\(bu\~Bool options can be used with implicit true values. +.br +.in 6 +.ti 4 +\(bu\~The short form of bool options can be combined. The last short form does not need to be a bool option. E.g. -abc=42. +.br +.in 6 +.ti 4 +\(bu\~Option values can be set both via = or just separated by space. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 10 +.ti 4 +help:\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBEnvironment variables:\fR +.br +.sp 1v +.in 4 +.ti 4 +Every command line option's value can also be provided as an environment variable. Environment variable names need to use snake casing like myapp_foo_bar_baz or MYAPP_FOO_BAR_BAZ, or other casing that doesn't include the '-' dash character, and they need to be prefixed with the name of the application, as in the base name of the command. +.br +.sp 1v +.in 4 +.ti 4 +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. +.br +.sp 1v +.in 4 +.ti 4 +\fBExample environment variable:\fR +.br +.sp 1v +.nf + FOO_FOO=true +.fi` + + execTest( + t, + testCase{ + impl: ShortForm(Command("foo", func(struct { + Foo bool + Bar int + }) {}), "f", "foo", "b", "bar"), + command: "foo", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + }, + "", + expect, + ) + }) + + t.Run("command name unsupported by env", func(t *testing.T) { + const expect = `.TH "foo.bar" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo.bar +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo.bar [options]... + foo.bar +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 12 +.ti 4 +--help:\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 10 +.ti 4 +help:\~Show help.` + + execTest( + t, + testCase{ + impl: func() {}, + command: "foo.bar", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + }, + "", + expect, + ) + }) + + t.Run("standard config", func(t *testing.T) { + const expect = `.TH "foo" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo [options]... + foo +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 15 +.ti 4 +--foo int:\~ +.br +.in 15 +.ti 4 +--config:\~\~Configuration file. +.br +.in 15 +.ti 4 +--help:\~\~\~\~Show help. +.br +.sp 1v +.in 4 +.ti 4 +Option values can be set both via = or just separated by space. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 10 +.ti 4 +help:\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBEnvironment variables:\fR +.br +.sp 1v +.in 4 +.ti 4 +Every command line option's value can also be provided as an environment variable. Environment variable names need to use snake casing like myapp_foo_bar_baz or MYAPP_FOO_BAR_BAZ, or other casing that doesn't include the '-' dash character, and they need to be prefixed with the name of the application, as in the base name of the command. +.br +.sp 1v +.in 4 +.ti 4 +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. +.br +.sp 1v +.in 4 +.ti 4 +\fBExample environment variable:\fR +.br +.sp 1v +.nf + FOO_FOO=42 +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBConfiguration:\fR +.br +.sp 1v +.in 4 +.ti 4 +Every command line option's value can also be provided as an entry in a configuration file. +.br +.sp 1v +.in 4 +.ti 4 +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. +.br +.sp 1v +.in 4 +.ti 4 +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). To discard values defined in the overridden config files without defining new ones, we can set entries with only the key, omitting the = key/value separator. Entries in the config files are overridden by the environment variables, when defined, and by the command line options when defined. +.br +.sp 1v +.in 4 +.ti 4 +Config files marked as optional don't need to be present 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/wand/src/branch/main/ini.treerack). +.br +.sp 1v +.in 4 +.ti 4 +\fBConfiguration files:\fR +.br +.sp 1v +.in 10 +.ti 8 +\(bu\~/etc/foo/config (optional) +.br +.in 10 +.ti 8 +\(bu\~/Users/arpio/.foo/config (optional) +.br +.in 10 +.ti 8 +\(bu\~/Users/arpio/.config/foo/config (optional) +.br +.in 10 +.ti 8 +\(bu\~zero or more configuration files defined by the --config option +.br +.sp 1v +.in 4 +.ti 4 +\fBExample configuration entry:\fR +.br +.sp 1v +.nf + # Default value for --foo: + foo = 42 +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBExample for discarding an inherited entry:\fR +.br +.sp 1v +.nf + # Discarding an inherited entry: + foo +.fi` + + execTest( + t, + testCase{ + impl: func(struct{ Foo int }) {}, + command: "foo", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + mergeConfTyped: []Config{SystemConfig()}, + }, + "", + expect, + ) + }) + + t.Run("bool option in env and config", func(t *testing.T) { + const expect = `.TH "foo" 1 "December 2025" "2025-12-03-abcd123" "User Commands" +.br +.sp 1v +.in 0 +.ti 0 +\fBName:\fR +.br +.sp 1v +.in 4 +.ti 4 +foo +.br +.sp 1v +.in 0 +.ti 0 +\fBSynopsis:\fR +.br +.sp 1v +.nf + foo [options]... + foo +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBOptions:\fR +.br +.sp 1v +.in 16 +.ti 4 +--foo bool:\~ +.br +.in 16 +.ti 4 +--config:\~\~\~Configuration file. +.br +.in 16 +.ti 4 +--help:\~\~\~\~\~Show help. +.br +.sp 1v +.in 4 +.ti 4 +Hints: +.br +.sp 1v +.in 6 +.ti 4 +\(bu\~Bool options can be used with implicit true values. +.br +.in 6 +.ti 4 +\(bu\~Option values can be set both via = or just separated by space. +.br +.sp 1v +.in 0 +.ti 0 +\fBSubcommands:\fR +.br +.sp 1v +.in 10 +.ti 4 +help:\~Show help. +.br +.sp 1v +.in 0 +.ti 0 +\fBEnvironment variables:\fR +.br +.sp 1v +.in 4 +.ti 4 +Every command line option's value can also be provided as an environment variable. Environment variable names need to use snake casing like myapp_foo_bar_baz or MYAPP_FOO_BAR_BAZ, or other casing that doesn't include the '-' dash character, and they need to be prefixed with the name of the application, as in the base name of the command. +.br +.sp 1v +.in 4 +.ti 4 +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. +.br +.sp 1v +.in 4 +.ti 4 +\fBExample environment variable:\fR +.br +.sp 1v +.nf + FOO_FOO=true +.fi +.br +.sp 1v +.in 0 +.ti 0 +\fBConfiguration:\fR +.br +.sp 1v +.in 4 +.ti 4 +Every command line option's value can also be provided as an entry in a configuration file. +.br +.sp 1v +.in 4 +.ti 4 +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. +.br +.sp 1v +.in 4 +.ti 4 +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). To discard values defined in the overridden config files without defining new ones, we can set entries with only the key, omitting the = key/value separator. Entries in the config files are overridden by the environment variables, when defined, and by the command line options when defined. +.br +.sp 1v +.in 4 +.ti 4 +Config files marked as optional don't need to be present 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/wand/src/branch/main/ini.treerack). +.br +.sp 1v +.in 4 +.ti 4 +\fBConfiguration files:\fR +.br +.sp 1v +.in 10 +.ti 8 +\(bu\~/etc/foo/config (optional) +.br +.in 10 +.ti 8 +\(bu\~/Users/arpio/.foo/config (optional) +.br +.in 10 +.ti 8 +\(bu\~/Users/arpio/.config/foo/config (optional) +.br +.in 10 +.ti 8 +\(bu\~zero or more configuration files defined by the --config option +.br +.sp 1v +.in 4 +.ti 4 +\fBExample configuration entry:\fR +.br +.sp 1v +.nf + # Default value for --foo: + foo = true +.fi +.br +.sp 1v +.in 4 +.ti 4 +\fBExample for discarding an inherited entry:\fR +.br +.sp 1v +.nf + # Discarding an inherited entry: + foo +.fi` + + execTest( + t, + testCase{ + impl: func(struct{ Foo bool }) {}, + command: "foo", + env: "_wandgenerate=man;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + mergeConfTyped: []Config{SystemConfig()}, + }, + "", + expect, + ) + }) +} + +func TestMarkdown(t *testing.T) { + t.Run("basic", func(t *testing.T) { + const expect = `# foo - Foo sums three numbers + +## Synopsis: + +` + "```" + ` +foo [options]... [--] +foo +` + "```" + ` + +## Description: + +Foo sums three numbers. It prints the sum to stdout. + +The input numbers can be any integer. + +## Options: + +- --help: Show help. + +## Subcommands: + +- help: Show help.` + + execTest( + t, + testCase{ + impl: testlib.Foo, + command: "foo", + env: "_wandgenerate=markdown;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + }, + "", + expect, + ) + }) + + t.Run("with subcommand", func(t *testing.T) { + const expect = `# foo + +## Synopsis: + +` + "```" + ` +foo [options]... +foo +` + "```" + ` + +## Options: + +- --help: Show help. + +## Subcommands: + +Show help for each subcommand by calling \ help or \ --help. + +### foo bar + +#### Synopsis: + +` + "```" + ` +foo bar [options]... +foo bar +` + "```" + ` + +#### Options: + +- --help: Show help.` + + execTest( + t, + testCase{ + impl: Command("foo", func() {}, Command("bar", func() {})), + command: "foo", + env: "_wandgenerate=markdown;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + }, + "", + expect, + ) + }) + + t.Run("options", func(t *testing.T) { + const expect = `# foo + +## Synopsis: + +` + "```" + ` +foo [options]... +foo +` + "```" + ` + +## Options: + +- --bar string \[\*\]: Bars, any number. +- --duration duration: Duration is another option. +- --foo int: Foo is an option. +- --time time: Time is the third option here. +- --config: Configuration file. +- --help: Show help. + +Hints: + +- Options marked with \[\*\] accept any number of instances. +- Option values can be set both via = or just separated by space. + +## Subcommands: + +- help: Show help. + +## 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 myapp\_foo\_bar\_baz or MYAPP\_FOO\_BAR\_BAZ, or other casing that doesn't include the +'-' dash character, and they need to be prefixed with the name of the application, as in the base name of the +command. + +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. + +### Example environment variable: + +` + "```" + ` +FOO_FOO=42 +` + "```" + ` + +## Configuration: + +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). To discard values defined in the overridden config files without defining new ones, we can set entries +with only the key, omitting the = key/value separator. 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 be present 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/wand/src/branch/main/ini.treerack). + +### Configuration files: + +- zero or more configuration files defined by the --config option + +### Example configuration entry: + +` + "```" + ` +# Default value for --foo: +foo = 42 +` + "```" + ` + +### Example for discarding an inherited entry: + +` + "```" + ` +# Discarding an inherited entry: +foo +` + "```" + + execTest( + t, + testCase{ + impl: testlib.Baz, + command: "foo", + env: "_wandgenerate=markdown;_wandgeneratedate=2025-12-03;_wandgenerateversion=2025-12-03-abcd123", + mergeConfTyped: []Config{ConfigFromOption()}, + }, + "", + expect, + ) + }) +} + +func TestVersion(t *testing.T) { + const expect = `v1` + + execTest( + t, + testCase{ + impl: Version(Command("foo", func() {}), "v1"), + command: "foo version", + }, + "", + expect, + ) +} diff --git a/input.go b/input.go index e81becd..a6f17df 100644 --- a/input.go +++ b/input.go @@ -51,15 +51,9 @@ func validateEnv(cmd Cmd, e env) error { } func validateOptions(cmd Cmd, o []option, conf Config) error { - ms := make(map[string]string) - ml := make(map[string]string) - for i := 0; i < len(cmd.shortForms); i += 2 { - s, l := cmd.shortForms[i], cmd.shortForms[i+1] - ms[s] = l - ml[l] = s - } - var names []string + ms := shortFormsToLong(cmd.shortForms) + ml := longFormsToShort(cmd.shortForms) mo := make(map[string][]option) for _, oi := range o { n := oi.name @@ -81,7 +75,7 @@ func validateOptions(cmd Cmd, o []option, conf Config) error { for _, n := range names { os := mo[n] en := "--" + n - if sn, ok := ml[n]; ok { + for _, sn := range ml[n] { en += ", -" + sn } diff --git a/internal/tests/testlib/lib.go b/internal/tests/testlib/lib.go index 23f2086..565bd6d 100644 --- a/internal/tests/testlib/lib.go +++ b/internal/tests/testlib/lib.go @@ -6,12 +6,30 @@ import ( ) type Options struct { - Foo int + + // Foo is an option. + Foo int + + // Bars, any number. + Bar []string + + // Duration is another option. Duration time.Duration - Time time.Time + + // Time is the third option here. + Time time.Time +} + +type OptionWithHelp struct { + + // Custom help. + Help bool } // Foo sums three numbers. +// It prints the sum to stdout. +// +// The input numbers can be any integer. func Foo(a, b, c int) int { return a + b + c } @@ -23,3 +41,5 @@ func Bar(out io.Writer, a, b, c int) int { func Baz(o Options) int { return o.Foo } + +func CustomHelp(o OptionWithHelp) {} diff --git a/output_test.go b/output_test.go index 62c6b6e..cc1da35 100644 --- a/output_test.go +++ b/output_test.go @@ -1,10 +1,10 @@ package wand import ( + "bytes" + "io" "testing" "time" - "io" - "bytes" ) func TestOutput(t *testing.T) { diff --git a/reflect.go b/reflect.go index 4f660fb..98b093b 100644 --- a/reflect.go +++ b/reflect.go @@ -115,6 +115,15 @@ func isTime(t reflect.Type) bool { return t.ConvertibleTo(reflect.TypeFor[time.Time]()) } +func isDuration(t reflect.Type) bool { + if t == nil { + return false + } + + t = unpackType(t) + return t == reflect.TypeFor[time.Duration]() +} + func isStruct(t reflect.Type) bool { if t == nil { return false @@ -254,7 +263,7 @@ func positionalIndices(f any) []int { t := r.Type() for i := 0; i < t.NumIn(); i++ { p := t.In(i) - if isTime(p) || isStruct(p) || isReader(p) || isWriter(p) { + if isStruct(p) || isReader(p) || isWriter(p) { continue } @@ -279,12 +288,23 @@ func bindable(t reflect.Type) bool { return bindable } +func scalarTypeStringOf(t reflect.Type) string { + if t == nil { + return "" + } + + t = unpackType(t) + return scalarTypeString(bind.FieldType(t.Kind())) +} + func scalarTypeString(t bind.FieldType) string { switch t { case bind.Duration: return "duration" case bind.Time: return "time" + case reflect.Interface: + return "any" default: return strings.ToLower(reflect.Kind(t).String()) } diff --git a/reflect_test.go b/reflect_test.go index de4877c..65d7cd9 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -195,12 +195,12 @@ func TestReflect(t *testing.T) { t.Run("indices of positional parameters", func(t *testing.T) { t.Run( "show function params", - testExec(testCase{impl: testlib.Foo, command: "foo help", contains: true}, "", "foo help"), + testExec(testCase{impl: testlib.Foo, command: "foo help", contains: true}, "", "foo [options]... [--] "), ) t.Run( "skip non-positional params", - testExec(testCase{impl: testlib.Bar, command: "bar help", contains: true}, "", "bar help"), + testExec(testCase{impl: testlib.Bar, command: "bar help", contains: true}, "", "bar [options]... [--] "), ) }) @@ -210,7 +210,6 @@ func TestReflect(t *testing.T) { testExec( testCase{impl: testlib.Baz, command: "baz help", contains: true}, "", - "baz help", "--foo int", ), ) diff --git a/tools/exec.go b/tools/exec.go index d697d3f..d6dc3ad 100644 --- a/tools/exec.go +++ b/tools/exec.go @@ -2,6 +2,7 @@ package tools import ( "bytes" + "fmt" "io" "os" "os/exec" @@ -19,6 +20,10 @@ func execc(stdin io.Reader, stdout, stderr io.Writer, command string, args []str } func execCommandDir(out io.Writer, commandDir string, env ...string) error { + if len(commandDir) > 0 && commandDir[0] != '/' && commandDir[0] != '.' { + commandDir = fmt.Sprintf("./%s", commandDir) + } + stderr := bytes.NewBuffer(nil) if err := execc(nil, out, stderr, "go run", []string{commandDir}, env); err != nil { io.Copy(os.Stderr, stderr) diff --git a/tools/lib.go b/tools/lib.go index 54ec0f5..294a027 100644 --- a/tools/lib.go +++ b/tools/lib.go @@ -6,6 +6,11 @@ import ( "io" ) +type ManOptions struct { + DateString string + Version string +} + type MarkdownOptions struct { Level int } @@ -14,10 +19,21 @@ func Docreflect(out io.Writer, packageName string, gopaths ...string) error { return generate.GenerateRegistry(out, packageName, gopaths...) } -func Man(out io.Writer, commandDir string) error { - return execCommandDir(out, commandDir, "_wandgenerate=man") +func Man(out io.Writer, o ManOptions, commandDir string) error { + return execCommandDir( + out, + commandDir, + "_wandgenerate=man", + fmt.Sprintf("_wandgeneratedate=%s", o.DateString), + fmt.Sprintf("_wandgenerateversion=%s", o.Version), + ) } func Markdown(out io.Writer, o MarkdownOptions, commandDir string) error { - return execCommandDir(out, commandDir, "_wandgenerate=markdown", fmt.Sprintf("_wandmarkdownlevel=%d", o.Level)) + return execCommandDir( + out, + commandDir, + "_wandgenerate=markdown", + fmt.Sprintf("_wandmarkdownlevel=%d", o.Level), + ) }