This commit is contained in:
Arpad Ryszka 2025-08-26 03:21:35 +02:00
parent 1dac4b0af0
commit 796b3c2a4b
23 changed files with 2241 additions and 655 deletions

BIN
.bin/wand Executable file

Binary file not shown.

1664
.cover

File diff suppressed because it is too large Load Diff

2
.gitignore vendored
View File

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

View File

@ -6,7 +6,7 @@ lib: $(SOURCES) iniparser.gen.go docreflect.gen.go
go build
go build ./tools
build: lib wand
build: lib .build/wand
check: $(SOURCES) build
go test -count 1 ./...
@ -24,7 +24,7 @@ fmt: $(SOURCES) iniparser.gen.go docreflect.gen.go
go fmt ./...
iniparser.gen.go: ini.treerack
go run script/ini-parser/parser.go wand < ini.treerack > iniparser.gen.go || rm iniparser.gen.go
go run script/ini-parser/parser.go wand < ini.treerack > iniparser.gen.go || rm -f iniparser.gen.go
docreflect.gen.go: $(SOURCES)
go run script/docreflect/docs.go \
@ -32,13 +32,19 @@ docreflect.gen.go: $(SOURCES)
code.squareroundforest.org/arpio/docreflect/generate \
code.squareroundforest.org/arpio/wand/tools \
> docreflect.gen.go \
|| rm docreflect.gen.go
|| rm -f docreflect.gen.go
.bin:
mkdir -p .bin
.build:
mkdir -p .build
wand: $(SOURCES) iniparser.gen.go docreflect.gen.go .bin
go build -o .bin/wand ./cmd/wand
.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
install: wand
cp .bin/wand ~/bin
install: .build/wand
cp .build/wand ~/bin
clean:
rm -rf .build
rm -f docreflect.gen.go
rm -f iniparser.gen.go
rm -f .cover

View File

