commit 0bd6f673977c5470f9d81477ffc9ded6cfd251f6 Author: andrey1s Date: Sun Oct 25 10:00:59 2020 +0300 first commit diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..745ce29 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,24 @@ +kind: pipeline +name: default + +steps: +- name: golangci-lint + image: golangci/golangci-lint:v1.26 + volumes: + - name: deps + path: /go/src/mod + commands: + - golangci-lint run --timeout 5m + +- name: test + image: golang + volumes: + - name: deps + path: /go/src/mod + commands: + - go test ./... + +volumes: +- name: deps + temp: {} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4d432a --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# ---> Go +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..f504a9e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,36 @@ +linters-settings: + dupl: + threshold: 100 + funlen: + lines: 100 + statements: 50 + goconst: + min-len: 2 + min-occurrences: 2 + gocyclo: + min-complexity: 15 + golint: + min-confidence: 0 + gomnd: + settings: + mnd: + # don't include the "operation" and "assign" + checks: argument,case,condition,return + govet: + check-shadowing: true + lll: + line-length: 140 + maligned: + suggest-new: true + misspell: + locale: US + +linters: + enable-all: true + +issues: + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + - path: _test\.go + linters: + - gomnd diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0dcfb43 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +MIT License Copyright (c) 2020 go-4devs + +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 (including the next +paragraph) 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/README.md b/README.md new file mode 100644 index 0000000..951b664 --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# Console + +## Creating a Command + +Commands are defined in struct extending `pkg/command/create_user.go`. For example, you may want a command to create a user: + +```go +package command + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/output" +) + +func Createuser() *console.Command { + return &console.Command{ + Name: "app:create-user", + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + return nil + }, + } +} +``` +## Configure command + +```go +func Createuser() *console.Command { + return &console.Command{ + //... + Description: "Creates a new user.", + Help: "This command allows you to create a user...", + } +} +``` + + +## Add arguments + +```go +func Createuser(required bool) *console.Command { + return &console.Command{ + //.... + Configure: func(ctx context.Context, cfg *input.Definition) error { + var opts []func(*input.Argument) + if required { + opts = append(opts, argument.Required) + } + cfg.SetArgument("password", "User password", opts...) + + return nil + }, + } +} +``` + +## Registering the Command + +`cmd/console/main.go` + +```go +package main + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "pkg/command" +) + +func main() { + console. + New(). + Add( + command.Createuser(false), + ). + Execute(context.Background()) +} +``` + +## Executing the Command + +build command `go build -o bin/console cmd/console/main.go` +run command `bin/console app:create-user`` + +## Console Output + +The Execute field has access to the output stream to write messages to the console: +```go +func Createuser(required bool) *console.Command { + return &console.Command{ + // .... + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + // outputs a message followed by a "\n" + out.Println(ctx, "User Creator") + out.Println(ctx, "Whoa!") + + // outputs a message without adding a "\n" at the end of the line + out.Print(ctx, "You are about to ", "create a user.") + + return nil + }, + } +} +``` + +Now, try build and executing the command: + +```bash +bin/console app:create-user +User Creator +Whoa! +You are about to create a user. +``` + +## Console Input + +Use input options or arguments to pass information to the command: + +```go +func CreateUser(required bool) *console.Command { + return &console.Command{ + Configure: func(ctx context.Context, cfg *input.Definition) error { + var opts []func(*input.Argument) + if required { + opts = append(opts, argument.Required) + } + cfg. + SetArgument("username", "The username of the user.", argument.Required). + SetArgument("password", "User password", opts...) + + return nil + }, + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + // outputs a message followed by a "\n" + out.Println(ctx, "User Creator") + out.Println(ctx, "Username: ", in.Argument(ctx, "username").String()) + + return nil + }, + } +} +``` + +Now, you can pass the username to the command: + +```bash +bin/console app:create-user AwesomeUsername +User Creator +Username: AwesomeUsername +``` + +## Testing Commands + +```go +package command_test + +import ( + "bytes" + "context" + "testing" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/example/pkg/command" + "gitoa.ru/go-4devs/console/input/array" + "gitoa.ru/go-4devs/console/output/writer" +) + +func TestCreateUser(t *testing.T) { + ctx := context.Background() + in := array.New(array.Argument("username", "andrey")) + buf := bytes.Buffer{} + out := writer.Buffer(&buf) + + console.Run(ctx, command.CreateUser(false), in, out) + + expect := `User Creator +Username: andrey +` + + if expect != buf.String() { + t.Errorf("expect: %s, got:%s", expect, buf.String()) + } +} +``` diff --git a/app.go b/app.go new file mode 100644 index 0000000..47ceda1 --- /dev/null +++ b/app.go @@ -0,0 +1,141 @@ +package console + +import ( + "context" + "os" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/argv" + "gitoa.ru/go-4devs/console/input/value" + "gitoa.ru/go-4devs/console/output" + "gitoa.ru/go-4devs/console/output/writer" +) + +// WithOutput sets outpu,^ by default output os.Stdout. +func WithOutput(out output.Output) func(*App) { + return func(a *App) { + a.out = out + } +} + +// WithInput sets input, by default creates inpur by os.Args. +func WithInput(in input.Input) func(*App) { + return func(a *App) { + a.in = in + } +} + +// WithSkipArgs sets how many arguments are passed. For example, you don't need to pass the name of a single command. +func WithSkipArgs(l int) func(*App) { + return func(a *App) { + a.skipArgv = l + } +} + +// WithExit sets exit callback by default os.Exit. +func WithExit(f func(int)) func(*App) { + return func(a *App) { + a.exit = f + } +} + +// New creates and configure new console app. +func New(opts ...func(*App)) *App { + a := &App{ + out: writer.Stdout(), + exit: os.Exit, + } + + for _, opt := range opts { + opt(a) + } + + if a.in == nil { + skip := 2 + + switch { + case a.skipArgv > 0 && len(os.Args) > a.skipArgv: + skip = a.skipArgv + case a.skipArgv > 0: + skip = len(os.Args) + case len(os.Args) == 1: + skip = 1 + case len(os.Args) > 1 && os.Args[1][0] == '-': + skip = 1 + } + + a.in = argv.New(os.Args[skip:]) + } + + return a +} + +// App is collection of command and configure env. +type App struct { + cmds []*Command + out output.Output + in input.Input + skipArgv int + exit func(int) +} + +// Add add or replace command. +func (a *App) Add(cmds ...*Command) *App { + a.cmds = append(a.cmds, cmds...) + + return a +} + +// Execute run the command by name and arguments. +func (a *App) Execute(ctx context.Context) { + for _, cmd := range a.cmds { + register(cmd) + } + + cmd, err := a.find(ctx) + if err != nil { + a.printError(ctx, err) + + if err := a.list(ctx); err != nil { + a.printError(ctx, err) + } + + a.exit(1) + } + + a.exec(ctx, cmd) +} + +func (a *App) exec(ctx context.Context, cmd *Command) { + if err := Run(ctx, cmd, a.in, a.out); err != nil { + a.printError(ctx, err) + a.exit(1) + } + + a.exit(0) +} + +func (a *App) find(_ context.Context) (*Command, error) { + if len(os.Args) < 2 || os.Args[1][1] == '-' { + return Find(CommandList) + } + + name := os.Args[1] + + return Find(name) +} + +func (a *App) list(ctx context.Context) error { + cmd, err := Find(CommandHelp) + if err != nil { + return err + } + + a.in.SetArgument("command_name", value.New(CommandList)) + + return Run(ctx, cmd, a.in, a.out) +} + +func (a *App) printError(ctx context.Context, err error) { + a.out.Println(ctx, "\n\n ", err, "\n") +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..6148c73 --- /dev/null +++ b/app_test.go @@ -0,0 +1,87 @@ +package console_test + +import ( + "context" + "os" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/example/pkg/command" +) + +//nolint: lll +func ExampleNew_help() { + ctx := context.Background() + os.Args = []string{ + "bin/console", + "test:command", + "-h", + "--no-ansi", + } + + console.New(console.WithExit(func(int) {})). + Add( + Command(), + ). + Execute(ctx) + // Output: + // Description: + // test command + // + // Usage: + // test:command [options] [--] [] + // + // Arguments: + // test_argument test argument + // + // Options: + // --duration[=DURATION] test duration with default [default: 1s] + // --bool test bool option + // --string[=STRING] array string (multiple values allowed) + // -q, --quiet Do not output any message + // -v, --verbose Increase the verbosity of messages: -v for info output, -vv for debug and -vvv for trace (multiple values allowed) + // -h, --help Display this help message + // -V, --version Display this application version + // --ansi Do not ask any interactive question + // --no-ansi Disable ANSI output +} + +func ExampleNew_list() { + ctx := context.Background() + + os.Args = []string{ + "bin/console", + "--no-ansi", + } + + console.New(console.WithExit(func(int) {})). + Add( + Command(), + command.Hello(), + command.Args(), + command.Namespace(), + ). + Execute(ctx) + // Output: + // Usage: + // command [options] [arguments] + // + // Options: + // -q, --quiet Do not output any message + // -v, --verbose Increase the verbosity of messages: -v for info output, -vv for debug and -vvv for trace (multiple values allowed) + // -h, --help Display this help message + // -V, --version Display this application version + // --ansi Do not ask any interactive question + // --no-ansi Disable ANSI output + // + // Available commands: + // help Displays help for a command + // list Lists commands + // app + // app:start example command in other namespace + // fdevs + // fdevs:console:arg Understanding how Console Arguments and Options Are Handled + // fdevs:console:hello example hello command + // fdevs:console:test test command + // test + // test:command test command +} diff --git a/command.go b/command.go new file mode 100644 index 0000000..03abb77 --- /dev/null +++ b/command.go @@ -0,0 +1,196 @@ +package console + +import ( + "context" + "fmt" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/output" +) + +type ( + Action func(ctx context.Context, input input.Input, output output.Output) error + Handle func(ctx context.Context, in input.Input, out output.Output, n Action) error + Configure func(ctx context.Context, cfg *input.Definition) error + Prepare func(ctx context.Context, cfg *input.Definition, n Configure) error + Option func(*Command) +) + +// WithPrepare append middleware for configuration command. +func WithPrepare(p ...Prepare) Option { + return func(c *Command) { + if c.Prepare != nil { + p = append([]Prepare{c.Prepare}, p...) + } + + c.Prepare = ChainPrepare(p...) + } +} + +// WithHandle append middleware for executed command. +func WithHandle(h ...Handle) Option { + return func(c *Command) { + if c.Handle != nil { + h = append([]Handle{c.Handle}, h...) + } + + c.Handle = ChainHandle(h...) + } +} + +// WithHidden sets hidden command. +func WithHidden(v bool) Option { + return func(c *Command) { + c.Hidden = v + } +} + +// WithName sets name command. +func WithName(n string) Option { + return func(c *Command) { + c.Name = n + } +} + +type Command struct { + // The name of the command. + Name string + // A short description of the usage of this command. + Description string + // A longer explanation of how the command works. + Help string + // Vervion command. + Version string + // Boolean to hide this command from help or completion. + Hidden bool + // Configures the current command. + Configure Configure + // The middleware for configures current command. + Prepare Prepare + // The function to call when this command is invoked. + Execute Action + // The middleware for executes current command. + Handle Handle +} + +func (c *Command) String() string { + return fmt.Sprintf("name: %s, version: %s", c.Name, c.Version) +} + +// With creates new command by parent and options. +func (c *Command) With(opts ...Option) *Command { + cmd := &Command{ + Name: c.Name, + Description: c.Description, + Help: c.Help, + Version: c.Version, + Hidden: c.Hidden, + Configure: c.Configure, + Prepare: c.Prepare, + Execute: c.Execute, + Handle: c.Handle, + } + + for _, opt := range opts { + opt(cmd) + } + + return cmd +} + +// Run run command with input and output. +func (c *Command) Run(ctx context.Context, in input.Input, out output.Output) error { + if c.Handle != nil { + return c.Handle(ctx, in, out, c.Execute) + } + + return c.Execute(ctx, in, out) +} + +// Init configures command. +func (c *Command) Init(ctx context.Context, cfg *input.Definition) error { + switch { + case c.Prepare != nil && c.Configure != nil: + return c.Prepare(ctx, cfg, c.Configure) + case c.Prepare != nil: + return c.Prepare(ctx, cfg, func(_ context.Context, _ *input.Definition) error { + return nil + }) + case c.Configure != nil: + return c.Configure(ctx, cfg) + default: + return nil + } +} + +// ChainPrepare creates middleware for configures command. +func ChainPrepare(prepare ...Prepare) Prepare { + n := len(prepare) + if n == 1 { + return prepare[0] + } + + if n > 1 { + lastI := n - 1 + + return func(ctx context.Context, def *input.Definition, next Configure) error { + var ( + chainHandler func(context.Context, *input.Definition) error + curI int + ) + + chainHandler = func(currentCtx context.Context, currentDef *input.Definition) error { + if curI == lastI { + return next(currentCtx, currentDef) + } + curI++ + err := prepare[curI](currentCtx, currentDef, chainHandler) + curI-- + + return err + } + + return prepare[0](ctx, def, chainHandler) + } + } + + return func(ctx context.Context, cfg *input.Definition, next Configure) error { + return next(ctx, cfg) + } +} + +// ChainHandle creates middleware for executes command. +func ChainHandle(handlers ...Handle) Handle { + n := len(handlers) + if n == 1 { + return handlers[0] + } + + if n > 1 { + lastI := n - 1 + + return func(ctx context.Context, in input.Input, out output.Output, next Action) error { + var ( + chainHandler func(context.Context, input.Input, output.Output) error + curI int + ) + + chainHandler = func(currentCtx context.Context, currentIn input.Input, currentOut output.Output) error { + if curI == lastI { + return next(currentCtx, currentIn, currentOut) + } + curI++ + err := handlers[curI](currentCtx, currentIn, currentOut, chainHandler) + curI-- + + return err + } + + return handlers[0](ctx, in, out, chainHandler) + } + } + + return func(ctx context.Context, in input.Input, out output.Output, next Action) error { + return next(ctx, in, out) + } +} diff --git a/command_test.go b/command_test.go new file mode 100644 index 0000000..1b2c983 --- /dev/null +++ b/command_test.go @@ -0,0 +1,122 @@ +package console_test + +import ( + "context" + "strings" + "sync/atomic" + "testing" + "time" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/example/pkg/command" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/array" + "gitoa.ru/go-4devs/console/input/option" + "gitoa.ru/go-4devs/console/output" + "gitoa.ru/go-4devs/console/output/writer" +) + +//nolint: gochecknoinits +func init() { + console.MustRegister(Command().With(console.WithName("fdevs:console:test"))) + console.MustRegister(command.Args()) +} + +func Command() *console.Command { + return &console.Command{ + Name: "test:command", + Description: "test command", + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + out.Print(ctx, + "test argument:", in.Argument(ctx, "test_argument").String(), "\n", + "bool option:", in.Option(ctx, "bool").Bool(), "\n", + "duration option with default:", in.Option(ctx, "duration").Duration(), "\n", + "array string:[", strings.Join(in.Option(ctx, "string").Strings(), ","), "]\n", + ) + + return nil + }, + Configure: func(ctx context.Context, def *input.Definition) error { + def. + SetArguments( + input.NewArgument("test_argument", "test argument"), + ). + SetOptions( + input.NewOption("string", "array string", option.Array), + option.Bool("bool", "test bool option"), + option.Duration("duration", "test duration with default", option.Default(time.Second)), + ) + + return nil + }, + } +} + +func TestChainPrepare(t *testing.T) { + var cnt int32 + + ctx := context.Background() + def := input.NewDefinition() + + prepare := func(ctx context.Context, def *input.Definition, n console.Configure) error { + atomic.AddInt32(&cnt, 1) + + return n(ctx, def) + } + configure := func(context.Context, *input.Definition) error { + return nil + } + + for i := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10} { + prepares := make([]console.Prepare, i) + for p := 0; p < i; p++ { + prepares[p] = prepare + } + + cnt = 0 + chain := console.ChainPrepare(prepares...) + + if err := chain(ctx, def, configure); err != nil { + t.Errorf("expected nil err, got: %s", err) + } + + if cnt != int32(i) { + t.Fatalf("expected: call prepare 1, got: %d ", cnt) + } + } +} + +func TestChainHandle(t *testing.T) { + var cnt int32 + + ctx := context.Background() + in := array.New() + out := writer.Stdout() + + handle := func(ctx context.Context, in input.Input, out output.Output, next console.Action) error { + atomic.AddInt32(&cnt, 1) + + return next(ctx, in, out) + } + action := func(context.Context, input.Input, output.Output) error { + return nil + } + + for i := range []int{0, 1, 2, 30, 40, 50} { + handles := make([]console.Handle, i) + for p := 0; p < i; p++ { + handles[p] = handle + } + + cnt = 0 + chain := console.ChainHandle(handles...) + + if err := chain(ctx, in, out, action); err != nil { + t.Errorf("expected nil err, got: %s", err) + } + + if cnt != int32(i) { + t.Fatalf("expected: call prepare 1, got: %d ", cnt) + } + } +} diff --git a/console.go b/console.go new file mode 100644 index 0000000..b4c08b0 --- /dev/null +++ b/console.go @@ -0,0 +1,135 @@ +package console + +import ( + "context" + "errors" + "os" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/option" + "gitoa.ru/go-4devs/console/input/value" + "gitoa.ru/go-4devs/console/output" + "gitoa.ru/go-4devs/console/output/verbosity" + "gitoa.ru/go-4devs/console/output/wrap" +) + +const ( + verboseTrace = 3 + verboseDebug = 2 + verboseInfo = 3 +) + +// Execute the current command with option. +func Execute(ctx context.Context, cmd *Command, opts ...func(*App)) { + opts = append([]func(*App){WithSkipArgs(1)}, opts...) + New(opts...).exec(ctx, cmd) +} + +// Run current command by input and output/ +func Run(ctx context.Context, cmd *Command, in input.Input, out output.Output) error { + def := input.NewDefinition() + + if err := cmd.Init(ctx, def); err != nil { + return err + } + + if err := in.Bind(ctx, Default(def)); err != nil { + ansi(ctx, in, out).Print(ctx, "\n\n ", err, "\n\n") + + return showHelp(ctx, cmd, in, wrap.Ansi(out)) + } + + out = ansi(ctx, in, out) + + out = verbose(ctx, in, out) + + if in.Option(ctx, "version").Bool() { + version := cmd.Version + if version == "" { + version = "unknown" + } + + out.Println(ctx, "command ", cmd.Name, " version: ", version, "") + + return nil + } + + if in.Option(ctx, "help").Bool() { + return showHelp(ctx, cmd, in, out) + } + + return cmd.Run(ctx, in, out) +} + +func ansi(ctx context.Context, in input.Input, out output.Output) output.Output { + switch { + case in.Option(ctx, "ansi").Bool(): + out = wrap.Ansi(out) + case in.Option(ctx, "no-ansi").Bool(): + out = wrap.None(out) + case lookupEnv("NO_COLOR"): + out = wrap.None(out) + default: + out = wrap.Ansi(out) + } + + return out +} + +func lookupEnv(name string) bool { + v, has := os.LookupEnv(name) + + return has && v == "true" +} + +func verbose(ctx context.Context, in input.Input, out output.Output) output.Output { + switch { + case in.Option(ctx, "quiet").Bool(): + out = verbosity.Quiet() + default: + v := in.Option(ctx, "verbose").Bools() + + switch { + case len(v) == verboseInfo: + out = verbosity.Verb(out, output.VerbosityInfo) + case len(v) == verboseDebug: + out = verbosity.Verb(out, output.VerbosityDebug) + case len(v) >= verboseTrace: + out = verbosity.Verb(out, output.VerbosityTrace) + default: + out = verbosity.Verb(out, output.VerbosityNorm) + } + } + + return out +} + +func showHelp(ctx context.Context, cmd *Command, in input.Input, out output.Output) error { + in.SetArgument(HelpArgumentCommandName, value.New(cmd.Name)) + in.SetOption("help", value.New(false)) + + if _, err := Find(cmd.Name); errors.Is(err, ErrNotFound) { + register(cmd) + } + + help, err := Find(CommandHelp) + if err != nil { + return err + } + + return Run(ctx, help, in, out) +} + +// Default options and argument command. +func Default(d *input.Definition) *input.Definition { + return d.SetOptions( + option.Bool("no-ansi", "Disable ANSI output"), + option.Bool("ansi", "Do not ask any interactive question"), + option.Bool("version", "Display this application version", option.Short("V")), + option.Bool("help", "Display this help message", option.Short("h")), + option.Bool("verbose", + "Increase the verbosity of messages: -v for info output, -vv for debug and -vvv for trace", + option.Short("v"), option.Array), + option.Bool("quiet", "Do not output any message", option.Short("q")), + ) +} diff --git a/console_test.go b/console_test.go new file mode 100644 index 0000000..e2911cd --- /dev/null +++ b/console_test.go @@ -0,0 +1,45 @@ +package console_test + +import ( + "context" + "fmt" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/input/array" + "gitoa.ru/go-4devs/console/input/value" + "gitoa.ru/go-4devs/console/output/writer" +) + +func ExampleRun() { + cmd := Command() + ctx := context.Background() + out := writer.Stdout() + in := array.New() + + err := console.Run(ctx, cmd, in, out) + fmt.Println("err:", err) + // Output: + // test argument: + // bool option:false + // duration option with default:1s + // array string:[] + // err: +} + +func ExampleExecute() { + cmd := Command() + ctx := context.Background() + in := array.New() + + // Run command: ./bin "argument value" -b --string="same value" --string="other value" + in.SetOption("bool", value.New(true)) + in.SetOption("string", value.New([]string{"same value", "other value"})) + in.SetArgument("test_argument", value.New("argument value")) + + console.Execute(ctx, cmd, console.WithInput(in), console.WithExit(func(int) {})) + // Output: + // test argument:argument value + // bool option:true + // duration option with default:1s + // array string:[same value,other value] +} diff --git a/descriptor/descriptor.go b/descriptor/descriptor.go new file mode 100644 index 0000000..e5f2e38 --- /dev/null +++ b/descriptor/descriptor.go @@ -0,0 +1,89 @@ +package descriptor + +import ( + "context" + "errors" + "sync" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/output" +) + +var ErrDescriptorNotFound = errors.New("descriptor not found") + +//nolint: gochecknoglobals +var ( + descriptors = map[string]Descriptor{ + "txt": &txt{}, + } + descriptorMu sync.Mutex +) + +type Command struct { + Bin string + Name string + Description string + Help string + Definition *input.Definition +} + +type Commands struct { + Namespace string + Definition *input.Definition + Commands []NSCommand +} + +type NSCommand struct { + Name string + Commands []ShortCommand +} + +func (n *NSCommand) Append(name, desc string) { + n.Commands = append(n.Commands, ShortCommand{Name: name, Description: desc}) +} + +type ShortCommand struct { + Name string + Description string +} + +type Descriptor interface { + Command(ctx context.Context, out output.Output, cmd Command) error + Commands(ctx context.Context, out output.Output, cmds Commands) error +} + +func Find(name string) (Descriptor, error) { + descriptorMu.Lock() + defer descriptorMu.Unlock() + + if d, has := descriptors[name]; has { + return d, nil + } + + return nil, ErrDescriptorNotFound +} + +func Descriptors() []string { + names := make([]string, 0, len(descriptors)) + + for name := range descriptors { + names = append(names, name) + } + + return names +} + +func Register(name string, descriptor Descriptor) { + descriptorMu.Lock() + defer descriptorMu.Unlock() + + if descriptor == nil { + panic("console: Register descriptor is nil") + } + + if _, has := descriptors[name]; has { + panic("console: Register called twice for descriptor " + name) + } + + descriptors[name] = descriptor +} diff --git a/descriptor/txt.go b/descriptor/txt.go new file mode 100644 index 0000000..c383a80 --- /dev/null +++ b/descriptor/txt.go @@ -0,0 +1,371 @@ +package descriptor + +import ( + "bytes" + "context" + "fmt" + "strconv" + "strings" + "text/template" + "time" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/output" +) + +const ( + defaultSpace = 2 + infoLen = 13 +) + +//nolint:gochecknoglobals +var ( + txtFunc = template.FuncMap{ + "synopsis": txtSynopsis, + "definition": txtDefinition, + "help": txtHelp, + "commands": txtCommands, + } + + txtHelpTemplate = template.Must(template.New("txt_template"). + Funcs(txtFunc). + Parse(` +{{- if .Description -}} +Description: + {{ .Description }} + +{{ end -}} +Usage: + {{ .Name }} {{ synopsis .Definition }} +{{- definition .Definition }} +{{- help . }} + `)) + + txtListTempkate = template.Must(template.New("txt_list"). + Funcs(txtFunc). + Parse(`Usage: + command [options] [arguments] +{{- definition .Definition }} +{{- commands .Commands -}} + `)) +) + +type txt struct{} + +func (t *txt) Command(ctx context.Context, out output.Output, cmd Command) error { + var tpl bytes.Buffer + + if err := txtHelpTemplate.Execute(&tpl, cmd); err != nil { + return err + } + + out.Println(ctx, tpl.String()) + + return nil +} + +func (t *txt) Commands(ctx context.Context, out output.Output, cmds Commands) error { + var buf bytes.Buffer + + if err := txtListTempkate.Execute(&buf, cmds); err != nil { + return err + } + + out.Println(ctx, buf.String()) + + return nil +} + +func txtDefaultArray(val input.Value, flag input.Flag) string { + st := val.Strings() + + switch { + case flag.IsInt(): + for _, i := range val.Ints() { + st = append(st, strconv.Itoa(i)) + } + case flag.IsInt64(): + for _, i := range val.Int64s() { + st = append(st, strconv.FormatInt(i, 10)) + } + case flag.IsUint(): + for _, u := range val.Uints() { + st = append(st, strconv.FormatUint(uint64(u), 10)) + } + case flag.IsUint64(): + for _, u := range val.Uint64s() { + st = append(st, strconv.FormatUint(u, 10)) + } + case flag.IsFloat64(): + for _, f := range val.Float64s() { + st = append(st, strconv.FormatFloat(f, 'g', -1, 64)) + } + case flag.IsDuration(): + for _, d := range val.Durations() { + st = append(st, d.String()) + } + case flag.IsTime(): + for _, d := range val.Times() { + st = append(st, d.Format(time.RFC3339)) + } + } + + return strings.Join(st, ",") +} + +func txtDefault(val input.Value, flag input.Flag) []byte { + var buf bytes.Buffer + + buf.WriteString(" [default: ") + + switch { + case flag.IsArray(): + buf.WriteString(txtDefaultArray(val, flag)) + case flag.IsInt(): + buf.WriteString(strconv.Itoa(val.Int())) + case flag.IsInt64(): + buf.WriteString(strconv.FormatInt(val.Int64(), 10)) + case flag.IsUint(): + buf.WriteString(strconv.FormatUint(uint64(val.Uint()), 10)) + case flag.IsUint64(): + buf.WriteString(strconv.FormatUint(val.Uint64(), 10)) + case flag.IsFloat64(): + buf.WriteString(strconv.FormatFloat(val.Float64(), 'g', -1, 64)) + case flag.IsDuration(): + buf.WriteString(val.Duration().String()) + case flag.IsTime(): + buf.WriteString(val.Time().Format(time.RFC3339)) + case flag.IsAny(): + buf.WriteString(fmt.Sprint(val.Any())) + default: + buf.WriteString(val.String()) + } + + buf.WriteString("]") + + return buf.Bytes() +} + +func txtCommands(cmds []NSCommand) string { + max := commandsTotalWidth(cmds) + showNS := len(cmds) > 1 + + var buf bytes.Buffer + + buf.WriteString("\nAvailable commands") + + if len(cmds) == 1 && cmds[0].Name != "" { + buf.WriteString("for the \"") + buf.WriteString(cmds[0].Name) + buf.WriteString(`" namespace`) + } + + buf.WriteString(":\n") + + for _, ns := range cmds { + if ns.Name != "" && showNS { + buf.WriteString("") + buf.WriteString(ns.Name) + buf.WriteString("\n") + } + + for _, cmd := range ns.Commands { + buf.WriteString(" ") + buf.WriteString(cmd.Name) + buf.WriteString("") + buf.WriteString(strings.Repeat(" ", max-len(cmd.Name)+defaultSpace)) + buf.WriteString(cmd.Description) + buf.WriteString("\n") + } + } + + return buf.String() +} + +func txtHelp(cmd Command) string { + if cmd.Help == "" { + return "" + } + + tpl := template.Must(template.New("help").Parse(cmd.Help)) + + var buf bytes.Buffer + + buf.WriteString("\nHelp:") + _ = tpl.Execute(&buf, cmd) + + return buf.String() +} + +func txtDefinitionOption(maxLen int, def *input.Definition) string { + buf := bytes.Buffer{} + opts := def.Options() + + buf.WriteString("\n\nOptions:\n") + + for _, name := range opts { + opt, _ := def.Option(name) + + var op bytes.Buffer + + op.WriteString(" ") + + if opt.HasShort() { + op.WriteString("-") + op.WriteString(opt.Short) + op.WriteString(", ") + } else { + op.WriteString(" ") + } + + op.WriteString("--") + op.WriteString(opt.Name) + + if !opt.IsBool() { + if !opt.IsRequired() { + op.WriteString("[") + } + + op.WriteString("=") + op.WriteString(strings.ToUpper(opt.Name)) + + if !opt.IsRequired() { + op.WriteString("]") + } + } + + op.WriteString("") + buf.Write(op.Bytes()) + buf.WriteString(strings.Repeat(" ", maxLen+17-op.Len())) + buf.WriteString(opt.Description) + + if opt.HasDefault() { + buf.Write(txtDefault(opt.Default, opt.Flag)) + } + + if opt.IsArray() { + buf.WriteString(" (multiple values allowed)") + } + + buf.WriteString("\n") + } + + return buf.String() +} + +func txtDefinition(def *input.Definition) string { + max := totalWidth(def) + + var buf bytes.Buffer + + if args := def.Arguments(); len(args) > 0 { + buf.WriteString("\n\nArguments:\n") + + for pos := range args { + var ab bytes.Buffer + + arg, _ := def.Argument(pos) + + ab.WriteString(" ") + ab.WriteString(arg.Name) + ab.WriteString("") + ab.WriteString(strings.Repeat(" ", max+infoLen+defaultSpace-ab.Len())) + + buf.Write(ab.Bytes()) + buf.WriteString(arg.Description) + + if arg.HasDefault() { + buf.Write(txtDefault(arg.Default, arg.Flag)) + } + } + } + + if opts := def.Options(); len(opts) > 0 { + buf.WriteString(txtDefinitionOption(max, def)) + } + + return buf.String() +} + +func txtSynopsis(def *input.Definition) string { + var buf bytes.Buffer + + if len(def.Options()) > 0 { + buf.WriteString("[options] ") + } + + if buf.Len() > 0 && len(def.Arguments()) > 0 { + buf.WriteString("[--]") + } + + var opt int + + for pos := range def.Arguments() { + buf.WriteString(" ") + + arg, _ := def.Argument(pos) + + if !arg.IsRequired() { + buf.WriteString("[") + opt++ + } + + buf.WriteString("<") + buf.WriteString(arg.Name) + buf.WriteString(">") + + if arg.IsArray() { + buf.WriteString("...") + } + } + + buf.WriteString(strings.Repeat("]", opt)) + + return buf.String() +} + +func commandsTotalWidth(cmds []NSCommand) int { + var max int + + for _, ns := range cmds { + for _, cmd := range ns.Commands { + if len(cmd.Name) > max { + max = len(cmd.Name) + } + } + } + + return max +} + +func totalWidth(def *input.Definition) int { + var max int + + for pos := range def.Arguments() { + arg, _ := def.Argument(pos) + l := len(arg.Name) + + if l > max { + max = l + } + } + + for _, name := range def.Options() { + opt, _ := def.Option(name) + l := len(opt.Name) + 6 + + if !opt.IsBool() { + l = l*2 + 1 + } + + if opt.HasDefault() { + l += 2 + } + + if l > max { + max = l + } + } + + return max +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..cd64aac --- /dev/null +++ b/doc.go @@ -0,0 +1,30 @@ +// Package console eases the creation of beautiful and testable command line interfaces. +// The Console package allows you to create command-line commands. +// Your console commands can be used for any recurring task, such as cronjobs, imports, or other batch jobs. +// console application can be written as follows: +// //cmd/console/main.go +// func main() { +// console.New().Execute(context.Background()) +// } +// Then, you can register the commands using Add(): +// package main +// +// import ( +// "context" +// +// "gitoa.ru/go-4devs/console" +// "gitoa.ru/go-4devs/console/example/pkg/command" +// ) +// +// func main() { +// console. +// New(). +// Add( +// command.Hello(), +// command.Args(), +// command.Hidden(), +// command.Namespace(), +// ). +// Execute(context.Background()) +// } +package console diff --git a/example/bin/console b/example/bin/console new file mode 100755 index 0000000..ec64968 Binary files /dev/null and b/example/bin/console differ diff --git a/example/cmd/cancel/main.go b/example/cmd/cancel/main.go new file mode 100644 index 0000000..f5b240b --- /dev/null +++ b/example/cmd/cancel/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "os" + "os/signal" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/example/pkg/command" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + + ch := make(chan os.Signal, 1) + defer close(ch) + + signal.Notify(ch, os.Interrupt) + + go func() { + <-ch + cancel() + }() + console.Execute(ctx, command.Long()) +} diff --git a/example/cmd/console/main.go b/example/cmd/console/main.go new file mode 100644 index 0000000..b41e179 --- /dev/null +++ b/example/cmd/console/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/example/pkg/command" +) + +func main() { + console. + New(). + Add( + command.Hello(), + command.Args(), + command.Hidden(), + command.Namespace(), + command.CreateUser(false), + ). + Execute(context.Background()) +} diff --git a/example/cmd/single/main.go b/example/cmd/single/main.go new file mode 100644 index 0000000..a4f2934 --- /dev/null +++ b/example/cmd/single/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/example/pkg/command" +) + +func main() { + console.Execute(context.Background(), command.Hello()) +} diff --git a/example/pkg/command/args.go b/example/pkg/command/args.go new file mode 100644 index 0000000..bb2b46f --- /dev/null +++ b/example/pkg/command/args.go @@ -0,0 +1,33 @@ +package command + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/option" + "gitoa.ru/go-4devs/console/output" +) + +func Args() *console.Command { + return &console.Command{ + Name: "fdevs:console:arg", + Description: "Understanding how Console Arguments and Options Are Handled", + Configure: func(ctx context.Context, def *input.Definition) error { + def.SetOptions( + option.Bool("foo", "foo option", option.Short("f")), + input.NewOption("bar", "required bar option", option.Required, option.Short("b")), + input.NewOption("cat", "cat option", option.Short("c")), + ) + + return nil + }, + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + out.Println(ctx, "foo: ", in.Option(ctx, "foo").Bool(), "") + out.Println(ctx, "bar: ", in.Option(ctx, "bar").String(), "") + out.Println(ctx, "cat: ", in.Option(ctx, "cat").String(), "") + + return nil + }, + } +} diff --git a/example/pkg/command/create_user.go b/example/pkg/command/create_user.go new file mode 100644 index 0000000..ae9425f --- /dev/null +++ b/example/pkg/command/create_user.go @@ -0,0 +1,36 @@ +package command + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/argument" + "gitoa.ru/go-4devs/console/output" +) + +func CreateUser(required bool) *console.Command { + return &console.Command{ + Name: "app:create-user", + Description: "Creates a new user.", + Help: "This command allows you to create a user...", + Configure: func(ctx context.Context, cfg *input.Definition) error { + var opts []func(*input.Argument) + if required { + opts = append(opts, argument.Required) + } + cfg. + SetArgument("username", "The username of the user.", argument.Required). + SetArgument("password", "User password", opts...) + + return nil + }, + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + // outputs a message followed by a "\n" + out.Println(ctx, "User Creator") + out.Println(ctx, "Username: ", in.Argument(ctx, "username").String()) + + return nil + }, + } +} diff --git a/example/pkg/command/create_user_test.go b/example/pkg/command/create_user_test.go new file mode 100644 index 0000000..8a05278 --- /dev/null +++ b/example/pkg/command/create_user_test.go @@ -0,0 +1,30 @@ +package command_test + +import ( + "bytes" + "context" + "testing" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/example/pkg/command" + "gitoa.ru/go-4devs/console/input/array" + "gitoa.ru/go-4devs/console/output/writer" +) + +func TestCreateUser(t *testing.T) { + ctx := context.Background() + in := array.New(array.Argument("username", "andrey")) + buf := bytes.Buffer{} + out := writer.Buffer(&buf) + + err := console.Run(ctx, command.CreateUser(false), in, out) + if err != nil { + t.Fatalf("expect nil err, got :%s", err) + } + + expect := "User Creator\nUsername: andrey\n" + + if expect != buf.String() { + t.Errorf("expect: %s, got:%s", expect, buf.String()) + } +} diff --git a/example/pkg/command/hello.go b/example/pkg/command/hello.go new file mode 100644 index 0000000..4d73e5b --- /dev/null +++ b/example/pkg/command/hello.go @@ -0,0 +1,34 @@ +package command + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/argument" + "gitoa.ru/go-4devs/console/output" +) + +func Hello() *console.Command { + return &console.Command{ + Name: "fdevs:console:hello", + Description: "example hello command", + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + name := in.Argument(ctx, "name").String() + out.Println(ctx, "Hello ", name, "") + + out.Info(ctx, "same trace info\n") + out.Debug(ctx, "have some question?\n") + out.Trace(ctx, "this message shows with -vvv\n") + + return nil + }, + Configure: func(_ context.Context, def *input.Definition) error { + def.SetArguments( + input.NewArgument("name", "Same name", argument.Default("World")), + ) + + return nil + }, + } +} diff --git a/example/pkg/command/hidden.go b/example/pkg/command/hidden.go new file mode 100644 index 0000000..d0d6217 --- /dev/null +++ b/example/pkg/command/hidden.go @@ -0,0 +1,22 @@ +package command + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/output" +) + +func Hidden() *console.Command { + return &console.Command{ + Name: "fdevs:console:hidden", + Description: "hidden command exmale", + Hidden: true, + Execute: func(ctx context.Context, _ input.Input, out output.Output) error { + out.Println(ctx, " call hidden command") + + return nil + }, + } +} diff --git a/example/pkg/command/long.go b/example/pkg/command/long.go new file mode 100644 index 0000000..9ecced1 --- /dev/null +++ b/example/pkg/command/long.go @@ -0,0 +1,50 @@ +package command + +import ( + "context" + "time" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/option" + "gitoa.ru/go-4devs/console/output" + "gitoa.ru/go-4devs/console/validator" +) + +const defaultTimeout = time.Second * 30 + +// Long example of a command that takes a long time to run. +func Long() *console.Command { + return &console.Command{ + Name: "fdevs:command:long", + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + timeout := in.Option(ctx, "timeout").Duration() + timer := time.NewTimer(timeout) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case t := <-ticker.C: + out.Println(ctx, "ticker: ", t, "") + case <-timer.C: + out.Println(ctx, "stop timer") + + return nil + case <-ctx.Done(): + out.Println(ctx, "cancel context") + + return nil + } + } + }, + Configure: func(ctx context.Context, def *input.Definition) error { + def.SetOptions(option.Duration("timeout", "set duration run command", + option.Default(defaultTimeout), + option.Short("t"), + option.Valid(validator.NotBlank(input.ValueDuration)), + )) + + return nil + }, + } +} diff --git a/example/pkg/command/namespace.go b/example/pkg/command/namespace.go new file mode 100644 index 0000000..1cd928c --- /dev/null +++ b/example/pkg/command/namespace.go @@ -0,0 +1,21 @@ +package command + +import ( + "context" + + "gitoa.ru/go-4devs/console" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/output" +) + +func Namespace() *console.Command { + return &console.Command{ + Name: "app:start", + Description: "example command in other namespace", + Execute: func(ctx context.Context, _ input.Input, out output.Output) error { + out.Println(ctx, "example command in other namespace") + + return nil + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..357bc26 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitoa.ru/go-4devs/console + +go 1.15 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/help.go b/help.go new file mode 100644 index 0000000..2d80c89 --- /dev/null +++ b/help.go @@ -0,0 +1,92 @@ +package console + +import ( + "context" + "fmt" + "os" + "strings" + + "gitoa.ru/go-4devs/console/descriptor" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/argument" + "gitoa.ru/go-4devs/console/input/option" + "gitoa.ru/go-4devs/console/output" + "gitoa.ru/go-4devs/console/validator" +) + +//nolint: gochecknoinits +func init() { + MustRegister(help()) +} + +const ( + HelpArgumentCommandName = "command_name" + helpOptFormat = "format" +) + +func help() *Command { + return &Command{ + Name: CommandHelp, + Description: `Displays help for a command`, + Help: ` +The {{ .Name }} command displays help for a given command: + {{ .Bin }} {{ .Name }} list +You can also output the help in other formats by using the --format option: + {{ .Bin }} {{ .Name }} --format=xml list +To display the list of available commands, please use the list command. +`, + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + var err error + name := in.Argument(ctx, HelpArgumentCommandName).String() + format := in.Option(ctx, helpOptFormat).String() + + des, err := descriptor.Find(format) + if err != nil { + return err + } + + cmd, err := Find(name) + if err != nil { + return err + } + + def := input.NewDefinition() + + if err := cmd.Init(ctx, Default(def)); err != nil { + return err + } + + var bin string + if len(os.Args) > 0 { + bin = os.Args[0] + } + + return des.Command(ctx, out, descriptor.Command{ + Bin: bin, + Name: cmd.Name, + Description: cmd.Description, + Help: cmd.Help, + Definition: def, + }) + }, + Configure: func(ctx context.Context, config *input.Definition) error { + formats := descriptor.Descriptors() + config. + SetArguments( + input.NewArgument(HelpArgumentCommandName, "The command name", argument.Default("help")), + ). + SetOptions( + input.NewOption(helpOptFormat, fmt.Sprintf("The output format (%s)", strings.Join(formats, ", ")), + option.Required, + option.Default(formats[0]), + option.Valid( + validator.NotBlank(input.ValueString), + validator.Enum(formats...), + ), + ), + ) + + return nil + }, + } +} diff --git a/input/argument.go b/input/argument.go new file mode 100644 index 0000000..742f27c --- /dev/null +++ b/input/argument.go @@ -0,0 +1,48 @@ +package input + +func NewArgument(name, description string, opts ...func(*Argument)) Argument { + a := Argument{ + Name: name, + Description: description, + } + + for _, opt := range opts { + opt(&a) + } + + return a +} + +type Argument struct { + Name string + Description string + Default Value + Flag Flag + Valid []func(Value) error +} + +func (a Argument) HasDefault() bool { + return a.Default != nil +} + +func (a Argument) IsBool() bool { + return a.Flag.IsBool() +} + +func (a Argument) IsRequired() bool { + return a.Flag.IsRequired() +} + +func (a Argument) IsArray() bool { + return a.Flag.IsArray() +} + +func (a Argument) Validate(v Value) error { + for _, valid := range a.Valid { + if err := valid(v); err != nil { + return ErrorArgument(a.Name, err) + } + } + + return nil +} diff --git a/input/argument/option.go b/input/argument/option.go new file mode 100644 index 0000000..942feae --- /dev/null +++ b/input/argument/option.go @@ -0,0 +1,26 @@ +package argument + +import ( + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/value" +) + +func Required(a *input.Argument) { + a.Flag |= input.ValueRequired +} + +func Default(v interface{}) func(*input.Argument) { + return func(a *input.Argument) { + a.Default = value.New(v) + } +} + +func Flag(flag input.Flag) func(*input.Argument) { + return func(a *input.Argument) { + a.Flag = flag + } +} + +func Array(a *input.Argument) { + a.Flag |= input.ValueArray +} diff --git a/input/argv/input.go b/input/argv/input.go new file mode 100644 index 0000000..6babff9 --- /dev/null +++ b/input/argv/input.go @@ -0,0 +1,211 @@ +package argv + +import ( + "context" + "fmt" + "strings" + "sync" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/value" + "gitoa.ru/go-4devs/console/input/wrap" +) + +const doubleDash = `--` + +var _ input.ReadInput = (*Input)(nil) + +func WithErrorHandle(h func(error) error) func(*Input) { + return func(i *Input) { + i.errorHandle = h + } +} + +func New(args []string, opts ...func(*Input)) *wrap.Input { + i := &Input{ + args: args, + arguments: make(map[string]input.AppendValue), + options: make(map[string]input.AppendValue), + errorHandle: func(err error) error { + return err + }, + } + + for _, opt := range opts { + opt(i) + } + + return &wrap.Input{ReadInput: i} +} + +type Input struct { + args []string + arguments map[string]input.AppendValue + options map[string]input.AppendValue + mu sync.RWMutex + errorHandle func(error) error +} + +func (i *Input) ReadOption(ctx context.Context, name string) (input.Value, error) { + if v, ok := i.options[name]; ok { + return v, nil + } + + return nil, input.ErrNotFound +} + +func (i *Input) SetOption(name string, val input.Value) { + i.mu.Lock() + defer i.mu.Unlock() + + i.options[name] = &value.Read{Value: val} +} + +func (i *Input) ReadArgument(ctx context.Context, name string) (input.Value, error) { + if v, ok := i.arguments[name]; ok { + return v, nil + } + + return nil, input.ErrNotFound +} + +func (i *Input) SetArgument(name string, val input.Value) { + i.mu.Lock() + defer i.mu.Unlock() + + i.arguments[name] = &value.Read{Value: val} +} + +func (i *Input) Bind(ctx context.Context, def *input.Definition) error { + options := true + + for len(i.args) > 0 { + var err error + + arg := i.args[0] + i.args = i.args[1:] + + switch { + case options && arg == doubleDash: + options = false + case options && len(arg) > 2 && arg[0:2] == doubleDash: + err = i.parseLongOption(arg[2:], def) + case options && arg[0:1] == "-": + if len(arg) == 1 { + return fmt.Errorf("%w: option name required given '-'", input.ErrInvalidName) + } + + err = i.parseShortOption(arg[1:], def) + default: + err = i.parseArgument(arg, def) + } + + if err != nil { + if herr := i.errorHandle(err); herr != nil { + return herr + } + } + } + + return nil +} + +func (i *Input) parseLongOption(arg string, def *input.Definition) error { + var value *string + + name := arg + + if strings.Contains(arg, "=") { + vals := strings.SplitN(arg, "=", 2) + name = vals[0] + value = &vals[1] + } + + opt, err := def.Option(name) + if err != nil { + return input.ErrorOption(name, err) + } + + return i.appendOption(name, value, opt) +} + +func (i *Input) appendOption(name string, data *string, opt input.Option) error { + v, ok := i.options[name] + + if ok && !opt.IsArray() { + return fmt.Errorf("%w: got: array, expect: %s", input.ErrUnexpectedType, input.Type(opt.Flag)) + } + + var val string + + switch { + case data != nil: + val = *data + case opt.IsBool(): + val = "true" + case len(i.args) > 0 && len(i.args[0]) > 0 && i.args[0][0:1] != "-": + val = i.args[0] + i.args = i.args[1:] + default: + return input.ErrorOption(name, input.ErrRequired) + } + + if !ok { + v = value.ByFlag(opt.Flag) + i.options[name] = v + } + + if err := v.Append(val); err != nil { + return input.ErrorOption(name, err) + } + + return nil +} + +func (i *Input) parseShortOption(arg string, def *input.Definition) error { + name := arg + + var value string + + if len(name) > 1 { + name, value = arg[0:1], arg[1:] + } + + opt, err := def.ShortOption(name) + if err != nil { + return err + } + + if opt.IsBool() && value != "" { + if err := i.parseShortOption(value, def); err != nil { + return err + } + + value = "" + } + + if value == "" { + return i.appendOption(opt.Name, nil, opt) + } + + return i.appendOption(opt.Name, &value, opt) +} + +func (i *Input) parseArgument(arg string, def *input.Definition) error { + opt, err := def.Argument(len(i.arguments)) + if err != nil { + return err + } + + v, ok := i.arguments[opt.Name] + if !ok { + v = value.ByFlag(opt.Flag) + i.arguments[opt.Name] = v + } + + if err := v.Append(arg); err != nil { + return input.ErrorArgument(opt.Name, err) + } + + return nil +} diff --git a/input/array/input.go b/input/array/input.go new file mode 100644 index 0000000..5bf1704 --- /dev/null +++ b/input/array/input.go @@ -0,0 +1,87 @@ +package array + +import ( + "context" + "sync" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/value" + "gitoa.ru/go-4devs/console/input/wrap" +) + +var _ input.ReadInput = (*Input)(nil) + +func Argument(name string, v interface{}) func(*Input) { + return func(i *Input) { + i.args[name] = value.New(v) + } +} + +func Option(name string, v interface{}) func(*Input) { + return func(i *Input) { + i.opt[name] = value.New(v) + } +} + +func New(opts ...func(*Input)) *wrap.Input { + i := &Input{ + args: make(map[string]input.Value), + opt: make(map[string]input.Value), + } + + for _, opt := range opts { + opt(i) + } + + return &wrap.Input{ReadInput: i} +} + +type Input struct { + args map[string]input.Value + opt map[string]input.Value + mu sync.Mutex +} + +func (i *Input) ReadOption(_ context.Context, name string) (input.Value, error) { + if o, has := i.opt[name]; has { + return o, nil + } + + return nil, input.ErrorOption(name, input.ErrNotFound) +} + +func (i *Input) HasOption(name string) bool { + _, has := i.opt[name] + + return has +} + +func (i *Input) SetOption(name string, val input.Value) { + i.mu.Lock() + i.opt[name] = val + i.mu.Unlock() +} + +func (i *Input) ReadArgument(_ context.Context, name string) (input.Value, error) { + if a, has := i.args[name]; has { + return a, nil + } + + return nil, input.ErrorArgument(name, input.ErrNotFound) +} + +func (i *Input) HasArgument(name string) bool { + _, has := i.args[name] + + return has +} + +func (i *Input) SetArgument(name string, val input.Value) { + i.mu.Lock() + i.args[name] = val + i.mu.Unlock() +} + +func (i *Input) Bind(_ context.Context, def *input.Definition) error { + return nil +} diff --git a/input/definition.go b/input/definition.go new file mode 100644 index 0000000..1ae9cca --- /dev/null +++ b/input/definition.go @@ -0,0 +1,95 @@ +package input + +func NewDefinition() *Definition { + return &Definition{ + options: make(map[string]Option), + args: make(map[string]Argument), + short: make(map[string]string), + } +} + +type Definition struct { + options map[string]Option + posOpt []string + args map[string]Argument + posArgs []string + short map[string]string +} + +func (d *Definition) Options() []string { + return d.posOpt +} + +func (d *Definition) Arguments() []string { + return d.posArgs +} + +func (d *Definition) SetOption(name, description string, opts ...func(*Option)) *Definition { + return d.SetOptions(NewOption(name, description, opts...)) +} + +func (d *Definition) SetOptions(opts ...Option) *Definition { + for _, opt := range opts { + if _, has := d.options[opt.Name]; !has { + d.posOpt = append([]string{opt.Name}, d.posOpt...) + } + + d.options[opt.Name] = opt + if opt.HasShort() { + d.short[opt.Short] = opt.Name + } + } + + return d +} + +func (d *Definition) SetArgument(name, description string, opts ...func(*Argument)) *Definition { + return d.SetArguments(NewArgument(name, description, opts...)) +} + +func (d *Definition) SetArguments(args ...Argument) *Definition { + for _, arg := range args { + if _, ok := d.args[arg.Name]; !ok { + d.posArgs = append(d.posArgs, arg.Name) + } + + d.args[arg.Name] = arg + } + + return d +} + +func (d *Definition) Argument(pos int) (Argument, error) { + if len(d.posArgs) == 0 { + return Argument{}, ErrNoArgs + } + + lastPos := len(d.posArgs) - 1 + if lastPos < pos { + arg := d.args[d.posArgs[lastPos]] + if arg.IsArray() { + return arg, nil + } + + return Argument{}, ErrToManyArgs + } + + return d.args[d.posArgs[pos]], nil +} + +func (d *Definition) ShortOption(short string) (Option, error) { + name, ok := d.short[short] + if !ok { + return Option{}, ErrNotFound + } + + return d.Option(name) +} + +func (d *Definition) Option(name string) (Option, error) { + if opt, ok := d.options[name]; ok { + return opt, nil + } + + return Option{}, ErrNotFound +} diff --git a/input/error.go b/input/error.go new file mode 100644 index 0000000..49f8661 --- /dev/null +++ b/input/error.go @@ -0,0 +1,50 @@ +package input + +import ( + "errors" + "fmt" +) + +var ( + ErrNotFound = errors.New("not found") + ErrNoArgs = errors.New("no arguments expected") + ErrToManyArgs = errors.New("too many arguments") + ErrUnexpectedType = errors.New("unexpected type") + ErrRequired = errors.New("is required") + ErrAppend = errors.New("failed append") + ErrInvalidName = errors.New("invalid name") +) + +type Error struct { + name string + err error + t string +} + +func (o Error) Error() string { + return fmt.Sprintf("%s: '%s' %s", o.t, o.name, o.err) +} + +func (o Error) Is(err error) bool { + return errors.Is(err, o.err) +} + +func (o Error) Unwrap() error { + return o.err +} + +func ErrorOption(name string, err error) Error { + return Error{ + name: name, + err: err, + t: "option", + } +} + +func ErrorArgument(name string, err error) Error { + return Error{ + name: name, + err: err, + t: "argument", + } +} diff --git a/input/flag.go b/input/flag.go new file mode 100644 index 0000000..9d719d3 --- /dev/null +++ b/input/flag.go @@ -0,0 +1,76 @@ +package input + +//go:generate stringer -type=Flag -linecomment + +type Flag int + +const ( + ValueString Flag = 0 // string + ValueRequired Flag = 1 << iota // required + ValueArray // array + ValueInt // int + ValueInt64 // int64 + ValueUint // uint + ValueUint64 // uint64 + ValueFloat64 // float64 + ValueBool // bool + ValueDuration // duration + ValueTime // time + ValueAny // any +) + +func (f Flag) Type() Flag { + return Type(f) +} + +func (f Flag) With(v Flag) Flag { + return f | v +} + +func (f Flag) IsString() bool { + return f|ValueRequired|ValueArray^ValueRequired^ValueArray == 0 +} + +func (f Flag) IsRequired() bool { + return f&ValueRequired > 0 +} + +func (f Flag) IsArray() bool { + return f&ValueArray > 0 +} + +func (f Flag) IsInt() bool { + return f&ValueInt > 0 +} + +func (f Flag) IsInt64() bool { + return f&ValueInt64 > 0 +} + +func (f Flag) IsUint() bool { + return f&ValueUint > 0 +} + +func (f Flag) IsUint64() bool { + return f&ValueUint64 > 0 +} + +func (f Flag) IsFloat64() bool { + return f&ValueFloat64 > 0 +} + +func (f Flag) IsBool() bool { + return f&ValueBool > 0 +} + +func (f Flag) IsDuration() bool { + return f&ValueDuration > 0 +} + +func (f Flag) IsTime() bool { + return f&ValueTime > 0 +} + +func (f Flag) IsAny() bool { + return f&ValueAny > 0 +} diff --git a/input/flag_string.go b/input/flag_string.go new file mode 100644 index 0000000..6040dca --- /dev/null +++ b/input/flag_string.go @@ -0,0 +1,47 @@ +// Code generated by "stringer -type=Flag -linecomment"; DO NOT EDIT. + +package input + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ValueString-0] + _ = x[ValueRequired-2] + _ = x[ValueArray-4] + _ = x[ValueInt-8] + _ = x[ValueInt64-16] + _ = x[ValueUint-32] + _ = x[ValueUint64-64] + _ = x[ValueFloat64-128] + _ = x[ValueBool-256] + _ = x[ValueDuration-512] + _ = x[ValueTime-1024] + _ = x[ValueAny-2048] +} + +const _Flag_name = "stringrequiredarrayintint64uintuint64float64booldurationtimeany" + +var _Flag_map = map[Flag]string{ + 0: _Flag_name[0:6], + 2: _Flag_name[6:14], + 4: _Flag_name[14:19], + 8: _Flag_name[19:22], + 16: _Flag_name[22:27], + 32: _Flag_name[27:31], + 64: _Flag_name[31:37], + 128: _Flag_name[37:44], + 256: _Flag_name[44:48], + 512: _Flag_name[48:56], + 1024: _Flag_name[56:60], + 2048: _Flag_name[60:63], +} + +func (i Flag) String() string { + if str, ok := _Flag_map[i]; ok { + return str + } + return "Flag(" + strconv.FormatInt(int64(i), 10) + ")" +} diff --git a/input/input.go b/input/input.go new file mode 100644 index 0000000..53679db --- /dev/null +++ b/input/input.go @@ -0,0 +1,21 @@ +package input + +import ( + "context" +) + +type ReadInput interface { + Bind(ctx context.Context, def *Definition) error + + ReadOption(ctx context.Context, name string) (Value, error) + SetOption(name string, value Value) + + ReadArgument(ctx context.Context, name string) (Value, error) + SetArgument(name string, value Value) +} + +type Input interface { + Option(ctx context.Context, name string) Value + Argument(ctx context.Context, name string) Value + ReadInput +} diff --git a/input/option.go b/input/option.go new file mode 100644 index 0000000..1017513 --- /dev/null +++ b/input/option.go @@ -0,0 +1,53 @@ +package input + +func NewOption(name, description string, opts ...func(*Option)) Option { + o := Option{ + Name: name, + Description: description, + } + + for _, opt := range opts { + opt(&o) + } + + return o +} + +type Option struct { + Name string + Description string + Short string + Flag Flag + Default Value + Valid []func(Value) error +} + +func (o Option) HasShort() bool { + return len(o.Short) == 1 +} + +func (o Option) HasDefault() bool { + return o.Default != nil +} + +func (o Option) IsBool() bool { + return o.Flag.IsBool() +} + +func (o Option) IsArray() bool { + return o.Flag.IsArray() +} + +func (o Option) IsRequired() bool { + return o.Flag.IsRequired() +} + +func (o Option) Validate(v Value) error { + for _, valid := range o.Valid { + if err := valid(v); err != nil { + return ErrorOption(o.Name, err) + } + } + + return nil +} diff --git a/input/option/helpers.go b/input/option/helpers.go new file mode 100644 index 0000000..397d5c5 --- /dev/null +++ b/input/option/helpers.go @@ -0,0 +1,35 @@ +package option + +import "gitoa.ru/go-4devs/console/input" + +func Bool(name, description string, opts ...func(*input.Option)) input.Option { + return input.NewOption(name, description, append(opts, Value(input.ValueBool))...) +} + +func Duration(name, description string, opts ...func(*input.Option)) input.Option { + return input.NewOption(name, description, append(opts, Value(input.ValueDuration))...) +} + +func Float64(name, description string, opts ...func(*input.Option)) input.Option { + return input.NewOption(name, description, append(opts, Value(input.ValueFloat64))...) +} + +func Int(name, description string, opts ...func(*input.Option)) input.Option { + return input.NewOption(name, description, append(opts, Value(input.ValueInt))...) +} + +func Int64(name, description string, opts ...func(*input.Option)) input.Option { + return input.NewOption(name, description, append(opts, Value(input.ValueInt64))...) +} + +func Time(name, description string, opts ...func(*input.Option)) input.Option { + return input.NewOption(name, description, append(opts, Value(input.ValueTime))...) +} + +func Uint(name, description string, opts ...func(*input.Option)) input.Option { + return input.NewOption(name, description, append(opts, Value(input.ValueUint))...) +} + +func Uint64(name, descriontion string, opts ...func(*input.Option)) input.Option { + return input.NewOption(name, descriontion, append(opts, Value(input.ValueUint64))...) +} diff --git a/input/option/option.go b/input/option/option.go new file mode 100644 index 0000000..ad69b30 --- /dev/null +++ b/input/option/option.go @@ -0,0 +1,44 @@ +package option + +import ( + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/value" +) + +func Required(o *input.Option) { + o.Flag |= input.ValueRequired +} + +func Default(in interface{}) func(*input.Option) { + return func(o *input.Option) { + o.Default = value.New(in) + } +} + +func Short(s string) func(*input.Option) { + return func(o *input.Option) { + o.Short = s + } +} + +func Array(o *input.Option) { + o.Flag |= input.ValueArray +} + +func Value(flag input.Flag) func(*input.Option) { + return func(o *input.Option) { + o.Flag |= flag + } +} + +func Flag(in input.Flag) func(*input.Option) { + return func(o *input.Option) { + o.Flag = in + } +} + +func Valid(f ...func(input.Value) error) func(*input.Option) { + return func(o *input.Option) { + o.Valid = f + } +} diff --git a/input/value.go b/input/value.go new file mode 100644 index 0000000..bb7e976 --- /dev/null +++ b/input/value.go @@ -0,0 +1,58 @@ +package input + +import ( + "time" +) + +type Value interface { + String() string + Int() int + Int64() int64 + Uint() uint + Uint64() uint64 + Float64() float64 + Bool() bool + Duration() time.Duration + Time() time.Time + Any() interface{} + + Strings() []string + Ints() []int + Int64s() []int64 + Uints() []uint + Uint64s() []uint64 + Float64s() []float64 + Bools() []bool + Durations() []time.Duration + Times() []time.Time +} + +type AppendValue interface { + Value + Append(string) error +} + +func Type(flag Flag) Flag { + switch { + case (flag & ValueInt) > 0: + return ValueInt + case (flag & ValueInt64) > 0: + return ValueInt64 + case (flag & ValueUint) > 0: + return ValueUint + case (flag & ValueUint64) > 0: + return ValueUint64 + case (flag & ValueFloat64) > 0: + return ValueFloat64 + case (flag & ValueBool) > 0: + return ValueBool + case (flag & ValueDuration) > 0: + return ValueDuration + case (flag & ValueTime) > 0: + return ValueTime + case (flag & ValueAny) > 0: + return ValueAny + default: + return ValueString + } +} diff --git a/input/value/any.go b/input/value/any.go new file mode 100644 index 0000000..08955f6 --- /dev/null +++ b/input/value/any.go @@ -0,0 +1,21 @@ +package value + +import "gitoa.ru/go-4devs/console/input" + +type Any struct { + Empty + Val []interface{} + Flag input.Flag +} + +func (a *Any) Any() interface{} { + if a.Flag.IsArray() { + return a.Val + } + + if len(a.Val) > 0 { + return a.Val[0] + } + + return nil +} diff --git a/input/value/bool.go b/input/value/bool.go new file mode 100644 index 0000000..f3a99f5 --- /dev/null +++ b/input/value/bool.go @@ -0,0 +1,44 @@ +package value + +import ( + "strconv" + + "gitoa.ru/go-4devs/console/input" +) + +type Bool struct { + Empty + Val []bool + Flag input.Flag +} + +func (b *Bool) Append(in string) error { + v, err := strconv.ParseBool(in) + if err != nil { + return err + } + + b.Val = append(b.Val, v) + + return nil +} + +func (b *Bool) Bool() bool { + if !b.Flag.IsArray() && len(b.Val) == 1 { + return b.Val[0] + } + + return false +} + +func (b *Bool) Bools() []bool { + return b.Val +} + +func (b *Bool) Any() interface{} { + if b.Flag&input.ValueArray > 0 { + return b.Bools() + } + + return b.Bool() +} diff --git a/input/value/duration.go b/input/value/duration.go new file mode 100644 index 0000000..0935443 --- /dev/null +++ b/input/value/duration.go @@ -0,0 +1,44 @@ +package value + +import ( + "time" + + "gitoa.ru/go-4devs/console/input" +) + +type Duration struct { + Empty + Val []time.Duration + Flag input.Flag +} + +func (d *Duration) Append(in string) error { + v, err := time.ParseDuration(in) + if err != nil { + return err + } + + d.Val = append(d.Val, v) + + return nil +} + +func (d *Duration) Duration() time.Duration { + if !d.Flag.IsArray() && len(d.Val) == 1 { + return d.Val[0] + } + + return 0 +} + +func (d *Duration) Durations() []time.Duration { + return d.Val +} + +func (d *Duration) Any() interface{} { + if d.Flag&input.ValueArray > 0 { + return d.Durations() + } + + return d.Duration() +} diff --git a/input/value/empty.go b/input/value/empty.go new file mode 100644 index 0000000..fd4b9fe --- /dev/null +++ b/input/value/empty.go @@ -0,0 +1,90 @@ +package value + +import ( + "fmt" + "time" + + "gitoa.ru/go-4devs/console/input" +) + +type Empty struct{} + +func (e *Empty) Append(string) error { + return fmt.Errorf("%w: in empty value", input.ErrInvalidName) +} + +func (e *Empty) String() string { + return "" +} + +func (e *Empty) Int() int { + return 0 +} + +func (e *Empty) Int64() int64 { + return 0 +} + +func (e *Empty) Uint() uint { + return 0 +} + +func (e *Empty) Uint64() uint64 { + return 0 +} + +func (e *Empty) Float64() float64 { + return 0 +} + +func (e *Empty) Bool() bool { + return false +} + +func (e *Empty) Duration() time.Duration { + return 0 +} + +func (e *Empty) Time() time.Time { + return time.Time{} +} + +func (e *Empty) Strings() []string { + return nil +} + +func (e *Empty) Ints() []int { + return nil +} + +func (e *Empty) Int64s() []int64 { + return nil +} + +func (e *Empty) Uints() []uint { + return nil +} + +func (e *Empty) Uint64s() []uint64 { + return nil +} + +func (e *Empty) Float64s() []float64 { + return nil +} + +func (e *Empty) Bools() []bool { + return nil +} + +func (e *Empty) Durations() []time.Duration { + return nil +} + +func (e *Empty) Times() []time.Time { + return nil +} + +func (e *Empty) Any() interface{} { + return nil +} diff --git a/input/value/float64.go b/input/value/float64.go new file mode 100644 index 0000000..b50c1e8 --- /dev/null +++ b/input/value/float64.go @@ -0,0 +1,44 @@ +package value + +import ( + "strconv" + + "gitoa.ru/go-4devs/console/input" +) + +type Float64 struct { + Empty + Val []float64 + Flag input.Flag +} + +func (f *Float64) Append(in string) error { + v, err := strconv.ParseFloat(in, 64) + if err != nil { + return err + } + + f.Val = append(f.Val, v) + + return nil +} + +func (f *Float64) Float64() float64 { + if !f.Flag.IsArray() && len(f.Val) == 1 { + return f.Val[0] + } + + return 0 +} + +func (f *Float64) Float64s() []float64 { + return f.Val +} + +func (f *Float64) Any() interface{} { + if f.Flag&input.ValueFloat64 > 0 { + return f.Float64s() + } + + return f.Float64() +} diff --git a/input/value/int.go b/input/value/int.go new file mode 100644 index 0000000..6ad95e7 --- /dev/null +++ b/input/value/int.go @@ -0,0 +1,44 @@ +package value + +import ( + "strconv" + + "gitoa.ru/go-4devs/console/input" +) + +type Int struct { + Empty + Val []int + Flag input.Flag +} + +func (i *Int) Append(in string) error { + v, err := strconv.Atoi(in) + if err != nil { + return err + } + + i.Val = append(i.Val, v) + + return nil +} + +func (i *Int) Int() int { + if !i.Flag.IsArray() && len(i.Val) == 1 { + return i.Val[0] + } + + return 0 +} + +func (i *Int) Ints() []int { + return i.Val +} + +func (i *Int) Any() interface{} { + if i.Flag&input.ValueArray > 0 { + return i.Ints() + } + + return i.Int() +} diff --git a/input/value/int64.go b/input/value/int64.go new file mode 100644 index 0000000..bb7deb8 --- /dev/null +++ b/input/value/int64.go @@ -0,0 +1,44 @@ +package value + +import ( + "strconv" + + "gitoa.ru/go-4devs/console/input" +) + +type Int64 struct { + Empty + Val []int64 + Flag input.Flag +} + +func (i *Int64) Int64() int64 { + if !i.Flag.IsArray() && len(i.Val) == 1 { + return i.Val[0] + } + + return 0 +} + +func (i *Int64) Int64s() []int64 { + return i.Val +} + +func (i *Int64) Any() interface{} { + if i.Flag&input.ValueArray > 0 { + return i.Int64s() + } + + return i.Int64() +} + +func (i *Int64) Append(in string) error { + v, err := strconv.ParseInt(in, 10, 64) + if err != nil { + return err + } + + i.Val = append(i.Val, v) + + return nil +} diff --git a/input/value/read.go b/input/value/read.go new file mode 100644 index 0000000..8e90448 --- /dev/null +++ b/input/value/read.go @@ -0,0 +1,17 @@ +package value + +import ( + "fmt" + + "gitoa.ru/go-4devs/console/input" +) + +var _ input.AppendValue = (*Read)(nil) + +type Read struct { + input.Value +} + +func (r *Read) Append(string) error { + return fmt.Errorf("%w: read value", input.ErrInvalidName) +} diff --git a/input/value/string.go b/input/value/string.go new file mode 100644 index 0000000..d138bae --- /dev/null +++ b/input/value/string.go @@ -0,0 +1,39 @@ +package value + +import "gitoa.ru/go-4devs/console/input" + +type String struct { + Empty + Val []string + Flag input.Flag +} + +func (s *String) Append(in string) error { + s.Val = append(s.Val, in) + + return nil +} + +func (s *String) String() string { + if s.Flag.IsArray() { + return "" + } + + if len(s.Val) == 1 { + return s.Val[0] + } + + return "" +} + +func (s *String) Strings() []string { + return s.Val +} + +func (s *String) Any() interface{} { + if s.Flag.IsArray() { + return s.Strings() + } + + return s.String() +} diff --git a/input/value/time.go b/input/value/time.go new file mode 100644 index 0000000..9986d4a --- /dev/null +++ b/input/value/time.go @@ -0,0 +1,44 @@ +package value + +import ( + "time" + + "gitoa.ru/go-4devs/console/input" +) + +type Time struct { + Empty + Val []time.Time + Flag input.Flag +} + +func (t *Time) Append(in string) error { + v, err := time.Parse(time.RFC3339, in) + if err != nil { + return err + } + + t.Val = append(t.Val, v) + + return nil +} + +func (t *Time) Time() time.Time { + if !t.Flag.IsArray() && len(t.Val) == 1 { + return t.Val[0] + } + + return time.Time{} +} + +func (t *Time) Times() []time.Time { + return t.Val +} + +func (t *Time) Amy() interface{} { + if t.Flag&input.ValueArray > 0 { + return t.Times() + } + + return t.Time() +} diff --git a/input/value/uint.go b/input/value/uint.go new file mode 100644 index 0000000..1561dac --- /dev/null +++ b/input/value/uint.go @@ -0,0 +1,44 @@ +package value + +import ( + "strconv" + + "gitoa.ru/go-4devs/console/input" +) + +type Uint struct { + Empty + Val []uint + Flag input.Flag +} + +func (u *Uint) Append(in string) error { + v, err := strconv.ParseUint(in, 10, 64) + if err != nil { + return err + } + + u.Val = append(u.Val, uint(v)) + + return nil +} + +func (u *Uint) Uint() uint { + if !u.Flag.IsArray() && len(u.Val) == 1 { + return u.Val[0] + } + + return 0 +} + +func (u *Uint) Uints() []uint { + return u.Val +} + +func (u *Uint) Any() interface{} { + if u.Flag&input.ValueArray > 0 { + return u.Uints() + } + + return u.Uint() +} diff --git a/input/value/uint64.go b/input/value/uint64.go new file mode 100644 index 0000000..a8b96e8 --- /dev/null +++ b/input/value/uint64.go @@ -0,0 +1,44 @@ +package value + +import ( + "strconv" + + "gitoa.ru/go-4devs/console/input" +) + +type Uint64 struct { + Empty + Val []uint64 + Flag input.Flag +} + +func (u *Uint64) Append(in string) error { + v, err := strconv.ParseUint(in, 10, 64) + if err != nil { + return err + } + + u.Val = append(u.Val, v) + + return nil +} + +func (u *Uint64) Uint64() uint64 { + if !u.Flag.IsArray() && len(u.Val) == 1 { + return u.Val[0] + } + + return 0 +} + +func (u *Uint64) Uint64s() []uint64 { + return u.Val +} + +func (u *Uint64) Any() interface{} { + if u.Flag&input.ValueArray > 0 { + return u.Uint64s() + } + + return u.Uint64() +} diff --git a/input/value/value.go b/input/value/value.go new file mode 100644 index 0000000..182bcb5 --- /dev/null +++ b/input/value/value.go @@ -0,0 +1,84 @@ +package value + +import ( + "time" + + "gitoa.ru/go-4devs/console/input" +) + +//nolint: gocyclo +func New(v interface{}) input.Value { + switch val := v.(type) { + case string: + return &String{Val: []string{val}, Flag: input.ValueString} + case int: + return &Int{Val: []int{val}, Flag: input.ValueInt} + case int64: + return &Int64{Val: []int64{val}, Flag: input.ValueInt64} + case uint: + return &Uint{Val: []uint{val}, Flag: input.ValueUint} + case uint64: + return &Uint64{Val: []uint64{val}, Flag: input.ValueUint64} + case float64: + return &Float64{Val: []float64{val}, Flag: input.ValueFloat64} + case bool: + return &Bool{Val: []bool{val}, Flag: input.ValueBool} + case time.Duration: + return &Duration{Val: []time.Duration{val}, Flag: input.ValueDuration} + case time.Time: + return &Time{Val: []time.Time{val}, Flag: input.ValueTime} + case []int64: + return &Int64{Val: val, Flag: input.ValueInt64 | input.ValueArray} + case []uint: + return &Uint{Val: val, Flag: input.ValueUint | input.ValueArray} + case []uint64: + return &Uint64{Val: val, Flag: input.ValueUint64 | input.ValueArray} + case []float64: + return &Float64{Val: val, Flag: input.ValueFloat64 | input.ValueArray} + case []bool: + return &Bool{Val: val, Flag: input.ValueBool | input.ValueArray} + case []time.Duration: + return &Duration{Val: val, Flag: input.ValueDuration | input.ValueArray} + case []time.Time: + return &Time{Val: val, Flag: input.ValueTime | input.ValueArray} + case []string: + return &String{Val: val, Flag: input.ValueString | input.ValueArray} + case []int: + return &Int{Val: val, Flag: input.ValueInt | input.ValueArray} + case []interface{}: + return &Any{Val: val, Flag: input.ValueAny | input.ValueArray} + case input.Value: + return val + default: + if v != nil { + return &Any{Val: []interface{}{v}, Flag: input.ValueAny} + } + + return &Empty{} + } +} + +func ByFlag(flag input.Flag) input.AppendValue { + switch { + case flag.IsInt(): + return &Int{Flag: flag | input.ValueInt} + case flag.IsInt64(): + return &Int64{Flag: flag | input.ValueInt64} + case flag.IsUint(): + return &Uint{Flag: flag | input.ValueUint} + case flag.IsUint64(): + return &Uint64{Flag: flag | input.ValueUint64} + case flag.IsFloat64(): + return &Float64{Flag: flag | input.ValueFloat64} + case flag.IsBool(): + return &Bool{Flag: flag | input.ValueBool} + case flag.IsDuration(): + return &Duration{Flag: flag | input.ValueDuration} + case flag.IsTime(): + return &Time{Flag: flag | input.ValueTime} + case flag.IsAny(): + return &Any{Flag: flag | input.ValueAny} + default: + return &String{} + } +} diff --git a/input/wrap/input.go b/input/wrap/input.go new file mode 100644 index 0000000..7ca33f9 --- /dev/null +++ b/input/wrap/input.go @@ -0,0 +1,105 @@ +package wrap + +import ( + "context" + "errors" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/value" +) + +type Input struct { + input.ReadInput +} + +func (i *Input) Option(ctx context.Context, name string) input.Value { + if v, err := i.ReadOption(ctx, name); err == nil { + return v + } + + return &value.Empty{} +} + +func (i *Input) Argument(ctx context.Context, name string) input.Value { + if v, err := i.ReadArgument(ctx, name); err == nil { + return v + } + + return &value.Empty{} +} + +func (i *Input) Bind(ctx context.Context, def *input.Definition) error { + if err := i.ReadInput.Bind(ctx, def); err != nil { + return err + } + + if err := i.bindArguments(ctx, def); err != nil { + return err + } + + return i.bindOptions(ctx, def) +} + +func (i *Input) bindOptions(ctx context.Context, def *input.Definition) error { + for _, name := range def.Options() { + opt, err := def.Option(name) + if err != nil { + return err + } + + v, err := i.ReadOption(ctx, name) + if err != nil && !errors.Is(err, input.ErrNotFound) { + return input.ErrorOption(name, err) + } + + if err == nil { + if err := opt.Validate(v); err != nil { + return input.ErrorOption(name, err) + } + + continue + } + + if opt.IsRequired() && !opt.HasDefault() { + return input.ErrorOption(name, input.ErrRequired) + } + + if opt.HasDefault() { + i.SetOption(name, opt.Default) + } + } + + return nil +} + +func (i *Input) bindArguments(ctx context.Context, def *input.Definition) error { + for pos, name := range def.Arguments() { + arg, err := def.Argument(pos) + if err != nil { + return err + } + + v, err := i.ReadArgument(ctx, name) + if err != nil && !errors.Is(err, input.ErrNotFound) { + return input.ErrorArgument(name, err) + } + + if err == nil { + if err := arg.Validate(v); err != nil { + return input.ErrorArgument(name, err) + } + + continue + } + + if arg.IsRequired() && !arg.HasDefault() { + return input.ErrorArgument(name, input.ErrRequired) + } + + if arg.HasDefault() { + i.SetArgument(name, arg.Default) + } + } + + return nil +} diff --git a/list.go b/list.go new file mode 100644 index 0000000..7c0cad1 --- /dev/null +++ b/list.go @@ -0,0 +1,111 @@ +package console + +import ( + "context" + "fmt" + "strings" + + "gitoa.ru/go-4devs/console/descriptor" + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/option" + "gitoa.ru/go-4devs/console/output" + "gitoa.ru/go-4devs/console/validator" +) + +const defaultLenNamespace = 2 + +//nolint: gochecknoinits +func init() { + MustRegister(list()) +} + +func list() *Command { + return &Command{ + Name: CommandList, + Description: "Lists commands", + Help: ` +The {{ .Name }} command lists all commands: + {{ .Bin }} {{ .Name }} +You can also display the commands for a specific namespace: + {{ .Bin }} {{ .Name }} test +You can also output the information in other formats by using the --format option: + {{ .Bin }} {{ .Name }} --format=xml +`, + Execute: func(ctx context.Context, in input.Input, out output.Output) error { + ns := in.Argument(ctx, "namespace").String() + format := in.Option(ctx, helpOptFormat).String() + + des, err := descriptor.Find(format) + if err != nil { + return err + } + cmds := Commands() + commands := descriptor.Commands{ + Namespace: ns, + Definition: Default(input.NewDefinition()), + } + groups := make(map[string]*descriptor.NSCommand) + namespaces := make([]string, 0, len(cmds)) + empty := descriptor.NSCommand{} + + for _, name := range cmds { + if ns != "" && !strings.HasPrefix(name, ns+":") { + continue + } + + cmd, _ := Find(name) + if cmd.Hidden { + continue + } + + gn := strings.SplitN(name, ":", 2) + if len(gn) != defaultLenNamespace { + empty.Append(cmd.Name, cmd.Description) + + continue + } + + if _, ok := groups[gn[0]]; !ok { + groups[gn[0]] = &descriptor.NSCommand{ + Name: gn[0], + } + namespaces = append(namespaces, gn[0]) + } + + groups[gn[0]].Append(name, cmd.Description) + } + if len(empty.Commands) > 0 { + commands.Commands = append(commands.Commands, empty) + } + + for _, name := range namespaces { + commands.Commands = append(commands.Commands, *groups[name]) + } + + if ns != "" && len(commands.Commands) == 0 { + return fmt.Errorf("%w: namespace %s", ErrNotFound, ns) + } + + return des.Commands(ctx, out, commands) + }, + Configure: func(ctx context.Context, config *input.Definition) error { + formats := descriptor.Descriptors() + config. + SetArguments( + input.NewArgument("namespace", "The namespace name"), + ). + SetOptions( + input.NewOption(helpOptFormat, fmt.Sprintf("The output format (%s)", strings.Join(formats, ", ")), + option.Required, + option.Default(formats[0]), + option.Valid( + validator.NotBlank(0), + validator.Enum(formats...), + ), + ), + ) + + return nil + }, + } +} diff --git a/output/formatter/ansi.go b/output/formatter/ansi.go new file mode 100644 index 0000000..f3b56e8 --- /dev/null +++ b/output/formatter/ansi.go @@ -0,0 +1,5 @@ +package formatter + +func Ansi() *Formatter { + return New() +} diff --git a/output/formatter/formatter.go b/output/formatter/formatter.go new file mode 100644 index 0000000..6993ed3 --- /dev/null +++ b/output/formatter/formatter.go @@ -0,0 +1,79 @@ +package formatter + +import ( + "bytes" + "context" + "regexp" + + "gitoa.ru/go-4devs/console/output/style" +) + +//nolint: gochecknoglobals +var re = regexp.MustCompile(`<(([a-z][^<>]+)|/([a-z][^<>]+)?)>`) + +func WithStyle(styles func(string) (style.Style, error)) func(*Formatter) { + return func(f *Formatter) { + f.styles = styles + } +} + +func New(opts ...func(*Formatter)) *Formatter { + f := &Formatter{ + styles: style.Find, + } + + for _, opt := range opts { + opt(f) + } + + return f +} + +type Formatter struct { + styles func(string) (style.Style, error) +} + +func (a *Formatter) Format(ctx context.Context, msg string) string { + var ( + out bytes.Buffer + cur int + ) + + for _, idx := range re.FindAllStringIndex(msg, -1) { + tag := msg[idx[0]+1 : idx[1]-1] + + if cur < idx[0] { + out.WriteString(msg[cur:idx[0]]) + } + + var ( + st style.Style + err error + ) + + switch { + case tag[0:1] == "/": + st, err = a.styles(tag[1:]) + if err == nil { + out.WriteString(st.Set(style.ActionUnset)) + } + default: + st, err = a.styles(tag) + if err == nil { + out.WriteString(st.Set(style.ActionSet)) + } + } + + if err != nil { + cur = idx[0] + } else { + cur = idx[1] + } + } + + if len(msg) > cur { + out.WriteString(msg[cur:]) + } + + return out.String() +} diff --git a/output/formatter/formatter_test.go b/output/formatter/formatter_test.go new file mode 100644 index 0000000..6c87150 --- /dev/null +++ b/output/formatter/formatter_test.go @@ -0,0 +1,26 @@ +package formatter_test + +import ( + "context" + "testing" + + "gitoa.ru/go-4devs/console/output/formatter" +) + +func TestFormatter(t *testing.T) { + ctx := context.Background() + formatter := formatter.New() + + cases := map[string]string{ + "info message": "\x1b[32minfo message\x1b[39m", + "": "\x1b[32m\x1b[39m", + "...": "...", + } + + for msg, ex := range cases { + got := formatter.Format(ctx, msg) + if ex != got { + t.Errorf("ivalid expected:%#v, got: %#v", ex, got) + } + } +} diff --git a/output/formatter/none.go b/output/formatter/none.go new file mode 100644 index 0000000..2c8ee78 --- /dev/null +++ b/output/formatter/none.go @@ -0,0 +1,14 @@ +package formatter + +import "gitoa.ru/go-4devs/console/output/style" + +func None() *Formatter { + return New( + WithStyle(func(name string) (style.Style, error) { + if _, err := style.Find(name); err != nil { + return style.Empty(), err + } + + return style.Empty(), nil + })) +} diff --git a/output/formatter/none_test.go b/output/formatter/none_test.go new file mode 100644 index 0000000..b9614d7 --- /dev/null +++ b/output/formatter/none_test.go @@ -0,0 +1,27 @@ +package formatter_test + +import ( + "context" + "testing" + + "gitoa.ru/go-4devs/console/output/formatter" +) + +func TestNone(t *testing.T) { + ctx := context.Background() + none := formatter.None() + + cases := map[string]string{ + "message info": "message info", + "message error": "message error", + "": "", + "body": "body", + } + + for msg, ex := range cases { + got := none.Format(ctx, msg) + if ex != got { + t.Errorf("expect:%#v, got:%#v", ex, got) + } + } +} diff --git a/output/key.go b/output/key.go new file mode 100644 index 0000000..0cb77dc --- /dev/null +++ b/output/key.go @@ -0,0 +1,59 @@ +package output + +type Key string + +func (k Key) Any(v interface{}) KeyValue { + return KeyValue{ + Key: k, + Value: AnyValue(v), + } +} + +func (k Key) Bool(v bool) KeyValue { + return KeyValue{ + Key: k, + Value: BoolValue(v), + } +} + +func (k Key) Int(v int) KeyValue { + return KeyValue{ + Key: k, + Value: IntValue(v), + } +} + +func (k Key) Int64(v int64) KeyValue { + return KeyValue{ + Key: k, + Value: Int64Value(v), + } +} + +func (k Key) Uint(v uint) KeyValue { + return KeyValue{ + Key: k, + Value: UintValue(v), + } +} + +func (k Key) Uint64(v uint64) KeyValue { + return KeyValue{ + Key: k, + Value: Uint64Value(v), + } +} + +func (k Key) Float64(v float64) KeyValue { + return KeyValue{ + Key: k, + Value: Float64Value(v), + } +} + +func (k Key) String(v string) KeyValue { + return KeyValue{ + Key: k, + Value: StringValue(v), + } +} diff --git a/output/kv.go b/output/kv.go new file mode 100644 index 0000000..34c1e6c --- /dev/null +++ b/output/kv.go @@ -0,0 +1,63 @@ +package output + +import ( + "fmt" + "strings" +) + +var ( + _ fmt.Stringer = KeyValue{} + _ fmt.Stringer = KeyValues{} +) + +type KeyValues []KeyValue + +func (kv KeyValues) String() string { + s := make([]string, len(kv)) + for i, v := range kv { + s[i] = v.String() + } + + return strings.Join(s, ", ") +} + +type KeyValue struct { + Key Key + Value Value +} + +func (k KeyValue) String() string { + return string(k.Key) + "=\"" + k.Value.String() + "\"" +} + +func Any(k string, v interface{}) KeyValue { + return Key(k).Any(v) +} + +func Bool(k string, v bool) KeyValue { + return Key(k).Bool(v) +} + +func Int(k string, v int) KeyValue { + return Key(k).Int(v) +} + +func Int64(k string, v int64) KeyValue { + return Key(k).Int64(v) +} + +func Uint(k string, v uint) KeyValue { + return Key(k).Uint(v) +} + +func Uint64(k string, v uint64) KeyValue { + return Key(k).Uint64(v) +} + +func Float64(k string, v float64) KeyValue { + return Key(k).Float64(v) +} + +func String(k string, v string) KeyValue { + return Key(k).String(v) +} diff --git a/output/output.go b/output/output.go new file mode 100644 index 0000000..b9906b0 --- /dev/null +++ b/output/output.go @@ -0,0 +1,77 @@ +package output + +import ( + "context" + "fmt" + "io" +) + +type Verbosity int + +const ( + VerbosityQuiet Verbosity = iota - 1 + VerbosityNorm + VerbosityInfo + VerbosityDebug + VerbosityTrace +) + +type Output func(ctx context.Context, verb Verbosity, msg string, args ...KeyValue) (int, error) + +func (o Output) Print(ctx context.Context, args ...interface{}) { + o(ctx, VerbosityNorm, fmt.Sprint(args...)) +} + +func (o Output) PrintKV(ctx context.Context, msg string, kv ...KeyValue) { + o(ctx, VerbosityNorm, msg, kv...) +} + +func (o Output) Printf(ctx context.Context, format string, args ...interface{}) { + o(ctx, VerbosityNorm, fmt.Sprintf(format, args...)) +} + +func (o Output) Println(ctx context.Context, args ...interface{}) { + o(ctx, VerbosityNorm, fmt.Sprintln(args...)) +} + +func (o Output) Info(ctx context.Context, args ...interface{}) { + o(ctx, VerbosityInfo, fmt.Sprint(args...)) +} + +func (o Output) InfoKV(ctx context.Context, msg string, kv ...KeyValue) { + o(ctx, VerbosityInfo, msg, kv...) +} + +func (o Output) Debug(ctx context.Context, args ...interface{}) { + o(ctx, VerbosityDebug, fmt.Sprint(args...)) +} + +func (o Output) DebugKV(ctx context.Context, msg string, kv ...KeyValue) { + o(ctx, VerbosityDebug, msg, kv...) +} + +func (o Output) Trace(ctx context.Context, args ...interface{}) { + o(ctx, VerbosityTrace, fmt.Sprint(args...)) +} + +func (o Output) TraceKV(ctx context.Context, msg string, kv ...KeyValue) { + o(ctx, VerbosityTrace, msg, kv...) +} + +func (o Output) Write(b []byte) (int, error) { + return o(context.Background(), VerbosityNorm, string(b)) +} + +func (o Output) Writer(ctx context.Context, verb Verbosity) io.Writer { + return verbosityWriter{ctx, o, verb} +} + +type verbosityWriter struct { + ctx context.Context + out Output + verb Verbosity +} + +func (w verbosityWriter) Write(b []byte) (int, error) { + return w.out(w.ctx, w.verb, string(b)) +} diff --git a/output/style/color.go b/output/style/color.go new file mode 100644 index 0000000..bb1c0b3 --- /dev/null +++ b/output/style/color.go @@ -0,0 +1,51 @@ +package style + +const ( + Black Color = "0" + Red Color = "1" + Green Color = "2" + Yellow Color = "3" + Blue Color = "4" + Magenta Color = "5" + Cyan Color = "6" + White Color = "7" + Default Color = "9" +) + +const ( + Bold Option = "122" + Underscore Option = "424" + Blink Option = "525" + Reverse Option = "727" + Conseal Option = "828" +) + +const ( + ActionSet = 1 + ActionUnset = 2 +) + +type Option string + +func (o Option) Apply(action int) string { + v := string(o) + + switch action { + case ActionSet: + return v[0:1] + case ActionUnset: + return v[1:] + } + + return "" +} + +type Color string + +func (c Color) Apply(action int) string { + if action == ActionSet { + return string(c) + } + + return string(Default) +} diff --git a/output/style/style.go b/output/style/style.go new file mode 100644 index 0000000..91a653e --- /dev/null +++ b/output/style/style.go @@ -0,0 +1,88 @@ +package style + +import ( + "errors" + "fmt" + "strings" + "sync" +) + +//nolint: gochecknoglobals +var ( + styles = map[string]Style{ + "error": {Foreground: White, Background: Red}, + "info": {Foreground: Green}, + "comment": {Foreground: Yellow}, + "question": {Foreground: Black, Background: Cyan}, + } + stylesMu sync.Mutex + empty = Style{} +) + +var ( + ErrNotFound = errors.New("console: style not found") + ErrDuplicateStyle = errors.New("console: Register called twice") +) + +func Empty() Style { + return empty +} + +func Find(name string) (Style, error) { + if st, has := styles[name]; has { + return st, nil + } + + return empty, ErrNotFound +} + +func Register(name string, style Style) error { + stylesMu.Lock() + defer stylesMu.Unlock() + + if _, has := styles[name]; has { + return fmt.Errorf("%w for style %s", ErrDuplicateStyle, name) + } + + styles[name] = style + + return nil +} + +func MustRegister(name string, style Style) { + if err := Register(name, style); err != nil { + panic(err) + } +} + +type Style struct { + Background Color + Foreground Color + Options []Option +} + +func (s Style) Apply(msg string) string { + return s.Set(ActionSet) + msg + s.Set(ActionUnset) +} + +func (s Style) Set(action int) string { + style := make([]string, 0, len(s.Options)) + + if s.Foreground != "" { + style = append(style, "3"+s.Foreground.Apply(action)) + } + + if s.Background != "" { + style = append(style, "4"+s.Background.Apply(action)) + } + + for _, opt := range s.Options { + style = append(style, opt.Apply(action)) + } + + if len(style) == 0 { + return "" + } + + return "\033[" + strings.Join(style, ";") + "m" +} diff --git a/output/value.go b/output/value.go new file mode 100644 index 0000000..4b0a8ee --- /dev/null +++ b/output/value.go @@ -0,0 +1,57 @@ +package output + +import "fmt" + +type Type int + +const ( + TypeAny Type = iota + TypeBool + TypeInt + TypeInt64 + TypeUint + TypeUint64 + TypeFloat64 + TypeString +) + +type Value struct { + vtype Type + value interface{} +} + +func (v Value) String() string { + return fmt.Sprint(v.value) +} + +func AnyValue(v interface{}) Value { + return Value{vtype: TypeAny, value: v} +} + +func BoolValue(v bool) Value { + return Value{vtype: TypeBool, value: v} +} + +func IntValue(v int) Value { + return Value{vtype: TypeInt, value: v} +} + +func Int64Value(v int64) Value { + return Value{vtype: TypeInt64, value: v} +} + +func UintValue(v uint) Value { + return Value{vtype: TypeUint, value: v} +} + +func Uint64Value(v uint64) Value { + return Value{vtype: TypeUint64, value: v} +} + +func Float64Value(v float64) Value { + return Value{vtype: TypeFloat64, value: v} +} + +func StringValue(v string) Value { + return Value{vtype: TypeString, value: v} +} diff --git a/output/verbosity/norm.go b/output/verbosity/norm.go new file mode 100644 index 0000000..608769b --- /dev/null +++ b/output/verbosity/norm.go @@ -0,0 +1,23 @@ +package verbosity + +import ( + "context" + + "gitoa.ru/go-4devs/console/output" +) + +func Verb(out output.Output, verb output.Verbosity) output.Output { + return func(ctx context.Context, v output.Verbosity, msg string, kv ...output.KeyValue) (int, error) { + if verb >= v { + return out(ctx, v, msg, kv...) + } + + return 0, nil + } +} + +func Quiet() output.Output { + return func(context.Context, output.Verbosity, string, ...output.KeyValue) (int, error) { + return 0, nil + } +} diff --git a/output/wrap/formatter.go b/output/wrap/formatter.go new file mode 100644 index 0000000..c2e8443 --- /dev/null +++ b/output/wrap/formatter.go @@ -0,0 +1,22 @@ +package wrap + +import ( + "context" + + "gitoa.ru/go-4devs/console/output" + "gitoa.ru/go-4devs/console/output/formatter" +) + +func Format(out output.Output, format *formatter.Formatter) output.Output { + return func(ctx context.Context, v output.Verbosity, msg string, kv ...output.KeyValue) (int, error) { + return out(ctx, v, format.Format(ctx, msg), kv...) + } +} + +func Ansi(out output.Output) output.Output { + return Format(out, formatter.Ansi()) +} + +func None(out output.Output) output.Output { + return Format(out, formatter.None()) +} diff --git a/output/writer/output.go b/output/writer/output.go new file mode 100644 index 0000000..2175cba --- /dev/null +++ b/output/writer/output.go @@ -0,0 +1,44 @@ +package writer + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + + "gitoa.ru/go-4devs/console/output" +) + +func Stderr() output.Output { + return New(os.Stderr, String) +} + +func Stdout() output.Output { + return New(os.Stdout, String) +} + +func Buffer(buf *bytes.Buffer) output.Output { + return New(buf, String) +} + +func String(_ output.Verbosity, msg string, kv ...output.KeyValue) string { + if len(kv) > 0 { + newline := "" + if msg[len(msg)-1:] == "\n" { + newline = "\n" + } + + return "msg=\"" + strings.TrimSpace(msg) + "\", " + output.KeyValues(kv).String() + newline + + } + + return msg +} + +func New(w io.Writer, format func(verb output.Verbosity, msg string, kv ...output.KeyValue) string) output.Output { + return func(ctx context.Context, verb output.Verbosity, msg string, kv ...output.KeyValue) (int, error) { + return fmt.Fprint(w, format(verb, msg, kv...)) + } +} diff --git a/output/writer/output_test.go b/output/writer/output_test.go new file mode 100644 index 0000000..1a4352d --- /dev/null +++ b/output/writer/output_test.go @@ -0,0 +1,49 @@ +package writer_test + +import ( + "bytes" + "context" + "testing" + + "gitoa.ru/go-4devs/console/output" + "gitoa.ru/go-4devs/console/output/writer" +) + +func TestNew(t *testing.T) { + ctx := context.Background() + buf := bytes.Buffer{} + wr := writer.New(&buf, writer.String) + + cases := map[string]struct { + ex string + kv []output.KeyValue + }{ + "message": { + ex: "message", + }, + "msg with kv": { + ex: "msg=\"msg with kv\", string key=\"string value\", bool key=\"false\", int key=\"42\"", + kv: []output.KeyValue{ + output.String("string key", "string value"), + output.Bool("bool key", false), + output.Int("int key", 42), + }, + }, + "msg with newline \n": { + ex: "msg=\"msg with newline\", int=\"42\"\n", + kv: []output.KeyValue{ + output.Int("int", 42), + }, + }, + } + + for msg, data := range cases { + wr.InfoKV(ctx, msg, data.kv...) + + if data.ex != buf.String() { + t.Errorf("message not equals expext:%s, got:%s", data.ex, buf.String()) + } + + buf.Reset() + } +} diff --git a/register.go b/register.go new file mode 100644 index 0000000..bd27c59 --- /dev/null +++ b/register.go @@ -0,0 +1,141 @@ +package console + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + "sync" +) + +const ( + CommandHelp = "help" + CommandList = "list" +) + +var ( + ErrNotFound = errors.New("command not found") + ErrCommandNil = errors.New("console: Register command is nil") + ErrCommandDuplicate = errors.New("console: duplicate command") +) + +//nolint: gochecknoglobals +var ( + commandsMu sync.RWMutex + commands = make(map[string]*Command) + findCommand = regexp.MustCompile("([^:]+|)") +) + +type ErrorAlternatives struct { + alt []string + err error +} + +func (e ErrorAlternatives) Error() string { + return fmt.Sprintf("%s, alternatives: [%s]", e.err, strings.Join(e.alt, ",")) +} + +func (e ErrorAlternatives) Is(err error) bool { + return errors.Is(e.err, err) +} + +func (e ErrorAlternatives) Unwrap() error { + return e.err +} + +func (e ErrorAlternatives) Alternatives() []string { + return e.alt +} + +// MustRegister register command or panic if err. +func MustRegister(cmd *Command) { + if err := Register(cmd); err != nil { + panic(err) + } +} + +// Register makes a command available execute in app. If Register is called twice with the same name or if driver is nil, return error. +func Register(cmd *Command) error { + if cmd == nil { + return ErrCommandNil + } + + if _, err := Find(cmd.Name); !errors.Is(err, ErrNotFound) { + return fmt.Errorf("%w: command %s", ErrCommandDuplicate, cmd.Name) + } + + register(cmd) + + return nil +} + +func register(cmd *Command) { + commandsMu.Lock() + defer commandsMu.Unlock() + + if cmd != nil && cmd.Name != "" { + commands[cmd.Name] = cmd + } +} + +// Commands returns a sorted list of the names of the registered commands. +func Commands() []string { + commandsMu.RLock() + defer commandsMu.RUnlock() + + return commandNames() +} + +func commandNames() []string { + names := make([]string, 0, len(commands)) + for name := range commands { + names = append(names, name) + } + + sort.Strings(names) + + return names +} + +// Find command by name, tries to find the best match if you give it an abbreviation of a name. +func Find(name string) (*Command, error) { + commandsMu.RLock() + defer commandsMu.RUnlock() + + if cmd, ok := commands[name]; ok { + return cmd, nil + } + + nameRegexp := findCommand.ReplaceAllStringFunc(name, func(in string) string { + return in + "[^:]*" + }) + + findCommands := make([]*Command, 0) + + cmdRegexp, err := regexp.Compile("^" + nameRegexp + "$") + if err != nil { + return nil, err + } + + for name := range commands { + if !commands[name].Hidden && cmdRegexp.MatchString(name) { + findCommands = append(findCommands, commands[name]) + } + } + + if len(findCommands) == 1 { + return findCommands[0], nil + } + + if len(findCommands) > 1 { + names := make([]string, len(findCommands)) + for i := range findCommands { + names[i] = findCommands[i].Name + } + + return nil, ErrorAlternatives{alt: names, err: ErrNotFound} + } + + return nil, ErrNotFound +} diff --git a/register_test.go b/register_test.go new file mode 100644 index 0000000..ead6fde --- /dev/null +++ b/register_test.go @@ -0,0 +1,30 @@ +package console_test + +import ( + "testing" + + "gitoa.ru/go-4devs/console" +) + +func TestFind(t *testing.T) { + cases := map[string]string{ + "fdevs:console:test": "fdevs:console:test", + "fd:c:t": "fdevs:console:test", + "fd::t": "fdevs:console:test", + "f:c:t": "fdevs:console:test", + "f:c:a": "fdevs:console:arg", + } + + for name, ex := range cases { + res, err := console.Find(name) + if err != nil { + t.Errorf("expect err, got:%s", err) + + continue + } + + if res.Name != ex { + t.Errorf("expect: %s, got: %s", ex, res) + } + } +} diff --git a/validator/enum.go b/validator/enum.go new file mode 100644 index 0000000..cf70d07 --- /dev/null +++ b/validator/enum.go @@ -0,0 +1,16 @@ +package validator + +import "gitoa.ru/go-4devs/console/input" + +func Enum(enum ...string) func(input.Value) error { + return func(in input.Value) error { + v := in.String() + for _, e := range enum { + if e == v { + return nil + } + } + + return NewError(ErrInvalid, v, enum) + } +} diff --git a/validator/enum_test.go b/validator/enum_test.go new file mode 100644 index 0000000..c889219 --- /dev/null +++ b/validator/enum_test.go @@ -0,0 +1,24 @@ +package validator_test + +import ( + "errors" + "testing" + + "gitoa.ru/go-4devs/console/input/value" + "gitoa.ru/go-4devs/console/validator" +) + +func TestEnum(t *testing.T) { + validValue := value.New("valid") + invalidValue := value.New("invalid") + + enum := validator.Enum("valid", "other", "three") + + if err := enum(validValue); err != nil { + t.Errorf("expected valid value got err:%s", err) + } + + if err := enum(invalidValue); !errors.Is(err, validator.ErrInvalid) { + t.Errorf("expected err:%s, got: %s", validator.ErrInvalid, err) + } +} diff --git a/validator/error.go b/validator/error.go new file mode 100644 index 0000000..da8b7c2 --- /dev/null +++ b/validator/error.go @@ -0,0 +1,37 @@ +package validator + +import ( + "errors" + "fmt" +) + +var ( + ErrInvalid = errors.New("invalid value") + ErrNotBlank = errors.New("not blank") +) + +func NewError(err error, value, expect interface{}) Error { + return Error{ + err: err, + value: value, + expect: expect, + } +} + +type Error struct { + err error + value interface{} + expect interface{} +} + +func (e Error) Error() string { + return fmt.Sprintf("%s: expext: %s, given: %s", e.err, e.expect, e.value) +} + +func (e Error) Is(err error) bool { + return errors.Is(e.err, err) +} + +func (e Error) Unwrap() error { + return e.err +} diff --git a/validator/not_blank.go b/validator/not_blank.go new file mode 100644 index 0000000..e9fbbd2 --- /dev/null +++ b/validator/not_blank.go @@ -0,0 +1,109 @@ +package validator + +import ( + "gitoa.ru/go-4devs/console/input" +) + +//nolint: gocyclo +func NotBlank(flag input.Flag) func(input.Value) error { + return func(in input.Value) error { + switch { + case flag.IsAny() && in.Any() != nil: + return nil + case flag.IsArray(): + return arrayNotBlank(flag, in) + case flag.IsInt() && in.Int() != 0: + return nil + case flag.IsInt64() && in.Int64() != 0: + return nil + case flag.IsUint() && in.Uint() != 0: + return nil + case flag.IsUint64() && in.Uint64() != 0: + return nil + case flag.IsFloat64() && in.Float64() != 0: + return nil + case flag.IsDuration() && in.Duration() != 0: + return nil + case flag.IsTime() && !in.Time().IsZero(): + return nil + case flag.IsString() && len(in.String()) > 0: + return nil + } + + return ErrNotBlank + } +} + +//nolint: gocyclo,gocognit +func arrayNotBlank(flag input.Flag, in input.Value) error { + switch { + case flag.IsInt() && len(in.Ints()) > 0: + for _, i := range in.Ints() { + if i == 0 { + return ErrNotBlank + } + } + + return nil + case flag.IsInt64() && len(in.Int64s()) > 0: + for _, i := range in.Int64s() { + if i == 0 { + return ErrNotBlank + } + } + + return nil + case flag.IsUint() && len(in.Uints()) > 0: + for _, u := range in.Uints() { + if u == 0 { + return ErrNotBlank + } + } + + return nil + case flag.IsUint64() && len(in.Uint64s()) > 0: + for _, u := range in.Uint64s() { + if u == 0 { + return ErrNotBlank + } + } + + return nil + case flag.IsFloat64() && len(in.Float64s()) > 0: + for _, f := range in.Float64s() { + if f == 0 { + return ErrNotBlank + } + } + + return nil + case flag.IsBool() && len(in.Bools()) > 0: + return nil + case flag.IsDuration() && len(in.Durations()) > 0: + for _, d := range in.Durations() { + if d == 0 { + return ErrNotBlank + } + } + + return nil + case flag.IsTime() && len(in.Times()) > 0: + for _, t := range in.Times() { + if t.IsZero() { + return ErrNotBlank + } + } + + return nil + case flag.IsString() && len(in.Strings()) > 0: + for _, st := range in.Strings() { + if len(st) == 0 { + return ErrNotBlank + } + } + + return nil + } + + return ErrNotBlank +} diff --git a/validator/not_blank_test.go b/validator/not_blank_test.go new file mode 100644 index 0000000..6352008 --- /dev/null +++ b/validator/not_blank_test.go @@ -0,0 +1,109 @@ +package validator_test + +import ( + "errors" + "testing" + "time" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/value" + "gitoa.ru/go-4devs/console/validator" +) + +func TestNotBlank(t *testing.T) { + cases := map[string]struct { + flag input.Flag + value input.Value + empty input.Value + }{ + "any": {flag: input.ValueAny, value: value.New(float32(1))}, + "array int": { + flag: input.ValueInt | input.ValueArray, + value: value.New([]int{1}), + empty: value.New([]int{10, 20, 0}), + }, + "array int64": { + flag: input.ValueInt64 | input.ValueArray, + value: value.New([]int64{1}), + empty: value.New([]int64{0}), + }, + "array uint": { + flag: input.ValueUint | input.ValueArray, + value: value.New([]uint{1}), + empty: value.New([]uint{1, 0}), + }, + "array uint64": { + flag: input.ValueUint64 | input.ValueArray, + value: value.New([]uint64{1}), + empty: value.New([]uint64{0}), + }, + "array float64": { + flag: input.ValueFloat64 | input.ValueArray, + value: value.New([]float64{0.2}), + empty: value.New([]float64{0}), + }, + "array bool": { + flag: input.ValueBool | input.ValueArray, + value: value.New([]bool{true, false}), + empty: value.New([]bool{}), + }, + "array duration": { + flag: input.ValueDuration | input.ValueArray, + value: value.New([]time.Duration{time.Second}), + empty: value.New([]time.Duration{time.Second, 0}), + }, + "array time": { + flag: input.ValueTime | input.ValueArray, + value: value.New([]time.Time{time.Now()}), + empty: value.New([]time.Time{{}, time.Now()}), + }, + "array string": { + flag: input.ValueArray, + value: value.New([]string{"value"}), + empty: value.New([]string{""}), + }, + "int": { + flag: input.ValueInt, + value: value.New(int(1)), + }, + "int64": { + flag: input.ValueInt64, + value: value.New(int64(2)), + }, + "uint": { + flag: input.ValueUint, + value: value.New(uint(1)), + empty: value.New([]uint{1}), + }, + "uint64": { + flag: input.ValueUint64, + value: value.New(uint64(10)), + }, + "float64": { + flag: input.ValueFloat64, + value: value.New(float64(.00001)), + }, + "duration": { + flag: input.ValueDuration, + value: value.New(time.Minute), + empty: value.New("same string"), + }, + "time": {flag: input.ValueTime, value: value.New(time.Now())}, + "string": {value: value.New("string"), empty: value.New("")}, + } + + for name, ca := range cases { + valid := validator.NotBlank(ca.flag) + if err := valid(ca.value); err != nil { + t.Errorf("case: %s, expected error , got: %s", name, err) + } + + if ca.empty == nil { + ca.empty = &value.Empty{} + } + + if err := valid(ca.empty); err == nil || !errors.Is(err, validator.ErrNotBlank) { + t.Errorf("case: %s, expect: %s, got:%s", name, validator.ErrNotBlank, err) + } + } +} diff --git a/validator/valid.go b/validator/valid.go new file mode 100644 index 0000000..cad205c --- /dev/null +++ b/validator/valid.go @@ -0,0 +1,15 @@ +package validator + +import "gitoa.ru/go-4devs/console/input" + +func Valid(v ...func(input.Value) error) func(input.Value) error { + return func(in input.Value) error { + for _, valid := range v { + if err := valid(in); err != nil { + return err + } + } + + return nil + } +} diff --git a/validator/valid_test.go b/validator/valid_test.go new file mode 100644 index 0000000..466c1ed --- /dev/null +++ b/validator/valid_test.go @@ -0,0 +1,28 @@ +package validator_test + +import ( + "errors" + "testing" + + "gitoa.ru/go-4devs/console/input" + "gitoa.ru/go-4devs/console/input/value" + "gitoa.ru/go-4devs/console/validator" +) + +func TestValid(t *testing.T) { + validValue := value.New("one") + invalidValue := value.New([]string{"one"}) + + valid := validator.Valid( + validator.NotBlank(input.ValueString), + validator.Enum("one", "two"), + ) + + if err := valid(validValue); err != nil { + t.Errorf("expected valid value, got: %s", err) + } + + if err := valid(invalidValue); !errors.Is(err, validator.ErrNotBlank) { + t.Errorf("expected not blank, got:%s", err) + } +}