complete help

This commit is contained in:
Arpad Ryszka 2025-08-24 04:46:54 +02:00
parent f206c694b2
commit 1dac4b0af0
12 changed files with 484 additions and 243 deletions

4
.gitignore vendored
View File

@ -1,3 +1,3 @@
iniparser.go
docreflect.go
iniparser.gen.go
docreflect.gen.go
.bin

View File

@ -1,8 +1,8 @@
SOURCES = $(shell find . -name "*.go" | grep -v iniparser.go | grep -v docreflect.go)
SOURCES = $(shell find . -name "*.go" | grep -v iniparser.gen.go | grep -v docreflect.gen.go)
default: build
lib: $(SOURCES) iniparser.go docreflect.go
lib: $(SOURCES) iniparser.gen.go docreflect.gen.go
go build
go build ./tools
@ -20,23 +20,24 @@ cover: .cover
showcover: .cover
go tool cover -html .cover
fmt: $(SOURCES) iniparser.go
fmt: $(SOURCES) iniparser.gen.go docreflect.gen.go
go fmt ./...
iniparser.go: ini.treerack
go run script/ini-parser/parser.go wand < ini.treerack > iniparser.go || rm iniparser.go
iniparser.gen.go: ini.treerack
go run script/ini-parser/parser.go wand < ini.treerack > iniparser.gen.go || rm iniparser.gen.go
docreflect.go: $(SOURCES)
docreflect.gen.go: $(SOURCES)
go run script/docreflect/docs.go \
wand \
code.squareroundforest.org/arpio/docreflect/generate \
code.squareroundforest.org/arpio/wand/tools \
> docreflect.go
> docreflect.gen.go \
|| rm docreflect.gen.go
.bin:
mkdir -p .bin
wand: $(SOURCES) iniparser.go docreflect.go .bin
wand: $(SOURCES) iniparser.gen.go docreflect.gen.go .bin
go build -o .bin/wand ./cmd/wand
install: wand

View File

@ -19,7 +19,7 @@ type config struct {
originalNames map[string]string
}
func fileReader(filename string) io.ReadCloser {
func fileReader(filename string) *file {
return &file{
filename: filename,
}
@ -129,7 +129,7 @@ func readConfigFromOption(cmd Cmd, cl commandLine, conf Config) (config, error)
continue
}
c = append(c, Config{file: func(Cmd) io.ReadCloser { return fileReader(o.value.str) }})
c = append(c, Config{file: func(Cmd) *file { return fileReader(o.value.str) }})
}
return readConfig(cmd, cl, Config{merge: c})

View File