@ -176,6 +176,10 @@ func createStructArg(t reflect.Type, shortForms []string, c config, e env, o []o
}
func createPositional(t reflect.Type, v string) reflect.Value {
if t.Kind() == reflect.Interface {
return reflect.ValueOf(v)
}
tup := unpack(t)
sv := reflect.ValueOf(scan(tup, v))
return pack(sv, t)
@ -223,7 +227,7 @@ func processResults(t reflect.Type, out []reflect.Value) ([]any, error) {
var err error
last := len(out) - 1
isErrorType := t.Out(last) == reflect.TypeOf(err)
isErrorType := t.Out(last) == reflect.TypeFor[error]()
if isErrorType && !out[last].IsZero() {
err = out[last].Interface().(error)
}

View File

@ -5,10 +5,15 @@ import (
"code.squareroundforest.org/arpio/wand/tools"
)
var version = "dev"
func main() {
docreflect := Command("docreflect", tools.Docreflect)
man := Command("manpages", tools.Man)
md := Command("markdown", tools.Markdown)
exec := Default(Command("exec", tools.Exec))
Exec(Command("wand", nil, docreflect, man, md, exec), Etc(), UserConfig())
exec := Command("exec", tools.Exec)
wand := Command("wand", nil, docreflect, man, md, Default(exec))
wand = Version(wand, version)
conf := MergeConfig(Etc(), UserConfig())
Exec(wand, conf)
}

View File

@ -4,9 +4,12 @@ import (
"errors"
"fmt"
"reflect"
"regexp"
"slices"
)
var commandNameExpression = regexp.MustCompile("^[a-zA-Z_][a-zA-Z_0-9]*$")
func command(name string, impl any, subcmds ...Cmd) Cmd {
return Cmd{
name: name,
@ -74,7 +77,7 @@ func validateParameter(visited map[reflect.Type]bool, t reflect.Type) error {
return validateParameter(visited, t)
case reflect.Interface:
if t.NumMethod() > 0 {
return errors.New("'non-empty' interface parameter")
return errors.New("non-empty interface parameter")
}
return nil
@ -203,11 +206,19 @@ func validateShortForms(cmd Cmd, assignedShortForms map[string]string) error {
return nil
}
func validateCommandTree(cmd Cmd, conf Config, assignedShortForms map[string]string) error {
func validateCommandTree(cmd Cmd, conf Config, assignedShortForms map[string]string, root bool) error {
if cmd.isHelp {
return nil
}
if cmd.version != "" {
return nil
}
if !root && !commandNameExpression.MatchString(cmd.name) {
return fmt.Errorf("command name is not a valid symbol: '%s'", cmd.name)
}
if cmd.impl != nil {
if err := validateImpl(cmd, conf); err != nil {
return fmt.Errorf("%s: %w", cmd.name, err)
@ -236,7 +247,7 @@ func validateCommandTree(cmd Cmd, conf Config, assignedShortForms map[string]str
}
names[s.name] = true
if err := validateCommandTree(s, conf, assignedShortForms); err != nil {
if err := validateCommandTree(s, conf, assignedShortForms, false); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
@ -275,7 +286,7 @@ func allShortForms(cmd Cmd) []string {
func validateCommand(cmd Cmd, conf Config) error {
assignedShortForms := make(map[string]string)
if err := validateCommandTree(cmd, conf, assignedShortForms); err != nil {
if err := validateCommandTree(cmd, conf, assignedShortForms, true); err != nil {
return err
}

View File

@ -182,11 +182,6 @@ func selectCommand(cmd Cmd, args []string) (Cmd, []string, []string) {
return defaultCommand(cmd), []string{cmd.name}, args
}
if sc.isHelp {
cmd.helpRequested = true
return cmd, []string{cmd.name}, args[1:]
}
cmd, fullCommand, args := selectCommand(sc, args[1:])
fullCommand = append([]string{cmd.name}, fullCommand...)
return cmd, fullCommand, args

View File

@ -1,10 +1,12 @@
package wand
import (
"bytes"
"errors"
"fmt"
"github.com/iancoleman/strcase"
"io"
"io/ioutil"
"os"
)
@ -60,8 +62,14 @@ func (f *file) Close() error {
}
func readConfigFile(cmd Cmd, conf Config) (config, error) {
f := conf.file(cmd)
var f io.ReadCloser
if conf.test == "" {
f = conf.file(cmd)
defer f.Close()
} else {
f = ioutil.NopCloser(bytes.NewBufferString(conf.test))
}
doc, err := parse(f)
if err != nil {
if conf.optional && (errors.Is(err, os.ErrPermission) || errors.Is(err, os.ErrNotExist)) {
@ -165,7 +173,7 @@ func readMergeConfig(cmd Cmd, cl commandLine, conf Config) (config, error) {
}
func readConfig(cmd Cmd, cl commandLine, conf Config) (config, error) {
if conf.file != nil {
if conf.file != nil || conf.test != "" {
return readConfigFile(cmd, conf)
}

27
doclets.go Normal file
View File

@ -0,0 +1,27 @@
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. 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 versionDocs = `Print the version of the current binary release.`

59
exec.go
View File

@ -3,7 +3,6 @@ package wand
import (
"errors"
"fmt"
"github.com/iancoleman/strcase"
"io"
"os"
"path/filepath"
@ -13,9 +12,10 @@ import (
func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, conf Config, env, args []string) {
cmd = insertHelp(cmd)
_, cmd.name = filepath.Split(args[0])
cmd.name = strcase.ToKebab(cmd.name)
if err := validateCommand(cmd, conf); err != nil {
panic(err)
fmt.Fprintf(stderr, "program error: %v\n", err)
exit(1)
return
}
if os.Getenv("wandgenerate") == "man" {
@ -39,6 +39,39 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co
e := readEnv(cmd.name, env)
cmd, fullCmd, args := selectCommand(cmd, args[1:])
if cmd.isHelp {
if err := showHelp(stdout, cmd, conf, fullCmd); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}
return
}
if cmd.version != "" {
if err := showVersion(stdout, cmd); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}
return
}
var bo []string
if cmd.impl != nil {
bo = boolOptions(cmd)
}
cl := readArgs(bo, args)
if hasHelpOption(cmd, cl.options) {
if err := showHelp(stdout, cmd, conf, fullCmd); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}
return
}
if cmd.impl == nil {
fmt.Fprintln(stderr, errors.New("subcommand not specified"))
suggestHelp(stderr, cmd, fullCmd)
@ -46,26 +79,6 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co
return
}
if cmd.helpRequested {
if err := showHelp(stdout, cmd, fullCmd, conf); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}
return
}
bo := boolOptions(cmd)
cl := readArgs(bo, args)
if hasHelpOption(cmd, cl.options) {
if err := showHelp(stdout, cmd, fullCmd, conf); err != nil {
fmt.Fprintln(stderr, err)
exit(1)
}
return
}
c, err := readConfig(cmd, cl, conf)
if err != nil {
fmt.Fprintf(stderr, "configuration error: %v", err)

View File

@ -1,23 +1,37 @@
package wand
/*
import (
"bytes"
"fmt"
"io"
"strings"
"testing"
)
func testExec(impl any, env, commandLine, err string, expect ...string) func(*testing.T) {
type testCase struct {
impl any
stdin string
conf string
env string
command string
}
func testExec(test testCase, err string, expect ...string) func(*testing.T) {
return func(t *testing.T) {
var exitCode int
exit := func(code int) { exitCode = code }
var stdinr io.Reader
if test.stdin != "" {
stdinr = bytes.NewBuffer([]byte(test.stdin))
}
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
cmd := wrap(impl)
e := strings.Split(env, ";")
a := strings.Split(commandLine, " ")
exec(stdout, stderr, exit, cmd, e, a)
cmd := wrap(test.impl)
e := strings.Split(test.env, ";")
a := strings.Split(test.command, " ")
exec(stdinr, stdout, stderr, exit, cmd, Config{test: test.conf}, e, a)
if exitCode != 0 && err == "" {
t.Fatal("non-zero exit code:", stderr.String())
}
@ -39,9 +53,13 @@ func testExec(impl any, env, commandLine, err string, expect ...string) func(*te
expstr = append(expstr, fmt.Sprint(e))
}
if stdout.String() != strings.Join(expstr, "\n")+"\n" {
output := stdout.String()
if output[len(output) - 1] != '\n' {
output = output + "\n"
}
if output != strings.Join(expstr, "\n")+"\n" {
t.Fatal("unexpected output:", stdout.String())
}
}
}
*/

201
format.go Normal file
View File

@ -0,0 +1,201 @@
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 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 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 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
}

View File

@ -8,25 +8,9 @@ import (
"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)
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()
@ -36,23 +20,30 @@ func formatHelp(w io.Writer, doc doc) error {
if doc.hasImplementation {
println()
printf(doc.synopsis.command)
printf(escapeTeletype(doc.synopsis.command))
if doc.synopsis.hasOptions {
printf(" [options...]")
}
for _, n := range doc.synopsis.arguments.names {
printf(" %s", n)
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("...")
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)
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)
}
}
@ -60,56 +51,39 @@ func formatHelp(w io.Writer, doc doc) error {
}
if doc.synopsis.hasSubcommands {
if !doc.hasImplementation {
println()
printf("%s <subcommand> [options or args...]", escapeTeletype(doc.synopsis.command))
println()
println()
printf("(For the details about the available subcommands, see the related section below.)")
println()
}
printf("%s <subcommand> [options or args...]", doc.synopsis.command)
if doc.description != "" {
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))
printf(escapeTeletype(paragraphs(doc.description)))
println()
}
if len(doc.options) > 0 {
println()
printf("Options")
if doc.hasBoolOptions || doc.hasListOptions {
println()
println()
printf("[*]: accepts multiple instances of the same option")
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()
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)
names, od := prepareOptions(doc.options)
var max int
for _, n := range names {
@ -120,13 +94,13 @@ func formatHelp(w io.Writer, doc doc) error {
for i := range names {
pad := strings.Join(make([]string, max-len(names[i])+1), " ")
names[i] = fmt.Sprintf("%s%s", names[i], pad)
names[i] = fmt.Sprintf("%s:%s", names[i], pad)
}
for _, n := range names {
printf(n)
if od[n] != "" {
printf(": %s", paragraphs(od[n]))
printf(" %s", escapeTeletype(lines(od[n])))
}
println()
@ -153,9 +127,9 @@ func formatHelp(w io.Writer, doc doc) error {
}
if sc.hasHelpSubcommand {
d = fmt.Sprintf("%s - For help, see: %s %s help", d, doc.name, sc.name)
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, doc.name, sc.name)
d = fmt.Sprintf("%s - For help, see: %s %s --help", d, escapeTeletype(doc.name), sc.name)
}
cd[name] = d
@ -178,22 +152,24 @@ func formatHelp(w io.Writer, doc doc) error {
for _, n := range names {
printf(n)
if cd[n] != "" {
printf(": %s", paragraphs(cd[n]))
printf(": %s", escapeTeletype(paragraphs(cd[n])))
}
println()
}
}
if len(doc.options) > 0 {
printf(paragraphs(envDocs))
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(o.name))
printf(strcase.ToSnake(fmt.Sprintf("%s-%s", doc.appName, o.name)))
printf("=")
if o.isBool {
printf("true")
@ -205,7 +181,9 @@ func formatHelp(w io.Writer, doc doc) error {
}
if len(doc.options) > 0 && len(doc.configFiles) > 0 {
printf(paragraphs(configDocs))
println("Configuration Files")
println()
printf(escapeTeletype(paragraphs(configDocs)))
println()
println()
printf("Config files:")
@ -236,6 +214,7 @@ func formatHelp(w io.Writer, doc doc) error {
println()
printf("# default for --")
printf(o.name)
printf(":")
println()
printf(strcase.ToSnake(o.name))
printf(" = ")
@ -246,7 +225,15 @@ func formatHelp(w io.Writer, doc doc) error {
}
println()
println()
printf("Example for discarding an inherited entry:")
println()
println()
printf("# discarding an inherited entry:")
println()
printf(strcase.ToSnake(o.name))
println()
}
return err
return finish()
}

View File

@ -1,11 +1,204 @@
package wand
import "io"
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 {
// if no subcommands, then similar to help
// otherwise:
// title
// all commands
return nil
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)
}

View File

@ -1,11 +1,201 @@
package wand
import "io"
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 {
// if no subcommands, then similar to help
// otherwise:
// title
// all commands
return nil
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)
}

160
help.go
View File

@ -5,41 +5,16 @@ import (
"fmt"
"io"
"reflect"
"sort"
"strings"
"time"
)
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
types []string
variadic bool
usesStdin bool
usesStdout bool
@ -56,6 +31,7 @@ type (
docOption struct {
name string
typ string
description string
shortNames []string
isBool bool
@ -70,19 +46,24 @@ type (
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
}
)
@ -102,7 +83,7 @@ func insertHelp(cmd Cmd) Cmd {
}
}
if !hasHelpCmd {
if !hasHelpCmd && cmd.version == "" {
cmd.subcommands = append(cmd.subcommands, help())
}
@ -120,6 +101,10 @@ func hasHelpSubcommand(cmd Cmd) bool {
}
func hasCustomHelpOption(cmd Cmd) bool {
if cmd.impl == nil {
return false
}
mf := mapFields(cmd.impl)
_, has := mf["help"]
return has
@ -149,13 +134,38 @@ func hasOptions(cmd Cmd) bool {
return len(fields(s...)) > 0
}
func functionParams(v reflect.Value, skip []int) []string {
names := docreflect.FunctionParams(v)
for _, i := range skip {
names = append(names[:i], names[i+1:]...)
func allCommands(cmd doc) []doc {
commands := []doc{cmd}
for _, sc := range cmd.subcommands {
commands = append(commands, allCommands(sc)...)
}
return names
sort.Slice(commands, func(i, j int) bool {
return commands[i].fullCommand < commands[j].fullCommand
})
return commands
}
func functionParams(v reflect.Value, skip []int) ([]string, []string) {
names := docreflect.FunctionParams(v)
var types []reflect.Kind
for i := 0; i < v.Type().NumIn(); i++ {
types = append(types, v.Type().In(i).Kind())
}
for _, i := range skip {
names = append(names[:i], names[i+1:]...)
types = append(types[:i], types[i+1:]...)
}
var stypes []string
for _, t := range types {
stypes = append(stypes, strings.ToLower(fmt.Sprint(t)))
}
return names, stypes
}
func constructArguments(cmd Cmd) argumentSet {
@ -163,12 +173,12 @@ func constructArguments(cmd Cmd) argumentSet {
return argumentSet{}
}
v := reflect.ValueOf(cmd.impl)
t := unpack(v.Type())
v := unpack(reflect.ValueOf(cmd.impl))
t := v.Type()
p := positionalParameters(t)
ior, iow := ioParameters(p)
count := len(p) - len(ior) - len(iow)
names := functionParams(v, append(ior, iow...))
names, types := functionParams(v, append(ior, iow...))
if len(names) < count {
names = nil
for i := 0; i < count; i++ {
@ -179,6 +189,7 @@ func constructArguments(cmd Cmd) argumentSet {
return argumentSet{
count: count,
names: names,
types: types,
variadic: t.IsVariadic(),
usesStdin: len(ior) > 0,
usesStdout: len(iow) > 0,
@ -197,6 +208,10 @@ func constructSynopsis(cmd Cmd, fullCommand []string) synopsis {
}
func constructDescription(cmd Cmd) string {
if cmd.version != "" {
return versionDocs
}
if cmd.impl == nil {
return ""
}
@ -230,6 +245,7 @@ func constructOptions(cmd Cmd, hasConfigFromOption bool) []docOption {
for name, fi := range f {
opt := docOption{
name: name,
typ: strings.ToLower(fmt.Sprint(fi[0].typ.Kind())),
description: d[name],
shortNames: sf[name],
isBool: fi[0].typ.Kind() == reflect.Bool,
@ -275,70 +291,64 @@ func constructConfigDocs(cmd Cmd, conf Config) []docConfig {
return docs
}
func constructDoc(cmd Cmd, conf Config, fullCommand []string, hasConfigFromOption bool) doc {
func constructDoc(cmd Cmd, conf Config, fullCommand []string) doc {
hasConfigFromOption := hasConfigFromOption(conf)
var subcommands []doc
for _, sc := range cmd.subcommands {
subcommands = append(subcommands, constructDoc(sc, conf, append(fullCommand, sc.name), hasConfigFromOption))
subcommands = append(subcommands, constructDoc(sc, conf, append(fullCommand, sc.name)))
}
var hasBoolOptions, hasListOptions bool
options := constructOptions(cmd, hasConfigFromOption)
for _, o := range options {
if o.isBool {
hasBoolOptions = true
}
if o.acceptsMultiple {
hasListOptions = true
}
}
return doc{
name: fullCommand[len(fullCommand)-1],
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.isHelp,
isVersion: cmd.version != "",
hasHelpSubcommand: hasHelpSubcommand(cmd),
hasHelpOption: hasCustomHelpOption(cmd),
options: constructOptions(cmd, hasConfigFromOption),
options: options,
hasBoolOptions: hasBoolOptions,
hasListOptions: hasListOptions,
arguments: constructArguments(cmd),
subcommands: subcommands,
configFiles: constructConfigDocs(cmd, conf),
date: time.Now(),
}
}
func paragraphs(s string) string {
var (
paragraph []string
paragraphs [][]string
)
l := strings.Split(s, "\n")
for i := range l {
l[i] = strings.TrimSpace(l[i])
if l[i] == "" {
if len(paragraph) > 0 {
paragraphs, paragraph = append(paragraphs, paragraph), nil
}
continue
}
paragraph = append(paragraph, l[i])
}
paragraphs = append(paragraphs, paragraph)
var cparagraphs []string
for _, p := range paragraphs {
cparagraphs = append(cparagraphs, strings.Join(p, " "))
}
return strings.Join(cparagraphs, "\n\n")
}
func showHelp(out io.Writer, cmd Cmd, fullCommand []string, conf Config) error {
doc := constructDoc(cmd, conf, fullCommand, hasConfigFromOption(conf))
func showHelp(out io.Writer, cmd Cmd, conf Config, fullCommand []string) error {
doc := constructDoc(cmd, conf, fullCommand)
return formatHelp(out, doc)
}
func generateMan(out io.Writer, cmd Cmd, conf Config) error {
doc := constructDoc(cmd, conf, []string{cmd.name}, hasConfigFromOption(conf))
doc := constructDoc(cmd, conf, []string{cmd.name})
return formatMan(out, doc)
}
func generateMarkdown(out io.Writer, cmd Cmd, conf Config, level int) error {
doc := constructDoc(cmd, conf, []string{cmd.name}, hasConfigFromOption(conf))
doc := constructDoc(cmd, conf, []string{cmd.name})
return formatMarkdown(out, doc, level)
}
func showVersion(out io.Writer, cmd Cmd) error {
_, err := fmt.Fprintln(out, cmd.version)
return err
}

View File

@ -149,6 +149,10 @@ func validatePositionalArgs(cmd Cmd, a []string) error {
}
for i, ai := range a {
if slices.Contains(ior, i) || slices.Contains(iow, i) {
continue
}
var pi reflect.Type
if i >= length {
pi = p[length-1]
@ -156,6 +160,10 @@ func validatePositionalArgs(cmd Cmd, a []string) error {
pi = p[i]
}
if pi.Kind() == reflect.Interface {
continue
}
if !canScan(pi, ai) {
return fmt.Errorf(
"cannot apply positional argument at index %d, expecting %v",

View File

@ -0,0 +1,21 @@
fix go.mod file path in exec
test starting from the most referenced file to the least referenced one
run only the related test when testing a file
reflect
command
commandline
config
doclets
format
formathelp
formatman
formatmarkdown
help
env
input
output
apply
exec
wand
tools/tools

View File

@ -80,7 +80,7 @@ func parseInt(s string, byteSize int) (int64, error) {
case strings.HasPrefix(s, "0"):
return strconv.ParseInt(s[1:], 8, bitSize)
default:
return strconv.ParseInt(s[2:], 2, byteSize*8)
return strconv.ParseInt(s, 10, bitSize)
}
}
@ -94,7 +94,7 @@ func parseUint(s string, byteSize int) (uint64, error) {
case strings.HasPrefix(s, "0"):
return strconv.ParseUint(s[1:], 8, bitSize)
default:
return strconv.ParseUint(s[2:], 2, bitSize)
return strconv.ParseUint(s, 10, bitSize)
}
}

84
reflect_test.go Normal file
View File

@ -0,0 +1,84 @@
package wand
import (
"testing"
"io"
"bytes"
)
func TestReflect(t *testing.T) {
t.Run("pack and unpack", func(t *testing.T) {
f := func(a int) int { return a }
t.Run("no need to pack", testExec(testCase{impl: f, command: "foo 42"}, "", "42"))
g := func(a *int) int { return *a }
t.Run("pointer", testExec(testCase{impl: g, command: "foo 42"}, "", "42"))
h := func(a []int) int { return a[0] }
t.Run("slice", testExec(testCase{impl: h, command: "foo 42"}, "", "42"))
i := func(a *[]*[]int) int { return (*((*a)[0]))[0] }
t.Run("pointer and slice", testExec(testCase{impl: i, command: "foo 42"}, "", "42"))
})
t.Run("io params", func(t *testing.T) {
f := func(in io.Reader) string { b, _ := io.ReadAll(in); return string(b) }
t.Run("in", testExec(testCase{impl: f, stdin: "foo", command: "bar"}, "", "foo"))
g := func(a string) io.Reader { return bytes.NewBufferString(a) }
t.Run("out", testExec(testCase{impl: g, command: "foo bar"}, "", "bar"))
h := func(out io.Writer, a string) { out.Write([]byte(a)) }
t.Run("stdout param", testExec(testCase{impl: h, command: "foo bar"}, "", "bar"))
})
t.Run("struct param", func(t *testing.T) {
f := func(s struct{Bar int}) int { return s.Bar }
t.Run("basic", testExec(testCase{impl: f, command: "foo --bar 42"}, "", "42"))
})
t.Run("basic types", func(t *testing.T) {
t.Run("signed int", func(t *testing.T) {
f := func(a int) int { return a }
t.Run("decimal", testExec(testCase{impl: f, command: "foo 42"}, "", "42"))
t.Run("hexa", testExec(testCase{impl: f, command: "foo 0x2a"}, "", "42"))
t.Run("octal", testExec(testCase{impl: f, command: "foo 052"}, "", "42"))
t.Run("binary", testExec(testCase{impl: f, command: "foo 0b101010"}, "", "42"))
t.Run("fail", testExec(testCase{impl: f, command: "foo bar"}, "expecting int", ""))
g := func(a int32) int32 { return a }
t.Run("sized", testExec(testCase{impl: g, command: "foo 42"}, "", "42"))
})
t.Run("unsigned int", func(t *testing.T) {
f := func(a uint) uint { return a }
t.Run("decimal", testExec(testCase{impl: f, command: "foo 42"}, "", "42"))
t.Run("hexa", testExec(testCase{impl: f, command: "foo 0x2a"}, "", "42"))
t.Run("octal", testExec(testCase{impl: f, command: "foo 052"}, "", "42"))
t.Run("binary", testExec(testCase{impl: f, command: "foo 0b101010"}, "", "42"))
t.Run("fail", testExec(testCase{impl: f, command: "foo bar"}, "expecting uint", ""))
g := func(a uint32) uint32 { return a }
t.Run("sized", testExec(testCase{impl: g, command: "foo 42"}, "", "42"))
})
t.Run("bool", func(t *testing.T) {
f := func(a bool) bool { return a }
t.Run("true", testExec(testCase{impl: f, command: "foo true"}, "", "true"))
t.Run("false", testExec(testCase{impl: f, command: "foo false"}, "", "false"))
t.Run("fail", testExec(testCase{impl: f, command: "foo yes"}, "expecting bool", ""))
})
t.Run("float", func(t *testing.T) {
f := func(a float64) float64 { return a }
t.Run("accept", testExec(testCase{impl: f, command: "foo 3.14"}, "", "3.14"))
t.Run("fail", testExec(testCase{impl: f, command: "foo bar"}, "expecting float", ""))
})
t.Run("string", func(t *testing.T) {
f := func(a string) string { return a }
t.Run("accept", testExec(testCase{impl: f, command: "foo bar"}, "", "bar"))
})
t.Run("interface", func(t *testing.T) {
f := func(a any) any { return a }
t.Run("any", testExec(testCase{impl: f, command: "foo bar"}, "", "bar"))
type i interface{Foo()}
g := func(a i) any { return a }
t.Run("unscannable", testExec(testCase{impl: g, command: "foo bar"}, "non-empty interface", ""))
})
})
}

View File

@ -180,7 +180,7 @@ func Exec(o ExecOptions, function string, args ...string) error {
cacheDir := o.CacheDir
if cacheDir == "" {
path.Join(os.Getenv("HOME"), ".wand")
cacheDir = path.Join(os.Getenv("HOME"), ".wand")
}
functionDir := path.Join(cacheDir, functionHash)
@ -204,6 +204,7 @@ func Exec(o ExecOptions, function string, args ...string) error {
}
goGet := func(pkg string) error {
println("go get", pkg)
if err := execInternal("go get", pkg); err != nil {
return fmt.Errorf("failed to get go module: %w", err)
}

14
wand.go
View File

@ -11,6 +11,7 @@ type Config struct {
merge []Config
fromOption bool
optional bool
test string
}
type Cmd struct {
@ -23,9 +24,11 @@ type Cmd struct {
shortForms []string
description string
isHelp bool
helpRequested bool
version string
}
// name needs to be valid symbol. The application name should also be a valid symbol,
// though not mandatory. If it is not, the environment variables may not work properly.
func Command(name string, impl any, subcmds ...Cmd) Cmd {
return command(name, impl, subcmds...)
}
@ -53,6 +56,15 @@ func ShortFormOptions(cmd Cmd, f ...string) Cmd {
return cmd
}
func Version(cmd Cmd, version string) Cmd {
cmd.subcommands = append(
cmd.subcommands,
Cmd{name: "version", version: version},
)
return cmd
}
func MergeConfig(conf ...Config) Config {
return Config{
merge: conf,