diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7745062 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..6fd0fb9 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,27 @@ +name: Go + +on: [push] + +env: + GO_VERSION: '>=1.21.0' + +jobs: + + test: + name: Test + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ ubuntu-latest, macos-latest, windows-latest ] + steps: + + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - run: go test ./... + +# based on: github.com/koron-go/_skeleton/.github/workflows/go.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1519234 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*~ +default.pgo +tags +tmp/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a73ff2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 MURAOKA Taro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..876c2e5 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +TEST_PACKAGE ?= ./... + +.PHONY: build +build: + go build -gcflags '-e' + +.PHONY: test +test: + go test $(TEST_PACKAGE) + +.PHONY: bench +bench: + go test -bench $(TEST_PACKAGE) + +.PHONY: tags +tags: + gotags -f tags -R . + +.PHONY: cover +cover: + mkdir -p tmp + go test -coverprofile tmp/_cover.out $(TEST_PACKAGE) + go tool cover -html tmp/_cover.out -o tmp/cover.html + +.PHONY: checkall +checkall: vet staticcheck + +.PHONY: vet +vet: + go vet $(TEST_PACKAGE) + +.PHONY: staticcheck +staticcheck: + staticcheck $(TEST_PACKAGE) + +.PHONY: clean +clean: + go clean + rm -f tags + rm -f tmp/_cover.out tmp/cover.html + +# based on: github.com/koron-go/_skeleton/Makefile diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7649ef --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# koron-go/subcmd + +[![PkgGoDev](https://pkg.go.dev/badge/github.com/koron-go/subcmd)](https://pkg.go.dev/github.com/koron-go/subcmd) +[![Actions/Go](https://github.com/koron-go/subcmd/workflows/Go/badge.svg)](https://github.com/koron-go/subcmd/actions?query=workflow%3AGo) +[![Go Report Card](https://goreportcard.com/badge/github.com/koron-go/subcmd)](https://goreportcard.com/report/github.com/koron-go/subcmd) + +koron-go/subcmd is very easy and very simple sub-commander library. +It focuses solely on providing a hierarchical subcommand mechanism. +It does not provide any flags nor options. + +## Getting Started + +Install or update: + +```console +$ go install github.com/koron-go/subcmd@latest +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cc6cfbe --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/koron-go/subcmd + +go 1.21 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/staticcheck.conf b/staticcheck.conf new file mode 100644 index 0000000..8b70bd6 --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1,5 @@ +# vim:set ft=toml: + +checks = ["all"] + +# based on: github.com/koron-go/_skeleton/staticcheck.conf diff --git a/subcmd.go b/subcmd.go new file mode 100644 index 0000000..01f37c1 --- /dev/null +++ b/subcmd.go @@ -0,0 +1,181 @@ +/* +Package subcmd provides sub commander. +*/ +package subcmd + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Runner defines a base interface for Command and Set. +// Runner interface is defined for use only with DefineSet function. +type Runner interface { + // Name returns name of runner. + Name() string + + // Desc returns description of runner. + Desc() string + + // Run runs runner with context and arguments. + Run(ctx context.Context, args []string) error +} + +// CommandFunc is handler of sub-command, and an entry point. +type CommandFunc func(ctx context.Context, args []string) error + +// Command repreesents a sub-command, and implements Runner interface. +type Command struct { + name string + desc string + runFn CommandFunc +} + +var _ Runner = Command{} + +// DefineCommand defines a Command with name, desc, and function. +func DefineCommand(name, desc string, fn CommandFunc) Command { + return Command{ + name: name, + desc: desc, + runFn: fn, + } +} + +// Name returns name of the command. +func (c Command) Name() string { + return c.name +} + +// Desc returns description of the command. +func (c Command) Desc() string { + return c.desc +} + +// Run executes sub-command, which +func (c Command) Run(ctx context.Context, args []string) error { + ctx = withName(ctx, c) + if c.runFn == nil { + names := strings.Join(Names(ctx), " ") + return fmt.Errorf("no function declared for command: %s", names) + } + return c.runFn(ctx, args) +} + +// Set provides set of Commands or nested Sets. +type Set struct { + name string + desc string + Runners []Runner +} + +var _ Runner = Set{} + +// DefineSet defines a set of Runners with name, and desc. +func DefineSet(name, desc string, runners ...Runner) Set { + return Set{ + name: name, + desc: desc, + Runners: runners, + } +} + +// DefineRootSet defines a set of Runners which used as root of Set (maybe +// passed to Run). +func DefineRootSet(runners ...Runner) Set { + return Set{name: rootName(), Runners: runners} +} + +// Name returns name of Set. +func (s Set) Name() string { + return s.name +} + +// Desc returns description of Set. +func (s Set) Desc() string { + return s.desc +} + +// childRunner retrieves a child Runner with name +func (s Set) childRunner(name string) Runner { + for _, r := range s.Runners { + if r.Name() == name { + return r + } + } + return nil +} + +type errorSetRun struct { + src Set + msg string +} + +func (err *errorSetRun) Error() string { + // align width of name columns + w := 12 + for _, r := range err.src.Runners { + if n := len(r.Name()) + 1; n > w { + w = (n + 3) / 4 * 4 + } + } + // format error message + bb := &bytes.Buffer{} + fmt.Fprintf(bb, "%s.\n\nAvailable sub-commands are:\n", err.msg) + for _, r := range err.src.Runners { + fmt.Fprintf(bb, "\n\t%-*s%s", w, r.Name(), r.Desc()) + } + return bb.String() +} + +func (s Set) Run(ctx context.Context, args []string) error { + if len(args) == 0 { + return &errorSetRun{src: s, msg: "no commands selected"} + } + name := args[0] + child := s.childRunner(name) + if child == nil { + return &errorSetRun{src: s, msg: "command not found"} + } + return child.Run(withName(ctx, s), args[1:]) +} + +// Run runs a Runner with ctx and args. +func Run(ctx context.Context, r Runner, args ...string) error { + return r.Run(ctx, args) +} + +var keyNames = struct{}{} + +// Names retrives names layer of current sub command. +func Names(ctx context.Context) []string { + if names, ok := ctx.Value(keyNames).([]string); ok { + return names + } + return nil +} + +func withName(ctx context.Context, r Runner) context.Context { + return context.WithValue(ctx, keyNames, append(Names(ctx), r.Name())) +} + +func stripExeExt(in string) string { + _, out := filepath.Split(in) + ext := filepath.Ext(out) + if ext == ".exe" { + return out[:len(out)-len(ext)] + } + return out +} + +func rootName() string { + exe, err := os.Executable() + if err != nil { + panic(fmt.Sprintf("failed to obtain executable name: %s", err)) + } + return stripExeExt(exe) +} diff --git a/subcmd_test.go b/subcmd_test.go new file mode 100644 index 0000000..e5b9985 --- /dev/null +++ b/subcmd_test.go @@ -0,0 +1,176 @@ +package subcmd_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/koron-go/subcmd" +) + +func TestCommand(t *testing.T) { + var called bool + cmd := subcmd.DefineCommand("foo", t.Name(), func(context.Context, []string) error { + called = true + return nil + }) + err := subcmd.Run(context.Background(), cmd) + if err != nil { + t.Fatal(err) + } + if !called { + t.Error("command func is not called") + } +} + +func TestCommandNil(t *testing.T) { + cmd := subcmd.DefineCommand("foo", t.Name(), nil) + err := subcmd.Run(context.Background(), cmd) + if err == nil { + t.Fatal("unexpected succeed") + } + if d := cmp.Diff("no function declared for command: foo", err.Error()); d != "" { + t.Errorf("error unmatch: -want +got\n%s", d) + } +} + +func TestSet(t *testing.T) { + var ( + gotNames []string + gotArgs []string + ) + record := func(ctx context.Context, args []string) error { + gotNames = subcmd.Names(ctx) + gotArgs = args + return nil + } + + set := subcmd.DefineSet("set", "", + subcmd.DefineSet("user", "", + subcmd.DefineCommand("list", "", record), + subcmd.DefineCommand("add", "", record), + subcmd.DefineCommand("delete", "", record), + ), + subcmd.DefineSet("post", "", + subcmd.DefineCommand("list", "", record), + subcmd.DefineCommand("add", "", record), + subcmd.DefineCommand("delete", "", record), + ), + ) + + for i, c := range []struct { + args []string + wantNames []string + wantArgs []string + }{ + { + []string{"user", "list"}, + []string{"set", "user", "list"}, + []string{}, + }, + { + []string{"user", "add", "-email", "foobar@example.com"}, + []string{"set", "user", "add"}, + []string{"-email", "foobar@example.com"}, + }, + { + []string{"user", "delete", "-id", "123"}, + []string{"set", "user", "delete"}, + []string{"-id", "123"}, + }, + { + []string{"post", "list"}, + []string{"set", "post", "list"}, + []string{}, + }, + { + []string{"post", "add", "-title", "Brown fox..."}, + []string{"set", "post", "add"}, + []string{"-title", "Brown fox..."}, + }, + { + []string{"post", "delete", "-id", "ABC"}, + []string{"set", "post", "delete"}, + []string{"-id", "ABC"}, + }, + } { + err := subcmd.Run(context.Background(), set, c.args...) + if err != nil { + t.Fatalf("failed for case#%d (%+v): %s", i, c, err) + continue + } + if d := cmp.Diff(c.wantNames, gotNames); d != "" { + t.Errorf("unexpected names on #%d: -want +got\n%s", i, d) + } + if d := cmp.Diff(c.wantArgs, gotArgs); d != "" { + t.Errorf("unexpected args on #%d: -want +got\n%s", i, d) + } + } +} + +func TestSetFails(t *testing.T) { + rootSet := subcmd.DefineSet("fail", "", + subcmd.DefineCommand("list", "list all entries", nil), + subcmd.DefineCommand("add", "add a new entry", nil), + subcmd.DefineCommand("delete", "delete an entry", nil), + subcmd.DefineSet("item", "operate items"), + ) + for i, c := range []struct { + args []string + want string + }{ + {[]string{}, `no commands selected. + +Available sub-commands are: + + list list all entries + add add a new entry + delete delete an entry + item operate items`}, + {[]string{"foo"}, `command not found. + +Available sub-commands are: + + list list all entries + add add a new entry + delete delete an entry + item operate items`}, + } { + err := subcmd.Run(context.Background(), rootSet, c.args...) + if err == nil { + t.Fatalf("unexpected succeed at #%d %+v", i, c) + } + got := err.Error() + if d := cmp.Diff(c.want, got); d != "" { + t.Errorf("unexpected error at #%d: -want +got\n%s", i, d) + } + } +} + +func TestAutoWidth(t *testing.T) { + rootSet := subcmd.DefineSet("faillong", "", + subcmd.DefineCommand("verylongname", "long name command", nil), + subcmd.DefineCommand("short", "short name command", nil), + ) + err := subcmd.Run(context.Background(), rootSet) + if err == nil { + t.Fatal("unexpected succeed") + } + want := `no commands selected. + +Available sub-commands are: + + verylongname long name command + short short name command` + got := err.Error() + if d := cmp.Diff(want, got); d != "" { + t.Errorf("unexpected error: -want +got\n%s", d) + } +} + +func TestRootSet(t *testing.T) { + rootSet := subcmd.DefineRootSet() + if d := cmp.Diff("subcmd.test", rootSet.Name()); d != "" { + t.Errorf("unexpected name: -want +got\n%s", d) + } +} diff --git a/whitebox_test.go b/whitebox_test.go new file mode 100644 index 0000000..a203ed4 --- /dev/null +++ b/whitebox_test.go @@ -0,0 +1,23 @@ +package subcmd + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestStripExeExt(t *testing.T) { + for i, c := range []struct{ in, want string }{ + {"foo.exe", "foo"}, + {"foo", "foo"}, + {"bar.txt", "bar.txt"}, + {"bar.txt.exe", "bar.txt"}, + {"subcmd.test.exe", "subcmd.test"}, + {"subcmd.test", "subcmd.test"}, + } { + got := stripExeExt(c.in) + if d := cmp.Diff(c.want, got); d != "" { + t.Errorf("unexpected result at #%d: -want +got\n%s", i, d) + } + } +}