@ -19,7 +19,7 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co
}
if os.Getenv("wandgenerate") == "man" {
if err := generateMan(stdout, cmd); err != nil {
if err := generateMan(stdout, cmd, conf); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}
@ -29,7 +29,7 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co
if os.Getenv("wandgenerate") == "markdown" {
level, _ := strconv.Atoi(os.Getenv("wandmarkdownlevel"))
if err := generateMarkdown(stdout, cmd, level); err != nil {
if err := generateMarkdown(stdout, cmd, conf, level); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}
@ -47,7 +47,7 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co
}
if cmd.helpRequested {
if err := showHelp(stdout, cmd, fullCmd); err != nil {
if err := showHelp(stdout, cmd, fullCmd, conf); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}
@ -58,7 +58,7 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co
bo := boolOptions(cmd)
cl := readArgs(bo, args)
if hasHelpOption(cmd, cl.options) {
if err := showHelp(stdout, cmd, fullCmd); err != nil {
if err := showHelp(stdout, cmd, fullCmd, conf); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}

252
formathelp.go Normal file
View File

@ -0,0 +1,252 @@
package wand
import (
"fmt"
"github.com/iancoleman/strcase"
"io"
"sort"
"strings"
)
func formatHelp(w io.Writer, doc doc) error {
var err error
println := func(a ...any) {
if err != nil {
return
}
_, err = fmt.Fprintln(w, a...)
}
printf := func(f string, a ...any) {
if err != nil {
return
}
_, err = fmt.Fprintf(w, f, a...)
}
printf(doc.fullCommand)
println()
if doc.hasImplementation || doc.synopsis.hasSubcommands {
println()
printf("Synopsis")
println()
}
if doc.hasImplementation {
println()
printf(doc.synopsis.command)
if doc.synopsis.hasOptions {
printf(" [options ...]")
}
for _, n := range doc.synopsis.arguments.names {
printf(" %s", n)
}
if doc.synopsis.arguments.variadic {
printf("...")
if doc.synopsis.arguments.minPositional > 0 {
printf(" min %d arguments", doc.synopsis.arguments.minPositional)
}
if doc.synopsis.arguments.maxPositional > 0 {
printf(" max %d arguments", doc.synopsis.arguments.maxPositional)
}
}
println()
}
if doc.synopsis.hasSubcommands {
if !doc.hasImplementation {
println()
}
printf("%s <subcommand> [options or args...]", doc.synopsis.command)
println()
println()
printf("(For the details about the available subcommands, see the according section below.)")
println()
}
if len(doc.description) > 0 {
println()
printf(paragraphs(doc.description))
println()
}
if len(doc.options) > 0 {
println()
printf("Options")
println()
println()
printf("[*]: accepts multiple instances of the same option")
println()
printf("[b]: booelan flag, true or false, or no argument means true")
println()
println()
var names []string
od := make(map[string]string)
for _, o := range doc.options {
ons := []string{fmt.Sprintf("--%s", o.name)}
for _, sn := range o.shortNames {
ons = append(ons, fmt.Sprintf("-%s", sn))
}
n := strings.Join(ons, ", ")
if o.acceptsMultiple {
n = fmt.Sprintf("%s [*]", n)
}
if o.isBool {
n = fmt.Sprintf("%s [b]", n)
}
names = append(names, n)
od[n] = o.description
}
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 od[n] != "" {
printf(": %s", paragraphs(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, doc.name, sc.name)
} else if sc.hasHelpOption {
d = fmt.Sprintf("%s - For help, see: %s %s --help", d, 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", paragraphs(cd[n]))
}
println()
}
}
if len(doc.options) > 0 {
printf(paragraphs(envDocs))
println()
println()
o := doc.options[0]
printf("Example environment variable:")
println()
println()
printf(strcase.ToSnake(o.name))
printf("=")
if o.isBool {
printf("true")
} else {
printf("42")
}
println()
}
if len(doc.options) > 0 && len(doc.configFiles) > 0 {
printf(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)
println()
printf(strcase.ToSnake(o.name))
printf(" = ")
if o.isBool {
printf("true")
} else {
printf("42")
}
println()
}
return err
}

11
formatman.go Normal file
View File

@ -0,0 +1,11 @@
package wand
import "io"
func formatMan(out io.Writer, doc doc) error {
// if no subcommands, then similar to help
// otherwise:
// title
// all commands
return nil
}

11
formatmarkdown.go Normal file
View File

@ -0,0 +1,11 @@
package wand
import "io"
func formatMarkdown(out io.Writer, doc doc, level int) error {
// if no subcommands, then similar to help
// otherwise:
// title
// all commands
return nil
}

279
help.go
View File

@ -5,12 +5,37 @@ import (
"fmt"
"io"
"reflect"
"sort"
"strings"
)
const defaultWrap = 112
const envDocs = `Environment Variables
Every command line option's value can also be provided as an environment variable. Environment variable names need to use
snake casing like foo_bar_baz or FOO_BAR_BAZ, or other casing that doesn't include the '-' dash character. When both the
environment variable and the command line option is defined, the command line option overrides the environment variable.
Multiple values for the same environment variable can be defined by concatenating the values with the ':' separator
character. When overriding multiple values with command line options, all the environment values of the same field are
dropped.`
const configOptionDocs = `the config option allows to define zero or more configuration files at arbitrary path`
const configDocs = `Configuration Files
Every command line option's value can also be provided as an entry in a configuration file. Configuration file entries
can use keys with different casings, e.g. snake case foo_bar_baz, or kebab case foo-bar-baz. The keys of the entries
can use a limited set of characters: [a-zA-Z0-9_-], and the first character needs to be one of [a-zA-Z_]. Entry values can
consist of any characters, except for newline, control characters, " (quote) and \ (backslash), or the values can be quoted,
in which case they can consist of any characters, spanning multiple lines, and only the " (quote) and \ (backslash)
characters need to be escaped by the \ (backslash) character. Configuration files allow multiple entries with the same key,
when if the associated command line option also allows multiple instances (marked with [*]). When an entry is defined
multiple configuration files, the effective value is overridden in the order of the definition of the possible config files
(see the listing order below). Entries in the config files are overridden by the environment variables, when defined, and by
the command line options when defined. Config files marked as optional don't need to exist in the file system, but if they
exist, then they must contain valid configuration syntax which is wand's flavor of .ini files
(https://code.squareroundforest.org/arpio/ini.treerack).`
type (
argumentSet struct {
count int
@ -18,6 +43,8 @@ type (
variadic bool
usesStdin bool
usesStdout bool
minPositional int
maxPositional int
}
synopsis struct {
@ -35,18 +62,27 @@ type (
acceptsMultiple bool
}
docConfig struct {
fromOption bool
optional bool
fn string
}
doc struct {
name string
fullCommand string
synopsis synopsis
description string
hasImplementation bool
isHelp bool
isDefault bool
hasHelpSubcommand bool
hasHelpOption bool
hasConfigFromOption bool
options []docOption
arguments argumentSet
subcommands []doc
configFiles []docConfig
}
)
@ -146,6 +182,8 @@ func constructArguments(cmd Cmd) argumentSet {
variadic: t.IsVariadic(),
usesStdin: len(ior) > 0,
usesStdout: len(iow) > 0,
minPositional: cmd.minPositional,
maxPositional: cmd.maxPositional,
}
}
@ -166,7 +204,7 @@ func constructDescription(cmd Cmd) string {
return strings.TrimSpace(docreflect.Function(reflect.ValueOf(cmd.impl)))
}
func constructOptions(cmd Cmd) []docOption {
func constructOptions(cmd Cmd, hasConfigFromOption bool) []docOption {
if cmd.impl == nil {
return nil
}
@ -206,13 +244,41 @@ func constructOptions(cmd Cmd) []docOption {
o = append(o, opt)
}
if hasConfigFromOption {
o = append(o, docOption{
name: "config",
description: configOptionDocs,
shortNames: sf["config"],
acceptsMultiple: true,
})
}
return o
}
func constructDoc(cmd Cmd, fullCommand []string) doc {
func constructConfigDocs(cmd Cmd, conf Config) []docConfig {
var docs []docConfig
if conf.file != nil {
docs = append(docs, docConfig{fn: conf.file(cmd).filename, optional: conf.optional})
return docs
}
if conf.fromOption {
docs = append(docs, docConfig{fromOption: true})
return docs
}
for _, m := range conf.merge {
docs = append(docs, constructConfigDocs(cmd, m)...)
}
return docs
}
func constructDoc(cmd Cmd, conf Config, fullCommand []string, hasConfigFromOption bool) doc {
var subcommands []doc
for _, sc := range cmd.subcommands {
subcommands = append(subcommands, constructDoc(sc, append(fullCommand, sc.name)))
subcommands = append(subcommands, constructDoc(sc, conf, append(fullCommand, sc.name), hasConfigFromOption))
}
return doc{
@ -220,204 +286,59 @@ func constructDoc(cmd Cmd, fullCommand []string) doc {
fullCommand: strings.Join(fullCommand, " "),
synopsis: constructSynopsis(cmd, fullCommand),
description: constructDescription(cmd),
hasImplementation: cmd.impl != nil,
isDefault: cmd.isDefault,
isHelp: cmd.isHelp,
hasHelpSubcommand: hasHelpSubcommand(cmd),
hasHelpOption: hasCustomHelpOption(cmd),
options: constructOptions(cmd),
options: constructOptions(cmd, hasConfigFromOption),
arguments: constructArguments(cmd),
subcommands: subcommands,
configFiles: constructConfigDocs(cmd, conf),
}
}
func formatHelp(w io.Writer, doc doc) error {
var err error
println := func(a ...any) {
if err != nil {
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
}
_, err = fmt.Fprintln(w, a...)
continue
}
printf := func(f string, a ...any) {
if err != nil {
return
paragraph = append(paragraph, l[i])
}
_, err = fmt.Fprintf(w, f, a...)
paragraphs = append(paragraphs, paragraph)
var cparagraphs []string
for _, p := range paragraphs {
cparagraphs = append(cparagraphs, strings.Join(p, " "))
}
printf(doc.fullCommand)
println()
println()
printf("Synopsis")
println()
println()
printf(doc.synopsis.command)
if doc.synopsis.hasOptions {
printf(" [options ...]")
return strings.Join(cparagraphs, "\n\n")
}
for _, n := range doc.synopsis.arguments.names {
printf(" %s", n)
}
if doc.synopsis.arguments.variadic {
printf("...")
}
println()
if doc.synopsis.hasSubcommands {
printf("%s <subcommand> [options or args...]", doc.synopsis.command)
println()
println()
printf("(For the details about the available subcommands, see the according section below.)")
}
if len(doc.description) > 0 {
println()
println()
printf(doc.description)
}
if len(doc.options) > 0 {
println()
println()
printf("Options")
println()
println()
printf("[*]: accepts multiple instances of the same option")
println()
printf("[b]: booelan flag, true or false, or no argument means true")
println()
var names []string
od := make(map[string]string)
for _, o := range doc.options {
ons := []string{fmt.Sprintf("--%s", o.name)}
for _, sn := range o.shortNames {
ons = append(ons, fmt.Sprintf("-%s", sn))
}
n := strings.Join(ons, ", ")
if o.acceptsMultiple {
n = fmt.Sprintf("%s [*]", n)
}
if o.isBool {
n = fmt.Sprintf("%s [b]", n)
}
names = append(names, n)
od[n] = o.description
}
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 {
println()
printf(n)
if od[n] != "" {
printf(": %s", od[n])
}
}
}
if len(doc.subcommands) > 0 {
println()
println()
printf("Subcommands")
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, doc.name, sc.name)
} else if sc.hasHelpOption {
d = fmt.Sprintf("%s - For help, see: %s %s --help", d, 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 {
println()
printf(n)
if cd[n] != "" {
printf(": %s", cd[n])
}
}
}
println()
return err
}
func showHelp(out io.Writer, cmd Cmd, fullCommand []string) error {
doc := constructDoc(cmd, fullCommand)
func showHelp(out io.Writer, cmd Cmd, fullCommand []string, conf Config) error {
doc := constructDoc(cmd, conf, fullCommand, hasConfigFromOption(conf))
return formatHelp(out, doc)
}
func formatMan(out io.Writer, doc doc) error {
// if no subcommands, then similar to help
// otherwise:
// title
// all commands
return nil
}
func generateMan(out io.Writer, cmd Cmd) error {
doc := constructDoc(cmd, []string{cmd.name})
func generateMan(out io.Writer, cmd Cmd, conf Config) error {
doc := constructDoc(cmd, conf, []string{cmd.name}, hasConfigFromOption(conf))
return formatMan(out, doc)
}
func formatMarkdown(out io.Writer, doc doc) error {
// if no subcommands, then similar to help
// otherwise:
// title
// all commands
return nil
}
func generateMarkdown(out io.Writer, cmd Cmd, level int) error {
doc := constructDoc(cmd, []string{cmd.name})
return formatMarkdown(out, doc)
func generateMarkdown(out io.Writer, cmd Cmd, conf Config, level int) error {
doc := constructDoc(cmd, conf, []string{cmd.name}, hasConfigFromOption(conf))
return formatMarkdown(out, doc, level)
}

View File

@ -1,7 +0,0 @@
help:
- what if cmd.impl is nil, but there is a default?
- config in help
- min/max args in help
- env vars in help
- test: method docs
- testing formatting may need to be necessary for the help docs

View File

@ -1,23 +1,63 @@
package wand
import (
"code.squareroundforest.org/arpio/notation"
"fmt"
"io"
"reflect"
)
func printOutput(w io.Writer, o []any) error {
wraperr := func(err error) error {
return fmt.Errorf("error copying output: %w", err)
}
for _, oi := range o {
r, ok := oi.(io.Reader)
if ok {
if _, err := io.Copy(w, r); err != nil {
return fmt.Errorf("error copying output: %w", err)
return wraperr(err)
}
continue
}
if _, err := fmt.Fprintf(w, "%v\n", oi); err != nil {
return fmt.Errorf("error printing output: %w", err)
t := reflect.TypeOf(oi)
if t.Implements(reflect.TypeFor[fmt.Stringer]()) {
if _, err := fmt.Fprintln(w, oi); err != nil {
return wraperr(err)
}
}
t = unpack(t, reflect.Pointer)
switch t.Kind() {
case reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.String,
reflect.UnsafePointer:
if _, err := fmt.Fprintln(w, oi); err != nil {
return wraperr(err)
}
default:
if _, err := notation.Fprintwt(w, oi); err != nil {
return wraperr(err)
}
if _, err := fmt.Fprintln(w); err != nil {
return wraperr(err)
}
}
}

View File

@ -5,6 +5,7 @@ import (
"github.com/iancoleman/strcase"
"io"
"reflect"
"slices"
"strconv"
"strings"
)
@ -44,14 +45,16 @@ func pack(v reflect.Value, t reflect.Type) reflect.Value {
return s
}
func unpack[T packedKind[T]](p T) T {
switch p.Kind() {
case reflect.Pointer,
reflect.Slice:
return unpack(p.Elem())
default:
return p
func unpack[T packedKind[T]](p T, kinds ...reflect.Kind) T {
if len(kinds) == 0 {
kinds = []reflect.Kind{reflect.Pointer, reflect.Slice}
}
if slices.Contains(kinds, p.Kind()) {
return unpack(p.Elem(), kinds...)
}
return p
}
func isReader(t reflect.Type) bool {

21
wand.go
View File

@ -1,13 +1,13 @@
package wand
import (
"io"
"fmt"
"os"
"path"
)
type Config struct {
file func(Cmd) io.ReadCloser
file func(Cmd) *file
merge []Config
fromOption bool
optional bool
@ -70,20 +70,29 @@ func OptionalConfig(conf Config) Config {
func Etc() Config {
return OptionalConfig(Config{
file: func(cmd Cmd) io.ReadCloser {
file: func(cmd Cmd) *file {
return fileReader(path.Join("/etc", cmd.name, "config"))
},
})
}
func UserConfig() Config {
return OptionalConfig(Config{
file: func(cmd Cmd) io.ReadCloser {
return OptionalConfig(MergeConfig(
Config{
file: func(cmd Cmd) *file {
return fileReader(
path.Join(os.Getenv("HOME"), fmt.Sprintf(".%s", cmd.name), "config"),
)
},
},
Config{
file: func(cmd Cmd) *file {
return fileReader(
path.Join(os.Getenv("HOME"), ".config", cmd.name, "config"),
)
},
})
},
))
}
func ConfigFromOption() Config {