diff --git a/.gitignore b/.gitignore index b5c1962..0355fb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -iniparser.go -docreflect.go +iniparser.gen.go +docreflect.gen.go .bin diff --git a/Makefile b/Makefile index b1ced0d..813284b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/config.go b/config.go index 27faea9..dec6519 100644 --- a/config.go +++ b/config.go @@ -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}) diff --git a/exec.go b/exec.go index 1224072..a60ab9e 100644 --- a/exec.go +++ b/exec.go @@ -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) } diff --git a/formathelp.go b/formathelp.go new file mode 100644 index 0000000..ed0fb71 --- /dev/null +++ b/formathelp.go @@ -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 [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 +} diff --git a/formatman.go b/formatman.go new file mode 100644 index 0000000..f068931 --- /dev/null +++ b/formatman.go @@ -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 +} diff --git a/formatmarkdown.go b/formatmarkdown.go new file mode 100644 index 0000000..7ec5bcc --- /dev/null +++ b/formatmarkdown.go @@ -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 +} diff --git a/help.go b/help.go index a3620af..b5dab1b 100644 --- a/help.go +++ b/help.go @@ -5,19 +5,46 @@ 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 - names []string - variadic bool - usesStdin bool - usesStdout bool + count int + names []string + 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 - isHelp bool - isDefault bool - hasHelpSubcommand bool - hasHelpOption bool - options []docOption - arguments argumentSet - subcommands []doc + 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 } ) @@ -141,11 +177,13 @@ func constructArguments(cmd Cmd) argumentSet { } return argumentSet{ - count: count, - names: names, - variadic: t.IsVariadic(), - usesStdin: len(ior) > 0, - usesStdout: len(iow) > 0, + count: count, + names: names, + 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 + } + + continue } - _, err = fmt.Fprintln(w, a...) + paragraph = append(paragraph, l[i]) } - printf := func(f string, a ...any) { - if err != nil { - return - } + paragraphs = append(paragraphs, paragraph) - _, err = fmt.Fprintf(w, f, a...) + 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 ...]") - } - - for _, n := range doc.synopsis.arguments.names { - printf(" %s", n) - } - - if doc.synopsis.arguments.variadic { - printf("...") - } - - println() - if doc.synopsis.hasSubcommands { - printf("%s [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 + return strings.Join(cparagraphs, "\n\n") } -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) } diff --git a/notes.txt b/notes.txt index bb668b6..e69de29 100644 --- a/notes.txt +++ b/notes.txt @@ -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 diff --git a/output.go b/output.go index 5ee8825..29ef935 100644 --- a/output.go +++ b/output.go @@ -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) + } } } diff --git a/reflect.go b/reflect.go index 384b407..023e704 100644 --- a/reflect.go +++ b/reflect.go @@ -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 { diff --git a/wand.go b/wand.go index 0d0fe6f..e2bcc63 100644 --- a/wand.go +++ b/wand.go @@ -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 fileReader( - path.Join(os.Getenv("HOME"), ".config", cmd.name, "config"), - ) + 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 {