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 iniparser.gen.go
docreflect.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
go build ./tools go build ./tools
build: lib wand build: lib .build/wand
check: $(SOURCES) build check: $(SOURCES) build
go test -count 1 ./... go test -count 1 ./...
@ -24,7 +24,7 @@ fmt: $(SOURCES) iniparser.gen.go docreflect.gen.go
go fmt ./... go fmt ./...
iniparser.gen.go: ini.treerack 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) docreflect.gen.go: $(SOURCES)
go run script/docreflect/docs.go \ go run script/docreflect/docs.go \
@ -32,13 +32,19 @@ docreflect.gen.go: $(SOURCES)
code.squareroundforest.org/arpio/docreflect/generate \ code.squareroundforest.org/arpio/docreflect/generate \
code.squareroundforest.org/arpio/wand/tools \ code.squareroundforest.org/arpio/wand/tools \
> docreflect.gen.go \ > docreflect.gen.go \
|| rm docreflect.gen.go || rm -f docreflect.gen.go
.bin: .build:
mkdir -p .bin mkdir -p .build
wand: $(SOURCES) iniparser.gen.go docreflect.gen.go .bin .build/wand: $(SOURCES) iniparser.gen.go docreflect.gen.go .build
go build -o .bin/wand ./cmd/wand go build -o .build/wand -ldflags "-X main.version=$(shell date +%Y-%m-%d)-$(shell git rev-parse --short HEAD)" ./cmd/wand
install: wand install: .build/wand
cp .bin/wand ~/bin 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 { func createPositional(t reflect.Type, v string) reflect.Value {
if t.Kind() == reflect.Interface {
return reflect.ValueOf(v)
}
tup := unpack(t) tup := unpack(t)
sv := reflect.ValueOf(scan(tup, v)) sv := reflect.ValueOf(scan(tup, v))
return pack(sv, t) return pack(sv, t)
@ -223,7 +227,7 @@ func processResults(t reflect.Type, out []reflect.Value) ([]any, error) {
var err error var err error
last := len(out) - 1 last := len(out) - 1
isErrorType := t.Out(last) == reflect.TypeOf(err) isErrorType := t.Out(last) == reflect.TypeFor[error]()
if isErrorType && !out[last].IsZero() { if isErrorType && !out[last].IsZero() {
err = out[last].Interface().(error) err = out[last].Interface().(error)
} }

View File

@ -5,10 +5,15 @@ import (
"code.squareroundforest.org/arpio/wand/tools" "code.squareroundforest.org/arpio/wand/tools"
) )
var version = "dev"
func main() { func main() {
docreflect := Command("docreflect", tools.Docreflect) docreflect := Command("docreflect", tools.Docreflect)
man := Command("manpages", tools.Man) man := Command("manpages", tools.Man)
md := Command("markdown", tools.Markdown) md := Command("markdown", tools.Markdown)
exec := Default(Command("exec", tools.Exec)) exec := Command("exec", tools.Exec)
Exec(Command("wand", nil, docreflect, man, md, exec), Etc(), UserConfig()) 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" "errors"
"fmt" "fmt"
"reflect" "reflect"
"regexp"
"slices" "slices"
) )
var commandNameExpression = regexp.MustCompile("^[a-zA-Z_][a-zA-Z_0-9]*$")
func command(name string, impl any, subcmds ...Cmd) Cmd { func command(name string, impl any, subcmds ...Cmd) Cmd {
return Cmd{ return Cmd{
name: name, name: name,
@ -74,7 +77,7 @@ func validateParameter(visited map[reflect.Type]bool, t reflect.Type) error {
return validateParameter(visited, t) return validateParameter(visited, t)
case reflect.Interface: case reflect.Interface:
if t.NumMethod() > 0 { if t.NumMethod() > 0 {
return errors.New("'non-empty' interface parameter") return errors.New("non-empty interface parameter")
} }
return nil return nil
@ -203,11 +206,19 @@ func validateShortForms(cmd Cmd, assignedShortForms map[string]string) error {
return nil 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 { if cmd.isHelp {
return nil 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 cmd.impl != nil {
if err := validateImpl(cmd, conf); err != nil { if err := validateImpl(cmd, conf); err != nil {
return fmt.Errorf("%s: %w", cmd.name, err) 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 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) return fmt.Errorf("%s: %w", s.name, err)
} }
@ -275,7 +286,7 @@ func allShortForms(cmd Cmd) []string {
func validateCommand(cmd Cmd, conf Config) error { func validateCommand(cmd Cmd, conf Config) error {
assignedShortForms := make(map[string]string) assignedShortForms := make(map[string]string)
if err := validateCommandTree(cmd, conf, assignedShortForms); err != nil { if err := validateCommandTree(cmd, conf, assignedShortForms, true); err != nil {
return err return err
} }

View File

@ -182,11 +182,6 @@ func selectCommand(cmd Cmd, args []string) (Cmd, []string, []string) {
return defaultCommand(cmd), []string{cmd.name}, args 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:]) cmd, fullCommand, args := selectCommand(sc, args[1:])
fullCommand = append([]string{cmd.name}, fullCommand...) fullCommand = append([]string{cmd.name}, fullCommand...)
return cmd, fullCommand, args return cmd, fullCommand, args

View File

@ -1,10 +1,12 @@
package wand package wand
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"github.com/iancoleman/strcase" "github.com/iancoleman/strcase"
"io" "io"
"io/ioutil"
"os" "os"
) )
@ -60,8 +62,14 @@ func (f *file) Close() error {
} }
func readConfigFile(cmd Cmd, conf Config) (config, error) { func readConfigFile(cmd Cmd, conf Config) (config, error) {
f := conf.file(cmd) var f io.ReadCloser
defer f.Close() if conf.test == "" {
f = conf.file(cmd)
defer f.Close()
} else {
f = ioutil.NopCloser(bytes.NewBufferString(conf.test))
}
doc, err := parse(f) doc, err := parse(f)
if err != nil { if err != nil {
if conf.optional && (errors.Is(err, os.ErrPermission) || errors.Is(err, os.ErrNotExist)) { 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) { func readConfig(cmd Cmd, cl commandLine, conf Config) (config, error) {
if conf.file != nil { if conf.file != nil || conf.test != "" {
return readConfigFile(cmd, conf) 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 ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/iancoleman/strcase"
"io" "io"
"os" "os"
"path/filepath" "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) { func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, conf Config, env, args []string) {
cmd = insertHelp(cmd) cmd = insertHelp(cmd)
_, cmd.name = filepath.Split(args[0]) _, cmd.name = filepath.Split(args[0])
cmd.name = strcase.ToKebab(cmd.name)
if err := validateCommand(cmd, conf); err != nil { 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" { 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) e := readEnv(cmd.name, env)
cmd, fullCmd, args := selectCommand(cmd, args[1:]) 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 { if cmd.impl == nil {
fmt.Fprintln(stderr, errors.New("subcommand not specified")) fmt.Fprintln(stderr, errors.New("subcommand not specified"))
suggestHelp(stderr, cmd, fullCmd) suggestHelp(stderr, cmd, fullCmd)
@ -46,26 +79,6 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co
return 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) c, err := readConfig(cmd, cl, conf)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "configuration error: %v", err) fmt.Fprintf(stderr, "configuration error: %v", err)

View File

@ -1,23 +1,37 @@
package wand package wand
/*
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io"
"strings" "strings"
"testing" "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) { return func(t *testing.T) {
var exitCode int var exitCode int
exit := func(code int) { exitCode = code } exit := func(code int) { exitCode = code }
var stdinr io.Reader
if test.stdin != "" {
stdinr = bytes.NewBuffer([]byte(test.stdin))
}
stdout := bytes.NewBuffer(nil) stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil) stderr := bytes.NewBuffer(nil)
cmd := wrap(impl) cmd := wrap(test.impl)
e := strings.Split(env, ";") e := strings.Split(test.env, ";")
a := strings.Split(commandLine, " ") a := strings.Split(test.command, " ")
exec(stdout, stderr, exit, cmd, e, a) exec(stdinr, stdout, stderr, exit, cmd, Config{test: test.conf}, e, a)
if exitCode != 0 && err == "" { if exitCode != 0 && err == "" {
t.Fatal("non-zero exit code:", stderr.String()) 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)) 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()) 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" "strings"
) )
func formatHelp(w io.Writer, doc doc) error { func formatHelp(out io.Writer, doc doc) error {
var err error printf, println, finish := printer(out)
println := func(a ...any) { printf(escapeTeletype(doc.fullCommand))
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() println()
if doc.hasImplementation || doc.synopsis.hasSubcommands { if doc.hasImplementation || doc.synopsis.hasSubcommands {
println() println()
@ -36,23 +20,30 @@ func formatHelp(w io.Writer, doc doc) error {
if doc.hasImplementation { if doc.hasImplementation {
println() println()
printf(doc.synopsis.command) printf(escapeTeletype(doc.synopsis.command))
if doc.synopsis.hasOptions { if doc.synopsis.hasOptions {
printf(" [options ...]") printf(" [options...]")
} }
for _, n := range doc.synopsis.arguments.names { for i := range doc.synopsis.arguments.names {
printf(" %s", n) printf(
" [%s %s]",
doc.synopsis.arguments.names[i],
doc.synopsis.arguments.types[i],
)
} }
if doc.synopsis.arguments.variadic { if doc.synopsis.arguments.variadic {
printf("...") printf("...")
if doc.synopsis.arguments.minPositional > 0 { min := doc.synopsis.arguments.minPositional
printf(" min %d arguments", doc.synopsis.arguments.minPositional) max := doc.synopsis.arguments.maxPositional
} switch {
case min > 0 && max > 0:
if doc.synopsis.arguments.maxPositional > 0 { printf("\nmin %d and max %d total positional arguments", min, max)
printf(" max %d arguments", doc.synopsis.arguments.maxPositional) 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.synopsis.hasSubcommands {
if !doc.hasImplementation { println()
println() printf("%s <subcommand> [options or args...]", escapeTeletype(doc.synopsis.command))
}
printf("%s <subcommand> [options or args...]", doc.synopsis.command)
println() println()
println() println()
printf("(For the details about the available subcommands, see the according section below.)") printf("(For the details about the available subcommands, see the related section below.)")
println() println()
} }
if len(doc.description) > 0 { if doc.description != "" {
println() println()
printf(paragraphs(doc.description)) printf(escapeTeletype(paragraphs(doc.description)))
println() println()
} }
if len(doc.options) > 0 { if len(doc.options) > 0 {
println() println()
printf("Options") printf("Options")
println() if doc.hasBoolOptions || doc.hasListOptions {
println() println()
printf("[*]: accepts multiple instances of the same option") if doc.hasBoolOptions {
println() println()
printf("[b]: booelan flag, true or false, or no argument means true") 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 doc.hasListOptions {
if o.acceptsMultiple { println()
n = fmt.Sprintf("%s [*]", n) printf("[*]: accepts multiple instances of the same option")
} }
if o.isBool {
n = fmt.Sprintf("%s [b]", n)
}
names = append(names, n)
od[n] = o.description
} }
sort.Strings(names) println()
println()
names, od := prepareOptions(doc.options)
var max int var max int
for _, n := range names { for _, n := range names {
@ -120,13 +94,13 @@ func formatHelp(w io.Writer, doc doc) error {
for i := range names { for i := range names {
pad := strings.Join(make([]string, max-len(names[i])+1), " ") 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 { for _, n := range names {
printf(n) printf(n)
if od[n] != "" { if od[n] != "" {
printf(": %s", paragraphs(od[n])) printf(" %s", escapeTeletype(lines(od[n])))
} }
println() println()
@ -153,9 +127,9 @@ func formatHelp(w io.Writer, doc doc) error {
} }
if sc.hasHelpSubcommand { 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 { } 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 cd[name] = d
@ -178,22 +152,24 @@ func formatHelp(w io.Writer, doc doc) error {
for _, n := range names { for _, n := range names {
printf(n) printf(n)
if cd[n] != "" { if cd[n] != "" {
printf(": %s", paragraphs(cd[n])) printf(": %s", escapeTeletype(paragraphs(cd[n])))
} }
println() println()
} }
} }
if len(doc.options) > 0 { if len(doc.options) > 0 && commandNameExpression.MatchString(doc.appName) {
printf(paragraphs(envDocs)) println("Environment Variables")
println()
printf(escapeTeletype(paragraphs(envDocs)))
println() println()
println() println()
o := doc.options[0] o := doc.options[0]
printf("Example environment variable:") printf("Example environment variable:")
println() println()
println() println()
printf(strcase.ToSnake(o.name)) printf(strcase.ToSnake(fmt.Sprintf("%s-%s", doc.appName, o.name)))
printf("=") printf("=")
if o.isBool { if o.isBool {
printf("true") printf("true")
@ -205,7 +181,9 @@ func formatHelp(w io.Writer, doc doc) error {
} }
if len(doc.options) > 0 && len(doc.configFiles) > 0 { if len(doc.options) > 0 && len(doc.configFiles) > 0 {
printf(paragraphs(configDocs)) println("Configuration Files")
println()
printf(escapeTeletype(paragraphs(configDocs)))
println() println()
println() println()
printf("Config files:") printf("Config files:")
@ -236,6 +214,7 @@ func formatHelp(w io.Writer, doc doc) error {
println() println()
printf("# default for --") printf("# default for --")
printf(o.name) printf(o.name)
printf(":")
println() println()
printf(strcase.ToSnake(o.name)) printf(strcase.ToSnake(o.name))
printf(" = ") printf(" = ")
@ -246,7 +225,15 @@ func formatHelp(w io.Writer, doc doc) error {
} }
println() 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 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 { func formatMan(out io.Writer, doc doc) error {
// if no subcommands, then similar to help var hasSubcommands bool
// otherwise: for _, sc := range doc.subcommands {
// title if !sc.isHelp && !sc.isVersion {
// all commands continue
return nil }
hasSubcommands = true
break
}
if hasSubcommands {
return formatManMultiCommand(out, doc)
}
return formatManSingleCommand(out, doc)
} }

View File

@ -1,11 +1,201 @@
package wand 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 { func formatMarkdown(out io.Writer, doc doc, level int) error {
// if no subcommands, then similar to help var hasSubcommands bool
// otherwise: for _, sc := range doc.subcommands {
// title if !sc.isHelp && !sc.isVersion {
// all commands continue
return nil }
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" "fmt"
"io" "io"
"reflect" "reflect"
"sort"
"strings" "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 ( type (
argumentSet struct { argumentSet struct {
count int count int
names []string names []string
types []string
variadic bool variadic bool
usesStdin bool usesStdin bool
usesStdout bool usesStdout bool
@ -56,6 +31,7 @@ type (
docOption struct { docOption struct {
name string name string
typ string
description string description string
shortNames []string shortNames []string
isBool bool isBool bool
@ -70,19 +46,24 @@ type (
doc struct { doc struct {
name string name string
appName string
fullCommand string fullCommand string
synopsis synopsis synopsis synopsis
description string description string
hasImplementation bool hasImplementation bool
isHelp bool isHelp bool
isVersion bool
isDefault bool isDefault bool
hasHelpSubcommand bool hasHelpSubcommand bool
hasHelpOption bool hasHelpOption bool
hasConfigFromOption bool hasConfigFromOption bool
options []docOption options []docOption
hasBoolOptions bool
hasListOptions bool
arguments argumentSet arguments argumentSet
subcommands []doc subcommands []doc
configFiles []docConfig 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()) cmd.subcommands = append(cmd.subcommands, help())
} }
@ -120,6 +101,10 @@ func hasHelpSubcommand(cmd Cmd) bool {
} }
func hasCustomHelpOption(cmd Cmd) bool { func hasCustomHelpOption(cmd Cmd) bool {
if cmd.impl == nil {
return false
}
mf := mapFields(cmd.impl) mf := mapFields(cmd.impl)
_, has := mf["help"] _, has := mf["help"]
return has return has
@ -149,13 +134,38 @@ func hasOptions(cmd Cmd) bool {
return len(fields(s...)) > 0 return len(fields(s...)) > 0
} }
func functionParams(v reflect.Value, skip []int) []string { func allCommands(cmd doc) []doc {
names := docreflect.FunctionParams(v) commands := []doc{cmd}
for _, i := range skip { for _, sc := range cmd.subcommands {
names = append(names[:i], names[i+1:]...) 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 { func constructArguments(cmd Cmd) argumentSet {
@ -163,12 +173,12 @@ func constructArguments(cmd Cmd) argumentSet {
return argumentSet{} return argumentSet{}
} }
v := reflect.ValueOf(cmd.impl) v := unpack(reflect.ValueOf(cmd.impl))
t := unpack(v.Type()) t := v.Type()
p := positionalParameters(t) p := positionalParameters(t)
ior, iow := ioParameters(p) ior, iow := ioParameters(p)
count := len(p) - len(ior) - len(iow) count := len(p) - len(ior) - len(iow)
names := functionParams(v, append(ior, iow...)) names, types := functionParams(v, append(ior, iow...))
if len(names) < count { if len(names) < count {
names = nil names = nil
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
@ -179,6 +189,7 @@ func constructArguments(cmd Cmd) argumentSet {
return argumentSet{ return argumentSet{
count: count, count: count,
names: names, names: names,
types: types,
variadic: t.IsVariadic(), variadic: t.IsVariadic(),
usesStdin: len(ior) > 0, usesStdin: len(ior) > 0,
usesStdout: len(iow) > 0, usesStdout: len(iow) > 0,
@ -197,6 +208,10 @@ func constructSynopsis(cmd Cmd, fullCommand []string) synopsis {
} }
func constructDescription(cmd Cmd) string { func constructDescription(cmd Cmd) string {
if cmd.version != "" {
return versionDocs
}
if cmd.impl == nil { if cmd.impl == nil {
return "" return ""
} }
@ -230,6 +245,7 @@ func constructOptions(cmd Cmd, hasConfigFromOption bool) []docOption {
for name, fi := range f { for name, fi := range f {
opt := docOption{ opt := docOption{
name: name, name: name,
typ: strings.ToLower(fmt.Sprint(fi[0].typ.Kind())),
description: d[name], description: d[name],
shortNames: sf[name], shortNames: sf[name],
isBool: fi[0].typ.Kind() == reflect.Bool, isBool: fi[0].typ.Kind() == reflect.Bool,
@ -275,70 +291,64 @@ func constructConfigDocs(cmd Cmd, conf Config) []docConfig {
return docs 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 var subcommands []doc
for _, sc := range cmd.subcommands { 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{ return doc{
name: fullCommand[len(fullCommand)-1], name: cmd.name,
appName: fullCommand[0],
fullCommand: strings.Join(fullCommand, " "), fullCommand: strings.Join(fullCommand, " "),
synopsis: constructSynopsis(cmd, fullCommand), synopsis: constructSynopsis(cmd, fullCommand),
description: constructDescription(cmd), description: constructDescription(cmd),
hasImplementation: cmd.impl != nil, hasImplementation: cmd.impl != nil,
isDefault: cmd.isDefault, isDefault: cmd.isDefault,
isHelp: cmd.isHelp, isHelp: cmd.isHelp,
isVersion: cmd.version != "",
hasHelpSubcommand: hasHelpSubcommand(cmd), hasHelpSubcommand: hasHelpSubcommand(cmd),
hasHelpOption: hasCustomHelpOption(cmd), hasHelpOption: hasCustomHelpOption(cmd),
options: constructOptions(cmd, hasConfigFromOption), options: options,
hasBoolOptions: hasBoolOptions,
hasListOptions: hasListOptions,
arguments: constructArguments(cmd), arguments: constructArguments(cmd),
subcommands: subcommands, subcommands: subcommands,
configFiles: constructConfigDocs(cmd, conf), configFiles: constructConfigDocs(cmd, conf),
date: time.Now(),
} }
} }
func paragraphs(s string) string { func showHelp(out io.Writer, cmd Cmd, conf Config, fullCommand []string) error {
var ( doc := constructDoc(cmd, conf, fullCommand)
paragraph []string
paragraphs [][]string
)
l := strings.Split(s, "\n")
for i := range l {
l[i] = strings.TrimSpace(l[i])
if l[i] == "" {
if len(paragraph) > 0 {
paragraphs, paragraph = append(paragraphs, paragraph), nil
}
continue
}
paragraph = append(paragraph, l[i])
}
paragraphs = append(paragraphs, paragraph)
var cparagraphs []string
for _, p := range paragraphs {
cparagraphs = append(cparagraphs, strings.Join(p, " "))
}
return strings.Join(cparagraphs, "\n\n")
}
func showHelp(out io.Writer, cmd Cmd, fullCommand []string, conf Config) error {
doc := constructDoc(cmd, conf, fullCommand, hasConfigFromOption(conf))
return formatHelp(out, doc) return formatHelp(out, doc)
} }
func generateMan(out io.Writer, cmd Cmd, conf Config) error { 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) return formatMan(out, doc)
} }
func generateMarkdown(out io.Writer, cmd Cmd, conf Config, level int) error { 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) 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 { for i, ai := range a {
if slices.Contains(ior, i) || slices.Contains(iow, i) {
continue
}
var pi reflect.Type var pi reflect.Type
if i >= length { if i >= length {
pi = p[length-1] pi = p[length-1]
@ -156,6 +160,10 @@ func validatePositionalArgs(cmd Cmd, a []string) error {
pi = p[i] pi = p[i]
} }
if pi.Kind() == reflect.Interface {
continue
}
if !canScan(pi, ai) { if !canScan(pi, ai) {
return fmt.Errorf( return fmt.Errorf(
"cannot apply positional argument at index %d, expecting %v", "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"): case strings.HasPrefix(s, "0"):
return strconv.ParseInt(s[1:], 8, bitSize) return strconv.ParseInt(s[1:], 8, bitSize)
default: 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"): case strings.HasPrefix(s, "0"):
return strconv.ParseUint(s[1:], 8, bitSize) return strconv.ParseUint(s[1:], 8, bitSize)
default: 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 cacheDir := o.CacheDir
if cacheDir == "" { if cacheDir == "" {
path.Join(os.Getenv("HOME"), ".wand") cacheDir = path.Join(os.Getenv("HOME"), ".wand")
} }
functionDir := path.Join(cacheDir, functionHash) functionDir := path.Join(cacheDir, functionHash)
@ -204,6 +204,7 @@ func Exec(o ExecOptions, function string, args ...string) error {
} }
goGet := func(pkg string) error { goGet := func(pkg string) error {
println("go get", pkg)
if err := execInternal("go get", pkg); err != nil { if err := execInternal("go get", pkg); err != nil {
return fmt.Errorf("failed to get go module: %w", err) return fmt.Errorf("failed to get go module: %w", err)
} }

14
wand.go
View File

@ -11,6 +11,7 @@ type Config struct {
merge []Config merge []Config
fromOption bool fromOption bool
optional bool optional bool
test string
} }
type Cmd struct { type Cmd struct {
@ -23,9 +24,11 @@ type Cmd struct {
shortForms []string shortForms []string
description string description string
isHelp bool 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 { func Command(name string, impl any, subcmds ...Cmd) Cmd {
return command(name, impl, subcmds...) return command(name, impl, subcmds...)
} }
@ -53,6 +56,15 @@ func ShortFormOptions(cmd Cmd, f ...string) Cmd {
return 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 { func MergeConfig(conf ...Config) Config {
return Config{ return Config{
merge: conf, merge: conf,