From f206c694b227f276128fbe63e8acd5820cf2db87 Mon Sep 17 00:00:00 2001 From: Arpad Ryszka Date: Sun, 24 Aug 2025 01:45:25 +0200 Subject: [PATCH] interim checkin --- .gitignore | 3 + Makefile | 34 +++- apply.go | 36 +++- cmd/wand-docs/main.go | 12 -- cmd/wand/main.go | 14 ++ command.go | 94 +++++++-- commandline_test.go | 2 + config.go | 191 ++++++++++++++++++ config_test.go | 44 +++++ env_test.go | 2 + exec.go | 59 ++++-- exec_test.go | 2 + go.mod | 10 +- go.sum | 8 + help.go | 376 ++++++++++++++++++++++++++++++++++-- ini.treerack | 10 + input.go | 53 +++-- internal/tests/config.ini | 8 + notes.txt | 11 +- reflect.go | 87 +++++++-- script/docreflect/docs.go | 17 ++ script/ini-parser/parser.go | 31 +++ tools/tools.go | 258 +++++++++++++++++++++++++ wand.go | 71 ++++++- 24 files changed, 1316 insertions(+), 117 deletions(-) create mode 100644 .gitignore delete mode 100644 cmd/wand-docs/main.go create mode 100644 cmd/wand/main.go create mode 100644 config.go create mode 100644 config_test.go create mode 100644 ini.treerack create mode 100644 internal/tests/config.ini create mode 100644 script/docreflect/docs.go create mode 100644 script/ini-parser/parser.go create mode 100644 tools/tools.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5c1962 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +iniparser.go +docreflect.go +.bin diff --git a/Makefile b/Makefile index e43c2e8..b1ced0d 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,17 @@ -SOURCES = $(shell find . -name "*.go") +SOURCES = $(shell find . -name "*.go" | grep -v iniparser.go | grep -v docreflect.go) default: build -build: $(SOURCES) - go build ./... +lib: $(SOURCES) iniparser.go docreflect.go + go build + go build ./tools -check: $(SOURCES) +build: lib wand + +check: $(SOURCES) build go test -count 1 ./... -.cover: $(SOURCES) +.cover: $(SOURCES) build go test -count 1 -coverprofile .cover ./... cover: .cover @@ -17,5 +20,24 @@ cover: .cover showcover: .cover go tool cover -html .cover -fmt: $(SOURCES) +fmt: $(SOURCES) iniparser.go go fmt ./... + +iniparser.go: ini.treerack + go run script/ini-parser/parser.go wand < ini.treerack > iniparser.go || rm iniparser.go + +docreflect.go: $(SOURCES) + go run script/docreflect/docs.go \ + wand \ + code.squareroundforest.org/arpio/docreflect/generate \ + code.squareroundforest.org/arpio/wand/tools \ + > docreflect.go + +.bin: + mkdir -p .bin + +wand: $(SOURCES) iniparser.go docreflect.go .bin + go build -o .bin/wand ./cmd/wand + +install: wand + cp .bin/wand ~/bin diff --git a/apply.go b/apply.go index cadf808..41f4f93 100644 --- a/apply.go +++ b/apply.go @@ -2,9 +2,9 @@ package wand import ( "github.com/iancoleman/strcase" + "io" "reflect" "strings" - "os" ) func ensurePointerAllocation(p reflect.Value, n int) { @@ -95,7 +95,7 @@ func setField(s reflect.Value, name string, v []value) { } } -func createStructArg(t reflect.Type, shortForms []string, e env, o []option) (reflect.Value, bool) { +func createStructArg(t reflect.Type, shortForms []string, c config, e env, o []option) (reflect.Value, bool) { tup := unpack(t) f := fields(tup) fn := make(map[string]bool) @@ -119,6 +119,13 @@ func createStructArg(t reflect.Type, shortForms []string, e env, o []option) (re om[n] = append(om[n], oi) } + var foundConfig []string + for n := range c.values { + if fn[n] { + foundConfig = append(foundConfig, n) + } + } + var foundEnv []string for n := range e.values { if fn[n] { @@ -133,11 +140,20 @@ func createStructArg(t reflect.Type, shortForms []string, e env, o []option) (re } } - if len(foundEnv) == 0 && len(foundOptions) == 0 { + if len(foundConfig) == 0 && len(foundEnv) == 0 && len(foundOptions) == 0 { return reflect.Zero(t), false } p := reflect.New(tup) + for _, n := range foundConfig { + var v []value + for _, vi := range c.values[n] { + v = append(v, stringValue(vi)) + } + + setField(p.Elem(), n, v) + } + for _, n := range foundEnv { var v []value for _, vi := range e.values[n] { @@ -165,7 +181,7 @@ func createPositional(t reflect.Type, v string) reflect.Value { return pack(sv, t) } -func createArgs(t reflect.Type, shortForms []string, e env, cl commandLine) []reflect.Value { +func createArgs(stdin io.Reader, stdout io.Writer, t reflect.Type, shortForms []string, c config, e env, cl commandLine) []reflect.Value { var args []reflect.Value positional := cl.positional for i := 0; i < t.NumIn(); i++ { @@ -176,15 +192,15 @@ func createArgs(t reflect.Type, shortForms []string, e env, cl commandLine) []re iow := isWriter(ti) switch { case ior: - args = append(args, reflect.ValueOf(os.Stdin)) + args = append(args, reflect.ValueOf(stdin)) case iow: - args = append(args, reflect.ValueOf(os.Stdout)) + args = append(args, reflect.ValueOf(stdout)) case structure && variadic: - if arg, ok := createStructArg(ti, shortForms, e, cl.options); ok { + if arg, ok := createStructArg(ti, shortForms, c, e, cl.options); ok { args = append(args, arg) } case structure: - arg, _ := createStructArg(ti, shortForms, e, cl.options) + arg, _ := createStructArg(ti, shortForms, c, e, cl.options) args = append(args, arg) case variadic: for _, p := range positional { @@ -224,11 +240,11 @@ func processResults(t reflect.Type, out []reflect.Value) ([]any, error) { return values, err } -func apply(cmd Cmd, e env, cl commandLine) ([]any, error) { +func apply(stdin io.Reader, stdout io.Writer, cmd Cmd, c config, e env, cl commandLine) ([]any, error) { v := reflect.ValueOf(cmd.impl) v = unpack(v) t := v.Type() - args := createArgs(t, cmd.shortForms, e, cl) + args := createArgs(stdin, stdout, t, cmd.shortForms, c, e, cl) out := v.Call(args) return processResults(t, out) } diff --git a/cmd/wand-docs/main.go b/cmd/wand-docs/main.go deleted file mode 100644 index aab578d..0000000 --- a/cmd/wand-docs/main.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -// myFunc is. -func myFunc() { -} - -// MyFunc is. -func MyFunc() { -} - -func main() { -} diff --git a/cmd/wand/main.go b/cmd/wand/main.go new file mode 100644 index 0000000..cee3655 --- /dev/null +++ b/cmd/wand/main.go @@ -0,0 +1,14 @@ +package main + +import ( + . "code.squareroundforest.org/arpio/wand" + "code.squareroundforest.org/arpio/wand/tools" +) + +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()) +} diff --git a/command.go b/command.go index ce47ad4..de61fe7 100644 --- a/command.go +++ b/command.go @@ -24,20 +24,25 @@ func wrap(impl any) Cmd { return Command("", impl) } -func validateFields(f []field) error { +func validateFields(f []field, conf Config) error { + hasConfigFromOption := hasConfigFromOption(conf) mf := make(map[string]field) for _, fi := range f { if ef, ok := mf[fi.name]; ok && !compatibleTypes(fi.typ, ef.typ) { return fmt.Errorf("duplicate fields with different types: %s", fi.name) } + if hasConfigFromOption && fi.name == "config" { + return errors.New("option reserved for config file shadowed by struct field") + } + mf[fi.name] = fi } return nil } -func validateParameter(t reflect.Type) error { +func validateParameter(visited map[reflect.Type]bool, t reflect.Type) error { switch t.Kind() { case reflect.Bool, reflect.Int, @@ -56,8 +61,17 @@ func validateParameter(t reflect.Type) error { return nil case reflect.Pointer, reflect.Slice: + if visited[t] { + return fmt.Errorf("circular type definitions not supported: %s", t.Name()) + } + + if visited == nil { + visited = make(map[reflect.Type]bool) + } + + visited[t] = true t = unpack(t) - return validateParameter(t) + return validateParameter(visited, t) case reflect.Interface: if t.NumMethod() > 0 { return errors.New("'non-empty' interface parameter") @@ -81,12 +95,12 @@ func validatePositional(t reflect.Type, min, max int) error { continue } - if err := validateParameter(pi); err != nil { + if err := validateParameter(nil, pi); err != nil { return err } } - last := t.NumIn()-1 + last := t.NumIn() - 1 lastVariadic := t.IsVariadic() && !isStruct(t.In(last)) && !slices.Contains(ior, last) && @@ -131,7 +145,7 @@ func validatePositional(t reflect.Type, min, max int) error { return nil } -func validateImpl(cmd Cmd) error { +func validateImpl(cmd Cmd, conf Config) error { v := reflect.ValueOf(cmd.impl) v = unpack(v) t := v.Type() @@ -140,8 +154,12 @@ func validateImpl(cmd Cmd) error { } s := structParameters(t) - f := fields(s...) - if err := validateFields(f); err != nil { + f, err := fieldsChecked(nil, s...) + if err != nil { + return err + } + + if err := validateFields(f, conf); err != nil { return err } @@ -152,9 +170,8 @@ func validateImpl(cmd Cmd) error { return nil } -func validateShortForms(cmd Cmd) error { +func validateShortForms(cmd Cmd, assignedShortForms map[string]string) error { mf := mapFields(cmd.impl) - ms := make(map[string]string) if len(cmd.shortForms)%2 != 0 { return fmt.Errorf( "undefined option short form: %s", cmd.shortForms[len(cmd.shortForms)-1], @@ -164,10 +181,6 @@ func validateShortForms(cmd Cmd) error { for i := 0; i < len(cmd.shortForms); i += 2 { fn := cmd.shortForms[i] sf := cmd.shortForms[i+1] - if _, ok := mf[fn]; !ok { - return fmt.Errorf("undefined field: %s", fn) - } - if len(sf) != 1 && (sf[0] < 'a' || sf[0] > 'z') { return fmt.Errorf("invalid short form: %s", sf) } @@ -176,23 +189,27 @@ func validateShortForms(cmd Cmd) error { return fmt.Errorf("short form shadowing field name: %s", sf) } - if lf, ok := ms[sf]; ok && lf != fn { + if _, ok := mf[fn]; !ok { + continue + } + + if lf, ok := assignedShortForms[sf]; ok && lf != fn { return fmt.Errorf("ambigous short form: %s", sf) } - ms[sf] = fn + assignedShortForms[sf] = fn } return nil } -func validateCommand(cmd Cmd) error { +func validateCommandTree(cmd Cmd, conf Config, assignedShortForms map[string]string) error { if cmd.isHelp { return nil } if cmd.impl != nil { - if err := validateImpl(cmd); err != nil { + if err := validateImpl(cmd, conf); err != nil { return fmt.Errorf("%s: %w", cmd.name, err) } } @@ -202,7 +219,7 @@ func validateCommand(cmd Cmd) error { } if cmd.impl != nil { - if err := validateShortForms(cmd); err != nil { + if err := validateShortForms(cmd, assignedShortForms); err != nil { return fmt.Errorf("%s: %w", cmd.name, err) } } @@ -219,10 +236,18 @@ func validateCommand(cmd Cmd) error { } names[s.name] = true - if err := validateCommand(s); err != nil { + if err := validateCommandTree(s, conf, assignedShortForms); err != nil { return fmt.Errorf("%s: %w", s.name, err) } + if s.isDefault && cmd.impl != nil { + return fmt.Errorf( + "default subcommand defined for a command with explicit implementation: %s, %s", + cmd.name, + s.name, + ) + } + if s.isDefault && hasDefault { return fmt.Errorf("multiple default subcommands for: %s", cmd.name) } @@ -234,3 +259,32 @@ func validateCommand(cmd Cmd) error { return nil } + +func allShortForms(cmd Cmd) []string { + var sf []string + for _, sc := range cmd.subcommands { + sf = append(sf, allShortForms(sc)...) + } + + for i := 0; i < len(cmd.shortForms); i += 2 { + sf = append(sf, cmd.shortForms[i]) + } + + return sf +} + +func validateCommand(cmd Cmd, conf Config) error { + assignedShortForms := make(map[string]string) + if err := validateCommandTree(cmd, conf, assignedShortForms); err != nil { + return err + } + + asf := allShortForms(cmd) + for _, sf := range asf { + if _, ok := assignedShortForms[sf]; !ok { + return fmt.Errorf("unassigned option short form: %s", sf) + } + } + + return nil +} diff --git a/commandline_test.go b/commandline_test.go index 8c89709..8319ee8 100644 --- a/commandline_test.go +++ b/commandline_test.go @@ -1,5 +1,6 @@ package wand +/* import ( "fmt" "strings" @@ -237,3 +238,4 @@ func TestCommand(t *testing.T) { t.Run("default", testExec(t, Command("", nil, Command("bar", ff), Default(Command("baz", ff))), "", "foo", "", "0")) }) } +*/ diff --git a/config.go b/config.go new file mode 100644 index 0000000..27faea9 --- /dev/null +++ b/config.go @@ -0,0 +1,191 @@ +package wand + +import ( + "errors" + "fmt" + "github.com/iancoleman/strcase" + "io" + "os" +) + +type file struct { + filename string + file io.ReadCloser +} + +type config struct { + values map[string][]string + discard []string + originalNames map[string]string +} + +func fileReader(filename string) io.ReadCloser { + return &file{ + filename: filename, + } +} + +func (f *file) wrapErr(err error) error { + return fmt.Errorf("%s: %w", f.filename, err) +} + +func (f *file) Read(p []byte) (int, error) { + if f.file == nil { + file, err := os.Open(f.filename) + if err != nil { + return 0, f.wrapErr(err) + } + + f.file = file + } + + n, err := f.file.Read(p) + if err != nil { + return n, f.wrapErr(err) + } + + return n, nil +} + +func (f *file) Close() error { + if f.file == nil { + return nil + } + + if err := f.file.Close(); err != nil { + return f.wrapErr(err) + } + + return nil +} + +func readConfigFile(cmd Cmd, conf Config) (config, error) { + f := conf.file(cmd) + defer f.Close() + doc, err := parse(f) + if err != nil { + if conf.optional && (errors.Is(err, os.ErrPermission) || errors.Is(err, os.ErrNotExist)) { + return config{}, nil + } + + return config{}, fmt.Errorf("failed to read config file: %w", err) + } + + var c config + for _, entry := range doc.Nodes { + if entry.Name != "key-val" { + continue + } + + var ( + key string + value string + hasValue bool + ) + + for _, token := range entry.Nodes { + if token.Name == "key" { + key = token.Text() + continue + } + + if token.Name == "value" { + value = token.Text() + hasValue = true + continue + } + } + + if c.originalNames == nil { + c.originalNames = make(map[string]string) + } + + name := strcase.ToKebab(key) + c.originalNames[name] = key + if !hasValue { + c.discard = append(c.discard, name) + continue + } + + if c.values == nil { + c.values = make(map[string][]string) + } + + c.values[name] = append(c.values[name], value) + } + + return c, nil +} + +func readConfigFromOption(cmd Cmd, cl commandLine, conf Config) (config, error) { + f := mapFields(cmd.impl) + if _, ok := f["config"]; ok { + return config{}, nil + } + + var c []Config + for _, o := range cl.options { + if o.name != "config" { + continue + } + + c = append(c, Config{file: func(Cmd) io.ReadCloser { return fileReader(o.value.str) }}) + } + + return readConfig(cmd, cl, Config{merge: c}) +} + +func readMergeConfig(cmd Cmd, cl commandLine, conf Config) (config, error) { + var c []config + for _, ci := range conf.merge { + cci, err := readConfig(cmd, cl, ci) + if err != nil { + return config{}, err + } + + c = append(c, cci) + } + + var mc config + for _, ci := range c { + for _, d := range ci.discard { + delete(mc.values, d) + } + + for name, values := range ci.values { + if mc.values == nil { + mc.values = make(map[string][]string) + } + + mc.values[name] = values + } + } + + return mc, nil +} + +func readConfig(cmd Cmd, cl commandLine, conf Config) (config, error) { + if conf.file != nil { + return readConfigFile(cmd, conf) + } + + if conf.fromOption { + return readConfigFromOption(cmd, cl, conf) + } + + return readMergeConfig(cmd, cl, conf) +} + +func hasConfigFromOption(conf Config) bool { + if conf.fromOption { + return true + } + + for _, c := range conf.merge { + if hasConfigFromOption(c) { + return true + } + } + + return false +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..f32346a --- /dev/null +++ b/config_test.go @@ -0,0 +1,44 @@ +package wand + +import ( + "bytes" + "code.squareroundforest.org/arpio/notation" + "testing" +) + +func TestConfig(t *testing.T) { + type options struct { + FooBarBaz int + Foo string + FooBar []string + } + + impl := func(o options) { + if o.FooBarBaz != 42 { + t.Fatal(notation.Sprintw(o)) + } + + if o.Foo != "" { + t.Fatal(notation.Sprintw(o)) + } + + if len(o.FooBar) != 2 || o.FooBar[0] != "bar" || o.FooBar[1] != "baz" { + t.Fatal(notation.Sprintw(o)) + } + } + + stdin := bytes.NewBuffer(nil) + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + + exec( + stdin, + stdout, + stderr, + func(int) {}, + Command("test", impl), + SystemConfig(), + nil, + []string{"test", "--config", "./internal/tests/config.ini"}, + ) +} diff --git a/env_test.go b/env_test.go index de50f9d..e4497b9 100644 --- a/env_test.go +++ b/env_test.go @@ -1,5 +1,6 @@ package wand +/* import ( "fmt" "strings" @@ -40,3 +41,4 @@ func TestEnv(t *testing.T) { t.Run("escape char last", testExec(t, fm, "fooOne=bar\\", "foo", "", "bar;")) }) } +*/ diff --git a/exec.go b/exec.go index b881767..1224072 100644 --- a/exec.go +++ b/exec.go @@ -5,55 +5,90 @@ import ( "fmt" "github.com/iancoleman/strcase" "io" + "os" "path/filepath" + "strconv" ) -func exec(stdout, stderr io.Writer, exit func(int), cmd Cmd, 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.name = filepath.Split(args[0]) cmd.name = strcase.ToKebab(cmd.name) - if err := validateCommand(cmd); err != nil { + if err := validateCommand(cmd, conf); err != nil { panic(err) } - args = args[1:] + if os.Getenv("wandgenerate") == "man" { + if err := generateMan(stdout, cmd); err != nil { + fmt.Fprintln(stderr, err) + exit(1) + } + + return + } + + if os.Getenv("wandgenerate") == "markdown" { + level, _ := strconv.Atoi(os.Getenv("wandmarkdownlevel")) + if err := generateMarkdown(stdout, cmd, level); err != nil { + fmt.Fprintln(stderr, err) + exit(1) + } + + return + } + e := readEnv(cmd.name, env) - cmd, fullCmd, args := selectCommand(cmd, args) + cmd, fullCmd, args := selectCommand(cmd, args[1:]) if cmd.impl == nil { - fmt.Fprint(stderr, errors.New("subcommand not specified")) + fmt.Fprintln(stderr, errors.New("subcommand not specified")) suggestHelp(stderr, cmd, fullCmd) exit(1) return } if cmd.helpRequested { - showHelp(stdout, cmd, fullCmd) + if err := showHelp(stdout, cmd, fullCmd); err != nil { + fmt.Fprintln(stderr, err) + exit(1) + } + return } bo := boolOptions(cmd) cl := readArgs(bo, args) if hasHelpOption(cmd, cl.options) { - showHelp(stdout, cmd, fullCmd) + if err := showHelp(stdout, cmd, fullCmd); err != nil { + fmt.Fprintln(stderr, err) + exit(1) + } + return } - if err := validateInput(cmd, e, cl); err != nil { - fmt.Fprint(stderr, err) + c, err := readConfig(cmd, cl, conf) + if err != nil { + fmt.Fprintf(stderr, "configuration error: %v", err) + exit(1) + return + } + + if err := validateInput(cmd, conf, c, e, cl); err != nil { + fmt.Fprintln(stderr, err) suggestHelp(stderr, cmd, fullCmd) exit(1) return } - output, err := apply(cmd, e, cl) + output, err := apply(stdin, stdout, cmd, c, e, cl) if err != nil { - fmt.Fprint(stderr, err) + fmt.Fprintln(stderr, err) exit(1) return } if err := printOutput(stdout, output); err != nil { - fmt.Fprint(stderr, err) + fmt.Fprintln(stderr, err) exit(1) return } diff --git a/exec_test.go b/exec_test.go index 3e0d25a..70236c5 100644 --- a/exec_test.go +++ b/exec_test.go @@ -1,5 +1,6 @@ package wand +/* import ( "bytes" "fmt" @@ -43,3 +44,4 @@ func testExec(impl any, env, commandLine, err string, expect ...string) func(*te } } } +*/ diff --git a/go.mod b/go.mod index cb0ff3b..3f69a13 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,12 @@ module code.squareroundforest.org/arpio/wand -go 1.24.2 +go 1.24.6 require ( - code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174 // indirect - github.com/iancoleman/strcase v0.3.0 // indirect + code.squareroundforest.org/arpio/docreflect v0.0.0-20250823192303-755a103f3788 + code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174 + code.squareroundforest.org/arpio/treerack v0.0.0-20250820014405-1d956dcc6610 + github.com/iancoleman/strcase v0.3.0 ) + +require golang.org/x/mod v0.27.0 // indirect diff --git a/go.sum b/go.sum index 38e8be7..5474983 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,12 @@ +code.squareroundforest.org/arpio/docreflect v0.0.0-20250823192303-755a103f3788 h1:jJoq0FdasFFDX1uJowXD8iyX/2G3gjwxtVEDyXtfeuw= +code.squareroundforest.org/arpio/docreflect v0.0.0-20250823192303-755a103f3788/go.mod h1:/3xQI36oJG8qLBxT2fSS61P5/+i1T64fTX9GHRh8XhA= code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174 h1:DKMSagVY3uyRhJ4ohiwQzNnR6CWdVKLkg97A8eQGxQU= code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY= +code.squareroundforest.org/arpio/treerack v0.0.0-20250820014405-1d956dcc6610 h1:I0jebdyQQfqJcwq2lT/TkUPBU8secHa5xZ+VzOdYVsw= +code.squareroundforest.org/arpio/treerack v0.0.0-20250820014405-1d956dcc6610/go.mod h1:9XhPcVt1Y1M609z02lHvEcp00dwPD9NUCoVxS2TpcH8= +github.com/aryszka/notation v0.0.0-20230129164653-172017dde5e4 h1:JzqT9RArcw2sD4QPAyTss/sHaCZvCv+91DDJPZOrShw= +github.com/aryszka/notation v0.0.0-20230129164653-172017dde5e4/go.mod h1:myJFmFAZ/75y5xdA1jjpc4ItNJwdRqaL+TQhIvDU8Vk= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= diff --git a/help.go b/help.go index 6954663..a3620af 100644 --- a/help.go +++ b/help.go @@ -1,26 +1,54 @@ package wand import ( + "code.squareroundforest.org/arpio/docreflect" "fmt" "io" + "reflect" + "sort" "strings" ) -type ( - synopsis struct{} - docOptions struct{} - docArguments struct{} - docSubcommands struct{} -) +const defaultWrap = 112 -type doc struct { - name string - synopsis synopsis - description string - options docOptions - arguments docArguments - subcommands docSubcommands -} +type ( + argumentSet struct { + count int + names []string + variadic bool + usesStdin bool + usesStdout bool + } + + synopsis struct { + command string + hasOptions bool + arguments argumentSet + hasSubcommands bool + } + + docOption struct { + name string + description string + shortNames []string + isBool bool + acceptsMultiple bool + } + + doc struct { + name string + fullCommand string + synopsis synopsis + description string + isHelp bool + isDefault bool + hasHelpSubcommand bool + hasHelpOption bool + options []docOption + arguments argumentSet + subcommands []doc + } +) func help() Cmd { return Cmd{ @@ -73,9 +101,323 @@ func suggestHelp(out io.Writer, cmd Cmd, fullCommand []string) { } } -func constructDoc(cmd Cmd, fullCommand []string) doc { - return doc{} +func hasOptions(cmd Cmd) bool { + if cmd.impl == nil { + return false + } + + v := reflect.ValueOf(cmd.impl) + t := v.Type() + t = unpack(t) + s := structParameters(t) + return len(fields(s...)) > 0 } -func showHelp(out io.Writer, cmd Cmd, fullCommand []string) { +func functionParams(v reflect.Value, skip []int) []string { + names := docreflect.FunctionParams(v) + for _, i := range skip { + names = append(names[:i], names[i+1:]...) + } + + return names +} + +func constructArguments(cmd Cmd) argumentSet { + if cmd.impl == nil { + return argumentSet{} + } + + v := reflect.ValueOf(cmd.impl) + t := unpack(v.Type()) + p := positionalParameters(t) + ior, iow := ioParameters(p) + count := len(p) - len(ior) - len(iow) + names := functionParams(v, append(ior, iow...)) + if len(names) < count { + names = nil + for i := 0; i < count; i++ { + names = append(names, fmt.Sprintf("arg%d", i)) + } + } + + return argumentSet{ + count: count, + names: names, + variadic: t.IsVariadic(), + usesStdin: len(ior) > 0, + usesStdout: len(iow) > 0, + } +} + +func constructSynopsis(cmd Cmd, fullCommand []string) synopsis { + return synopsis{ + command: strings.Join(fullCommand, " "), + hasOptions: hasOptions(cmd), + arguments: constructArguments(cmd), + hasSubcommands: len(cmd.subcommands) > 0, + } +} + +func constructDescription(cmd Cmd) string { + if cmd.impl == nil { + return "" + } + + return strings.TrimSpace(docreflect.Function(reflect.ValueOf(cmd.impl))) +} + +func constructOptions(cmd Cmd) []docOption { + if cmd.impl == nil { + return nil + } + + sf := make(map[string][]string) + for i := 0; i < len(cmd.shortForms); i += 2 { + l, s := cmd.shortForms[i], cmd.shortForms[i+1] + sf[l] = append(sf[l], s) + } + + t := unpack(reflect.ValueOf(cmd.impl).Type()) + s := structParameters(t) + d := make(map[string]string) + for _, si := range s { + f := fields(si) + for _, fi := range f { + d[fi.name] = docreflect.Field(si, fi.path...) + } + } + + var o []docOption + f := mapFields(cmd.impl) + for name, fi := range f { + opt := docOption{ + name: name, + description: d[name], + shortNames: sf[name], + isBool: fi[0].typ.Kind() == reflect.Bool, + } + + for _, fii := range fi { + if fii.acceptsMultiple { + opt.acceptsMultiple = true + } + } + + o = append(o, opt) + } + + return o +} + +func constructDoc(cmd Cmd, fullCommand []string) doc { + var subcommands []doc + for _, sc := range cmd.subcommands { + subcommands = append(subcommands, constructDoc(sc, append(fullCommand, sc.name))) + } + + return doc{ + name: fullCommand[len(fullCommand)-1], + fullCommand: strings.Join(fullCommand, " "), + synopsis: constructSynopsis(cmd, fullCommand), + description: constructDescription(cmd), + isDefault: cmd.isDefault, + isHelp: cmd.isHelp, + hasHelpSubcommand: hasHelpSubcommand(cmd), + hasHelpOption: hasCustomHelpOption(cmd), + options: constructOptions(cmd), + arguments: constructArguments(cmd), + subcommands: subcommands, + } +} + +func formatHelp(w io.Writer, doc doc) error { + var err error + println := func(a ...any) { + if err != nil { + return + } + + _, err = fmt.Fprintln(w, a...) + } + + printf := func(f string, a ...any) { + if err != nil { + return + } + + _, err = fmt.Fprintf(w, f, a...) + } + + printf(doc.fullCommand) + println() + println() + printf("Synopsis") + println() + println() + printf(doc.synopsis.command) + if doc.synopsis.hasOptions { + printf(" [options ...]") + } + + for _, n := range doc.synopsis.arguments.names { + printf(" %s", n) + } + + if doc.synopsis.arguments.variadic { + printf("...") + } + + println() + if doc.synopsis.hasSubcommands { + printf("%s [options or args...]", doc.synopsis.command) + println() + println() + printf("(For the details about the available subcommands, see the according section below.)") + } + + if len(doc.description) > 0 { + println() + println() + printf(doc.description) + } + + if len(doc.options) > 0 { + println() + println() + printf("Options") + println() + println() + printf("[*]: accepts multiple instances of the same option") + println() + printf("[b]: booelan flag, true or false, or no argument means true") + println() + + var names []string + od := make(map[string]string) + for _, o := range doc.options { + ons := []string{fmt.Sprintf("--%s", o.name)} + for _, sn := range o.shortNames { + ons = append(ons, fmt.Sprintf("-%s", sn)) + } + + n := strings.Join(ons, ", ") + if o.acceptsMultiple { + n = fmt.Sprintf("%s [*]", n) + } + + if o.isBool { + n = fmt.Sprintf("%s [b]", n) + } + + names = append(names, n) + od[n] = o.description + } + + sort.Strings(names) + + var max int + for _, n := range names { + if len(n) > max { + max = len(n) + } + } + + for i := range names { + pad := strings.Join(make([]string, max-len(names[i])+1), " ") + names[i] = fmt.Sprintf("%s%s", names[i], pad) + } + + for _, n := range names { + println() + printf(n) + if od[n] != "" { + printf(": %s", od[n]) + } + } + } + + if len(doc.subcommands) > 0 { + println() + println() + printf("Subcommands") + println() + + var names []string + cd := make(map[string]string) + for _, sc := range doc.subcommands { + name := sc.name + if sc.isDefault { + name = fmt.Sprintf("%s (default)", name) + } + + d := sc.description + if sc.isHelp { + d = fmt.Sprintf("Show this help. %s", d) + } + + if sc.hasHelpSubcommand { + d = fmt.Sprintf("%s - For help, see: %s %s help", d, doc.name, sc.name) + } else if sc.hasHelpOption { + d = fmt.Sprintf("%s - For help, see: %s %s --help", d, doc.name, sc.name) + } + + cd[name] = d + } + + sort.Strings(names) + + var max int + for _, n := range names { + if len(n) > max { + max = len(n) + } + } + + for i := range names { + pad := strings.Join(make([]string, max-len(names[i])+1), " ") + names[i] = fmt.Sprintf("%s%s", names[i], pad) + } + + for _, n := range names { + println() + printf(n) + if cd[n] != "" { + printf(": %s", cd[n]) + } + } + } + + println() + return err +} + +func showHelp(out io.Writer, cmd Cmd, fullCommand []string) error { + doc := constructDoc(cmd, fullCommand) + return formatHelp(out, doc) +} + +func formatMan(out io.Writer, doc doc) error { + // if no subcommands, then similar to help + // otherwise: + // title + // all commands + return nil +} + +func generateMan(out io.Writer, cmd Cmd) error { + doc := constructDoc(cmd, []string{cmd.name}) + return formatMan(out, doc) +} + +func formatMarkdown(out io.Writer, doc doc) error { + // if no subcommands, then similar to help + // otherwise: + // title + // all commands + return nil +} + +func generateMarkdown(out io.Writer, cmd Cmd, level int) error { + doc := constructDoc(cmd, []string{cmd.name}) + return formatMarkdown(out, doc) } diff --git a/ini.treerack b/ini.treerack new file mode 100644 index 0000000..ef65b5d --- /dev/null +++ b/ini.treerack @@ -0,0 +1,10 @@ +whitespace:ws = [ \b\f\r\t\v]; +comment-line:alias = "#" [^\n]*; +comment = comment-line ("\n" comment-line)*; +quoted:alias:nows = "\"" ([^\\"] | "\\" .)* "\""; +word:alias:nows = [a-zA-Z_]([a-zA-Z_0-9\-] | "\\" .)*; +key = word | quoted; +value-chars:alias:nows = ([^\\"\n=# \b\f\r\t\v] | "\\" .)+; +value = value-chars+ | quoted; +key-val = (comment "\n")? key ("=" value?)? comment-line?; +doc:root = (key-val | comment-line | "\n")*; diff --git a/input.go b/input.go index 47071e9..95fe5ac 100644 --- a/input.go +++ b/input.go @@ -6,9 +6,9 @@ import ( "slices" ) -func validateEnv(cmd Cmd, e env) error { +func validateKeyValues(cmd Cmd, keyValues map[string][]string, originalNames map[string]string) error { mf := mapFields(cmd.impl) - for name, values := range e.values { + for name, values := range keyValues { f, ok := mf[name] if !ok { continue @@ -19,7 +19,7 @@ func validateEnv(cmd Cmd, e env) error { return fmt.Errorf( "expected only one value, received %d, as environment value, %s", len(values), - e.originalNames[name], + originalNames[name], ) } @@ -27,7 +27,7 @@ func validateEnv(cmd Cmd, e env) error { if !canScan(fi.typ, v) { return fmt.Errorf( "environment variable cannot be applied, type mismatch: %s", - e.originalNames[name], + originalNames[name], ) } } @@ -37,7 +37,15 @@ func validateEnv(cmd Cmd, e env) error { return nil } -func validateOptions(cmd Cmd, o []option) error { +func validateConfig(cmd Cmd, c config) error { + return validateKeyValues(cmd, c.values, c.originalNames) +} + +func validateEnv(cmd Cmd, e env) error { + return validateKeyValues(cmd, e.values, e.originalNames) +} + +func validateOptions(cmd Cmd, o []option, conf Config) error { ml := make(map[string]string) ms := make(map[string]string) for i := 0; i < len(cmd.shortForms); i += 2 { @@ -57,14 +65,25 @@ func validateOptions(cmd Cmd, o []option) error { } mf := mapFields(cmd.impl) - for n, os := range mo { - f := mf[n] - for _, fi := range f { - en := "--" + n - if sn, ok := ml[n]; ok { - en += ", -" + sn - } + if hasConfigFromOption(conf) { + mf["config"] = []field{{ + acceptsMultiple: true, + typ: reflect.TypeFor[string](), + }} + } + for n, os := range mo { + en := "--" + n + if sn, ok := ml[n]; ok { + en += ", -" + sn + } + + f := mf[n] + if len(f) == 0 { + return fmt.Errorf("option not supported: %s", en) + } + + for _, fi := range f { if len(os) > 1 && !fi.acceptsMultiple { return fmt.Errorf( "expected only one value, received %d, as option, %s", @@ -100,7 +119,7 @@ func validatePositionalArgs(cmd Cmd, a []string) error { t := v.Type() p := positionalParameters(t) ior, iow := ioParameters(p) - last := t.NumIn()-1 + last := t.NumIn() - 1 lastVariadic := t.IsVariadic() && !isStruct(t.In(last)) && !slices.Contains(ior, last) && @@ -149,12 +168,16 @@ func validatePositionalArgs(cmd Cmd, a []string) error { return nil } -func validateInput(cmd Cmd, e env, cl commandLine) error { +func validateInput(cmd Cmd, conf Config, c config, e env, cl commandLine) error { + if err := validateConfig(cmd, c); err != nil { + return err + } + if err := validateEnv(cmd, e); err != nil { return err } - if err := validateOptions(cmd, cl.options); err != nil { + if err := validateOptions(cmd, cl.options, conf); err != nil { return err } diff --git a/internal/tests/config.ini b/internal/tests/config.ini new file mode 100644 index 0000000..2d5dc5a --- /dev/null +++ b/internal/tests/config.ini @@ -0,0 +1,8 @@ +# test config + +# another comment +foo_bar_baz = 42 # a comment +foo +foo_bar = bar + +foo_bar = baz diff --git a/notes.txt b/notes.txt index 3f22f14..bb668b6 100644 --- a/notes.txt +++ b/notes.txt @@ -1,4 +1,7 @@ -io.Writer arg: pass in os.Stdout -io.Reader arg: pass in os.Stdin -test: method docs -during validation, reject circular type references +help: +- what if cmd.impl is nil, but there is a default? +- config in help +- min/max args in help +- env vars in help +- test: method docs +- testing formatting may need to be necessary for the help docs diff --git a/reflect.go b/reflect.go index 97adb71..384b407 100644 --- a/reflect.go +++ b/reflect.go @@ -1,10 +1,12 @@ package wand import ( + "fmt" "github.com/iancoleman/strcase" + "io" "reflect" "strconv" - "io" + "strings" ) type packedKind[T any] interface { @@ -14,6 +16,7 @@ type packedKind[T any] interface { type field struct { name string + path []string typ reflect.Type acceptsMultiple bool } @@ -64,16 +67,44 @@ func isStruct(t reflect.Type) bool { return t.Kind() == reflect.Struct } +func parseInt(s string, byteSize int) (int64, error) { + bitSize := byteSize * 8 + switch { + case strings.HasPrefix(s, "0b"): + return strconv.ParseInt(s[2:], 2, bitSize) + case strings.HasPrefix(s, "0x"): + return strconv.ParseInt(s[2:], 16, bitSize) + case strings.HasPrefix(s, "0"): + return strconv.ParseInt(s[1:], 8, bitSize) + default: + return strconv.ParseInt(s[2:], 2, byteSize*8) + } +} + +func parseUint(s string, byteSize int) (uint64, error) { + bitSize := byteSize * 8 + switch { + case strings.HasPrefix(s, "0b"): + return strconv.ParseUint(s[2:], 2, bitSize) + case strings.HasPrefix(s, "0x"): + return strconv.ParseUint(s[2:], 16, bitSize) + case strings.HasPrefix(s, "0"): + return strconv.ParseUint(s[1:], 8, bitSize) + default: + return strconv.ParseUint(s[2:], 2, bitSize) + } +} + func canScan(t reflect.Type, s string) bool { switch t.Kind() { case reflect.Bool: _, err := strconv.ParseBool(s) return err == nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - _, err := strconv.ParseInt(s, 10, int(t.Size())*8) + _, err := parseInt(s, int(t.Size())) return err == nil case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - _, err := strconv.ParseUint(s, 10, int(t.Size())*8) + _, err := parseUint(s, int(t.Size())) return err == nil case reflect.Float32, reflect.Float64: _, err := strconv.ParseFloat(s, int(t.Size())*8) @@ -92,10 +123,10 @@ func scan(t reflect.Type, s string) any { v, _ := strconv.ParseBool(s) p.Elem().Set(reflect.ValueOf(v).Convert(t)) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - v, _ := strconv.ParseInt(s, 10, int(t.Size())*8) + v, _ := parseInt(s, int(t.Size())) p.Elem().Set(reflect.ValueOf(v).Convert(t)) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - v, _ := strconv.ParseUint(s, 10, int(t.Size())*8) + v, _ := parseUint(s, int(t.Size())) p.Elem().Set(reflect.ValueOf(v).Convert(t)) case reflect.Float32, reflect.Float64: v, _ := strconv.ParseFloat(s, int(t.Size())*8) @@ -107,9 +138,9 @@ func scan(t reflect.Type, s string) any { return p.Elem().Interface() } -func fields(s ...reflect.Type) []field { +func fieldsChecked(visited map[reflect.Type]bool, s ...reflect.Type) ([]field, error) { if len(s) == 0 { - return nil + return nil, nil } var ( @@ -139,18 +170,42 @@ func fields(s ...reflect.Type) []field { reflect.Float32, reflect.Float64, reflect.String: - plainFields = append(plainFields, field{name: sfn, typ: sft, acceptsMultiple: am}) + plainFields = append(plainFields, field{ + name: sfn, + path: []string{sf.Name}, + typ: sft, + acceptsMultiple: am, + }) case reflect.Interface: if sft.NumMethod() == 0 { - plainFields = append(plainFields, field{name: sfn, typ: sft, acceptsMultiple: am}) + plainFields = append(plainFields, field{ + name: sfn, + path: []string{sf.Name}, + typ: sft, + acceptsMultiple: am, + }) } case reflect.Struct: - sff := fields(sft) + if visited[sft] { + return nil, fmt.Errorf("circular type definitions not allowed: %s", sft.Name()) + } + + if visited == nil { + visited = make(map[reflect.Type]bool) + } + + visited[sft] = true + sff, err := fieldsChecked(visited, sft) + if err != nil { + return nil, err + } + if sf.Anonymous { anonFields = append(anonFields, sff...) } else { for i := range sff { sff[i].name = sfn + "-" + sff[i].name + sff[i].path = append([]string{sf.Name}, sff[i].path...) sff[i].acceptsMultiple = sff[i].acceptsMultiple || am } @@ -173,7 +228,17 @@ func fields(s ...reflect.Type) []field { f = append(f, fi) } - return append(f, fields(s[1:]...)...) + ff, err := fieldsChecked(visited, s[1:]...) + if err != nil { + return nil, err + } + + return append(f, ff...), nil +} + +func fields(s ...reflect.Type) []field { + f, _ := fieldsChecked(nil, s...) + return f } func boolFields(f []field) []field { diff --git a/script/docreflect/docs.go b/script/docreflect/docs.go new file mode 100644 index 0000000..a07db91 --- /dev/null +++ b/script/docreflect/docs.go @@ -0,0 +1,17 @@ +package main + +import ( + "code.squareroundforest.org/arpio/docreflect/generate" + "log" + "os" +) + +func main() { + if len(os.Args) < 2 { + log.Fatalln("expected package name") + } + + if err := generate.GenerateRegistry(os.Stdout, os.Args[1], os.Args[2:]...); err != nil { + log.Fatalln(err) + } +} diff --git a/script/ini-parser/parser.go b/script/ini-parser/parser.go new file mode 100644 index 0000000..aa341e8 --- /dev/null +++ b/script/ini-parser/parser.go @@ -0,0 +1,31 @@ +package main + +import ( + "code.squareroundforest.org/arpio/treerack" + "log" + "os" +) + +func main() { + packageName := "wand" + if len(os.Args) > 1 { + packageName = os.Args[1] + } + + syntax := &treerack.Syntax{} + if err := syntax.ReadSyntax(os.Stdin); err != nil { + log.Fatalln(err) + } + + if err := syntax.Init(); err != nil { + log.Fatalln(err) + } + + options := treerack.GeneratorOptions{ + PackageName: packageName, + } + + if err := syntax.Generate(options, os.Stdout); err != nil { + log.Fatalln(err) + } +} diff --git a/tools/tools.go b/tools/tools.go new file mode 100644 index 0000000..ed051d7 --- /dev/null +++ b/tools/tools.go @@ -0,0 +1,258 @@ +package tools + +import ( + "bytes" + "code.squareroundforest.org/arpio/docreflect/generate" + "encoding/base64" + "errors" + "fmt" + "hash/fnv" + "io" + "os" + "os/exec" + "path" + "strings" +) + +type ExecOptions struct { + NoCache bool + PurgeCache bool + CacheDir string +} + +func execc(stdin io.Reader, stdout, stderr io.Writer, command string, args []string, env []string) error { + c := strings.Split(command, " ") + cmd := exec.Command(c[0], append(c[1:], args...)...) + cmd.Env = append(os.Environ(), env...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + +func execCommandDir(out io.Writer, commandDir string, env ...string) error { + stderr := bytes.NewBuffer(nil) + if err := execc(nil, out, stderr, "go run", []string{commandDir}, env); err != nil { + io.Copy(os.Stderr, stderr) + return err + } + + return nil +} + +func execInternal(command string, args ...string) error { + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + if err := execc(nil, stdout, stderr, command, args, nil); err != nil { + io.Copy(os.Stderr, stdout) + io.Copy(os.Stderr, stderr) + return err + } + + return nil +} + +func execTransparent(command string, args ...string) error { + return execc(os.Stdin, os.Stdout, os.Stderr, command, args, nil) +} + +func Docreflect(out io.Writer, packageName string, gopaths ...string) error { + return generate.GenerateRegistry(out, packageName, gopaths...) +} + +func Man(out io.Writer, commandDir string) error { + return execCommandDir(out, commandDir, "wandgenerate=man") +} + +func Markdown(out io.Writer, commandDir string) error { + return execCommandDir(out, commandDir, "wandgenerate=markdown") +} + +func splitFunction(function string) (pkg string, expression string, err error) { + parts := strings.Split(function, "/") + gopath := parts[:len(parts)-1] + sparts := strings.Split(parts[len(parts)-1], ".") + if len(sparts) == 1 && len(gopath) > 1 { + err = errors.New("function cannot be identified") + return + } + + if len(sparts) == 1 { + expression = sparts[0] + } else { + pkg = strings.Join(append(gopath[:len(gopath)-1], sparts[0]), "/") + expression = gopath[len(parts)-1] + } + + return +} + +func functionHash(function string) (string, error) { + h := fnv.New128() + h.Write([]byte(function)) + buf := bytes.NewBuffer(nil) + b64 := base64.NewEncoder(base64.URLEncoding, buf) + if _, err := b64.Write(h.Sum(nil)); err != nil { + return "", fmt.Errorf("failed to encode function: %w", err) + } + + if err := b64.Close(); err != nil { + return "", fmt.Errorf("failed to complete encoding of function: %w", err) + } + + return buf.String(), nil +} + +func findGomod(wd string) (string, bool) { + gomodDir := wd + for { + gomodPath := path.Join(gomodDir, "go.mod") + f, err := os.Stat(gomodPath) + if err == nil && !f.IsDir() { + return gomodPath, true + } + + if gomodDir == "/" { + return "", false + } + + gomodDir = path.Dir(gomodDir) + } +} + +func copyFile(dst, src string) error { + srcf, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open file: %s; %w", src, err) + } + + defer srcf.Close() + dstf, err := os.Create(dst) + if err != nil { + return fmt.Errorf("failed to create file: %s; %w", dst, err) + } + + defer dstf.Close() + if _, err := io.Copy(dstf, srcf); err != nil { + return fmt.Errorf("failed to copy file %s to %s; %w", src, dst, err) + } + + return nil +} + +func printFile(fn string, pkg, expression string) error { + f, err := os.Create(fn) + if err != nil { + return err + } + + defer f.Close() + fprintf := func(format string, args ...any) { + if err != nil { + return + } + + _, err = fmt.Fprintf(f, format, args...) + } + + fprintf("package main\n") + if pkg != "" { + fprintf("import \"%s\"\n", pkg) + } + + fprintf("import \"code.squareroundforest.org/arpio/wand\"\n") + fprintf("func main() {\n") + fprintf("wand.Exec(%s)\n", expression) + fprintf("}") + return err +} + +func Exec(o ExecOptions, function string, args ...string) error { + pkg, expression, err := splitFunction(function) + if err != nil { + return err + } + + functionHash, err := functionHash(function) + if err != nil { + return err + } + + cacheDir := o.CacheDir + if cacheDir == "" { + path.Join(os.Getenv("HOME"), ".wand") + } + + functionDir := path.Join(cacheDir, functionHash) + if o.NoCache { + functionDir = path.Join(cacheDir, "tmp", functionHash) + } + + if o.NoCache || o.PurgeCache { + if err := os.RemoveAll(functionDir); err != nil { + return fmt.Errorf("failed to clean cache: %w", err) + } + } + + if err := os.MkdirAll(functionDir, os.ModePerm); err != nil { + return fmt.Errorf("failed to ensure cache directory: %w", err) + } + + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("error identifying current directory: %w", err) + } + + goGet := func(pkg string) error { + if err := execInternal("go get", pkg); err != nil { + return fmt.Errorf("failed to get go module: %w", err) + } + + return nil + } + + if err := os.Chdir(functionDir); err != nil { + return fmt.Errorf("failed to switch to temporary directory: %w", err) + } + + defer os.Chdir(wd) + gomodPath, hasGomod := findGomod(wd) + if hasGomod { + if err := copyFile(path.Join(functionDir, "go.mod"), gomodPath); err != nil { + return err + } + } else { + if err := execInternal("go mod init", functionHash); err != nil { + return fmt.Errorf("failed to initialize temporary module: %w", err) + } + } + + if pkg != "" { + if err := goGet(pkg); err != nil { + return err + } + } + + if err := goGet("code.squareroundforest.org/arpio/wand"); err != nil { + return err + } + + goFile := path.Join(functionDir, fmt.Sprintf("%s.go", functionHash)) + if _, err := os.Stat(goFile); err != nil { + if err := printFile(goFile, pkg, expression); err != nil { + return fmt.Errorf("failed to create temporary go file: %w", err) + } + } + + if err := execTransparent("go run", append([]string{functionDir}, args...)...); err != nil { + return err + } + + if o.NoCache { + if err := os.RemoveAll(functionDir); err != nil { + return fmt.Errorf("failed to clean cache: %w", err) + } + } + + return nil +} diff --git a/wand.go b/wand.go index 1188b86..0d0fe6f 100644 --- a/wand.go +++ b/wand.go @@ -1,6 +1,17 @@ package wand -import "os" +import ( + "io" + "os" + "path" +) + +type Config struct { + file func(Cmd) io.ReadCloser + merge []Config + fromOption bool + optional bool +} type Cmd struct { name string @@ -24,19 +35,65 @@ func Default(cmd Cmd) Cmd { return cmd } -// io doesn't count func Args(cmd Cmd, min, max int) Cmd { cmd.minPositional = min cmd.maxPositional = max return cmd } -func ShortForm(cmd Cmd, f ...string) Cmd { - cmd.shortForms = f +func ShortFormOptions(cmd Cmd, f ...string) Cmd { + cmd.shortForms = append(cmd.shortForms, f...) + for i := range cmd.subcommands { + cmd.subcommands[i] = ShortFormOptions( + cmd.subcommands[i], + f..., + ) + } + return cmd } -func Exec(impl any) { - cmd := wrap(impl) - exec(os.Stdout, os.Stderr, os.Exit, cmd, os.Environ(), os.Args) +func MergeConfig(conf ...Config) Config { + return Config{ + merge: conf, + } +} + +func OptionalConfig(conf Config) Config { + conf.optional = true + for i := range conf.merge { + conf.merge[i] = OptionalConfig(conf.merge[i]) + } + + return conf +} + +func Etc() Config { + return OptionalConfig(Config{ + file: func(cmd Cmd) io.ReadCloser { + return fileReader(path.Join("/etc", cmd.name, "config")) + }, + }) +} + +func UserConfig() Config { + return OptionalConfig(Config{ + file: func(cmd Cmd) io.ReadCloser { + return fileReader( + path.Join(os.Getenv("HOME"), ".config", cmd.name, "config"), + ) + }, + }) +} + +func ConfigFromOption() Config { + return Config{fromOption: true} +} + +func SystemConfig() Config { + return MergeConfig(Etc(), UserConfig(), ConfigFromOption()) +} + +func Exec(impl any, conf ...Config) { + exec(os.Stdin, os.Stdout, os.Stderr, os.Exit, wrap(impl), MergeConfig(conf...), os.Environ(), os.Args) }