diff --git a/cmd/example-cmd.go b/cmd/example-cmd.go new file mode 100644 index 0000000..139ae26 --- /dev/null +++ b/cmd/example-cmd.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "os" + + "go.fifitido.net/cmd" + "go.fifitido.net/cmd/opt" +) + +var ( + so = opt.String("string", "s", "", "example string option") + io = opt.Int("int", "i", 0, "example int option") + fo = opt.Float("float", "f", 0, "example float option") + bo = opt.Bool("bool", "b", false, "example bool option") +) + +var root = cmd.NewRoot( + "example-cmd", + cmd.WithShortDescription("Example command"), + cmd.WithLongDescription(`An example command to show how to use go.fifitido.net/cmd + +this example is just a simple hello world program to show +the basics of the library.`), + cmd.WithArgument("name", false), + cmd.WithSubcommand(cmd.CompletionsSubcommand()), + cmd.WithOptions(so, io, fo, bo), + cmd.WithRunFunc(func(args []string) { + if len(args) == 0 { + fmt.Println("Hello World!") + } else { + fmt.Printf("Hello %s!\n", args[0]) + } + }), +) + +func main() { + root.Execute(os.Args) +} diff --git a/command.go b/command.go index 9741938..5112b00 100644 --- a/command.go +++ b/command.go @@ -3,32 +3,31 @@ package cmd import ( "fmt" "os" - "path/filepath" "go.fifitido.net/cmd/opt" ) type Command struct { - name string - shortDescription string - longDescription string - aliases []string - arguments []*Argument - opts opt.Set - subcommands Set - parent *Command + Name string + ShortDescription string + LongDescription string + Aliases []string + Args []*Argument + Opts opt.Set + Subcommands Set + Parent *Command run func(args []string) isRoot bool } -func NewRoot(options ...Option) *Command { - cmd := &Command{isRoot: true} +func NewRoot(name string, options ...Option) *Command { + cmd := &Command{Name: name, isRoot: true} cmd.ApplyOptions(options...) return cmd } func New(name string, options ...Option) *Command { - cmd := &Command{name: name} + cmd := &Command{Name: name} cmd.ApplyOptions(options...) return cmd } @@ -44,15 +43,20 @@ func (c *Command) Root() *Command { return c } - return c.parent.Root() + return c.Parent.Root() } func (c *Command) CommandPath() string { - if c.parent == nil { - return filepath.Base(os.Args[0]) + if c.Parent == nil { + return "" } - return c.parent.CommandPath() + " " + c.name + parentPath := c.Parent.CommandPath() + if parentPath == "" { + return c.Name + } + + return c.Parent.CommandPath() + " " + c.Name } func (c *Command) CanRun() bool { @@ -68,7 +72,7 @@ func (c *Command) Execute(args []string) { args = args[1:] } - parser := opt.NewParser(args, c.opts, false) + parser := opt.NewParser(args, c.Opts, false) restArgs, err := parser.Parse() if err != nil { fmt.Println(err.Error()) @@ -77,7 +81,7 @@ func (c *Command) Execute(args []string) { } if len(restArgs) > 0 { - sc, ok := c.subcommands.Get(restArgs[0]) + sc, ok := c.Subcommands.Get(restArgs[0]) if ok { sc.Execute(restArgs[1:]) return diff --git a/completions.go b/completions.go new file mode 100644 index 0000000..0009e71 --- /dev/null +++ b/completions.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "io" + "os" + + "go.fifitido.net/cmd/opt" +) + +var outputFileOption = opt.String("output", "o", "", "The file to output the completions to") + +func CompletionsSubcommand() *Command { + cmd := New( + "completions", + WithShortDescription("Generate shell completions"), + WithLongDescription("Generate shell completions"), + ) + + registerBashCompletions(cmd) + registerFishCompletions(cmd) + registerPowerShellCompletions(cmd) + registerZsgCompletions(cmd) + + return cmd +} + +func getCompletionsOut() io.Writer { + outputFile := outputFileOption.Value() + + if outputFile != "" { + var err error + out, err := os.OpenFile(outputFile, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + panic(err) + } + return out + } + + return os.Stdout +} + +func registerBashCompletions(parent *Command) *Command { + return New( + "bash", + WithShortDescription("Generate bash completions"), + WithLongDescription("Generate bash completions"), + WithOptions(outputFileOption), + WithParent(parent), + WithRunFunc(func(args []string) { + out := getCompletionsOut() + + if fc, ok := out.(io.Closer); ok { + defer fc.Close() + } + + if err := WriteBashCompletions(out, parent.Root()); err != nil { + panic(err) + } + }), + ) +} + +func registerFishCompletions(parent *Command) *Command { + return New( + "fish", + WithShortDescription("Generate fish completions"), + WithLongDescription("Generate fish completions"), + WithOptions(outputFileOption), + WithParent(parent), + WithRunFunc(func(args []string) { + out := getCompletionsOut() + + if fc, ok := out.(io.Closer); ok { + defer fc.Close() + } + + if err := WriteFishCompletions(out, parent.Root()); err != nil { + panic(err) + } + }), + ) +} + +func registerPowerShellCompletions(parent *Command) *Command { + return New( + "powershell", + WithShortDescription("Generate powershell completions"), + WithLongDescription("Generate powershell completions"), + WithOptions(outputFileOption), + WithParent(parent), + WithRunFunc(func(args []string) { + out := getCompletionsOut() + + if fc, ok := out.(io.Closer); ok { + defer fc.Close() + } + + if err := WritePowerShellCompletions(out, parent.Root()); err != nil { + panic(err) + } + }), + ) +} + +func registerZsgCompletions(parent *Command) *Command { + return New( + "zsh", + WithShortDescription("Generate zsh completions"), + WithLongDescription("Generate zsh completions"), + WithOptions(outputFileOption), + WithParent(parent), + WithRunFunc(func(args []string) { + out := getCompletionsOut() + + if fc, ok := out.(io.Closer); ok { + defer fc.Close() + } + + if err := WriteZshCompletions(out, parent.Root()); err != nil { + panic(err) + } + }), + ) +} diff --git a/completions_bash.go b/completions_bash.go new file mode 100644 index 0000000..a790090 --- /dev/null +++ b/completions_bash.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "io" + "text/template" + + "go.fifitido.net/cmd/opt" +) + +func WriteBashCompletions(out io.Writer, rootCmd *Command) error { + return bashTpl.Execute(out, map[string]any{ + "rootCmd": rootCmd, + "globalOpts": opt.Globals(), + }) +} + +var bashTpl = template.Must(template.New("bash").Parse(` + +`)) diff --git a/completions_fish.go b/completions_fish.go new file mode 100644 index 0000000..8e0228d --- /dev/null +++ b/completions_fish.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "io" + "text/template" + + "go.fifitido.net/cmd/opt" +) + +func WriteFishCompletions(out io.Writer, rootCmd *Command) error { + return fishTpl.Execute(out, map[string]any{ + "RootCmd": rootCmd, + "GlobalOpts": opt.Globals(), + }) +} + +var fishTpl = template.Must(template.New("fish").Funcs(tplFuncs).Parse(` +{{- $rootCmd := .RootCmd -}} +{{- $progName := $rootCmd.Name -}} +{{- $varName := under $rootCmd.Name -}} +set -l commands {{- range $rootCmd.Subcommands }} {{ .Name }}{{ end }} + +function __fish_{{ $varName }}_needs_command + set -l cmd (commandline -opc) + if test (count $cmd) -eq 1 + return 0 + end + return 1 +end + +function __fish_{{ $varName }}_using_command + set -l cmd (commandline -opc) + set -l cnt (count $argv) + if test (count $cmd) -gt $cnt + for i in (seq 1 $cnt); + if test $argv[$i] != $cmd[(math $i + 1)] + return 1 + end + end + return 0 + end + return 1 +end + +{{/* Option template */ -}} +{{ define "opt" }} +{{ $progName := .ProgName -}} +{{- $varName := .VarName -}} +complete -c {{ $progName }} +{{- if ne .Opt.ShortName "" }} -s {{ .Opt.ShortName }} {{ end -}} +{{- if ne .Opt.Name "" }} -l {{ .Opt.Name }} {{ end -}} +{{- if .Cmd }} {{- if ne .Cmd.CommandPath "" -}} +-n "__fish_{{ $varName }}_using_command {{.Cmd.CommandPath}}" {{ else -}} +-n "__fish_{{ $varName }}_needs_command" {{ end -}}{{- end -}} +-d '{{ .Opt.Description }}' +{{- end }} + +{{- /* Command template */ -}} +{{ define "cmd" }} +{{ $progName := .ProgName -}} +{{- $varName := .VarName -}} +{{- $cmd := .Cmd -}} +{{- $parentVarPrefix := varPrefix .Cmd.Parent.CommandPath -}} +{{- $varPrefix := varPrefix .Cmd.CommandPath -}} + +{{- if eq .Cmd.Parent.CommandPath "" -}} +complete -f -c {{ $progName }} -n "__fish_{{ $varName }}_needs_command" -a {{ .Cmd.Name }} -d "{{ .Cmd.ShortDescription }}" +{{- else -}} +complete -f -c {{ $progName }} -n "__fish_{{ $varName }}_using_command {{.Cmd.Parent.CommandPath}}" -a {{ .Cmd.Name }} -d "{{ .Cmd.ShortDescription }}" +{{- end -}} + +{{- range .Cmd.Opts }} +{{- template "opt" (map "Opt" . "ProgName" $progName "VarName" $varName "Cmd" $cmd) -}} +{{ end -}} + +{{ if gt (len .Cmd.Subcommands) 0 }} +set -l {{ $varPrefix }}commands {{- range .Cmd.Subcommands }} {{ .Name }}{{ end -}} +{{ $cmdName := .Cmd.Name }} +{{- range .Cmd.Subcommands }} +{{- template "cmd" (map "Cmd" . "ProgName" $progName "VarName" $varName) -}} +{{ end -}} +{{- end -}} +{{ end }} + +{{- /* Top-level commands */ -}} +{{ range $rootCmd.Subcommands }} +{{- template "cmd" (map "Cmd" . "ProgName" $progName "VarName" $varName) -}} +{{ end }} + +{{- /* Root command options */ -}} +{{ range $rootCmd.Opts }} +{{- template "opt" (map "Opt" . "ProgName" $progName "VarName" $varName "Cmd" $rootCmd) -}} +{{ end }} + +{{- /* Global options */ -}} +{{ range .GlobalOpts }} +{{- template "opt" (map "Opt" . "ProgName" $progName "VarName" $varName) -}} +{{ end }} +`)) diff --git a/completions_powershell.go b/completions_powershell.go new file mode 100644 index 0000000..a0f271a --- /dev/null +++ b/completions_powershell.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "io" + "text/template" + + "go.fifitido.net/cmd/opt" +) + +func WritePowerShellCompletions(out io.Writer, rootCmd *Command) error { + return powerShellTpl.Execute(out, map[string]any{ + "rootCmd": rootCmd, + "globalOpts": opt.Globals(), + }) +} + +var powerShellTpl = template.Must(template.New("PowerShell").Parse(` + +`)) diff --git a/completions_zsh.go b/completions_zsh.go new file mode 100644 index 0000000..885ec41 --- /dev/null +++ b/completions_zsh.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "io" + "text/template" + + "go.fifitido.net/cmd/opt" +) + +func WriteZshCompletions(out io.Writer, rootCmd *Command) error { + return zshTpl.Execute(out, map[string]any{ + "rootCmd": rootCmd, + "globalOpts": opt.Globals(), + }) +} + +var zshTpl = template.Must(template.New("zsh").Parse(` + +`)) diff --git a/examples/hello-world/cmd/main.go b/examples/hello-world/cmd/main.go index 57064a5..b742cec 100644 --- a/examples/hello-world/cmd/main.go +++ b/examples/hello-world/cmd/main.go @@ -8,6 +8,7 @@ import ( ) var root = cmd.NewRoot( + "hello-world", cmd.WithShortDescription("Example command"), cmd.WithLongDescription(`An example command to show how to use go.fifitido.net/cmd diff --git a/help.go b/help.go index 18819bf..62c4496 100644 --- a/help.go +++ b/help.go @@ -8,13 +8,14 @@ import ( func (c *Command) ShowHelp() { cmdPath := c.CommandPath() + binaryName := c.Root().Name - fmt.Println(c.longDescription) + fmt.Println(c.LongDescription) fmt.Println() fmt.Println("Usage: ") - fmt.Printf(" %s ", cmdPath) + fmt.Printf(" %s %s ", binaryName, cmdPath) - if len(c.subcommands) > 0 { + if len(c.Subcommands) > 0 { if c.CanRun() { fmt.Print("[command] ") } else { @@ -22,8 +23,8 @@ func (c *Command) ShowHelp() { } } fmt.Print("[options]") - if len(c.arguments) > 0 { - for _, a := range c.arguments { + if len(c.Args) > 0 { + for _, a := range c.Args { if a.Required { fmt.Print(" <" + a.Name + ">") } else { @@ -33,7 +34,7 @@ func (c *Command) ShowHelp() { } fmt.Println() - if len(c.subcommands) > 0 { + if len(c.Subcommands) > 0 { fmt.Println() if c.isRoot { @@ -42,16 +43,16 @@ func (c *Command) ShowHelp() { fmt.Println("Available subcommands:") } - for _, s := range c.subcommands { - fmt.Println(" " + s.name + " " + s.shortDescription) + for _, s := range c.Subcommands { + fmt.Println(" " + s.Name + " " + s.ShortDescription) } } - if len(c.opts) > 0 { + if len(c.Opts) > 0 { fmt.Println() fmt.Println("Available options:") - paddedWidth := c.opts.MaxWidth() - for _, f := range c.opts { + paddedWidth := c.Opts.MaxWidth() + for _, f := range c.Opts { fmt.Println(" " + opt.HelpLine(f, paddedWidth)) } } @@ -66,8 +67,8 @@ func (c *Command) ShowHelp() { } } - if len(c.subcommands) > 0 { + if len(c.Subcommands) > 0 { fmt.Println() - fmt.Printf("Run '%s --help' for more information about a command.\n", c.Root().CommandPath()) + fmt.Printf("Run '%s --help' for more information about a command.\n", binaryName) } } diff --git a/option.go b/option.go index 6f0db66..90a057d 100644 --- a/option.go +++ b/option.go @@ -8,60 +8,60 @@ type Option func(*Command) func WithShortDescription(s string) Option { return func(c *Command) { - c.shortDescription = s + c.ShortDescription = s } } func WithLongDescription(s string) Option { return func(c *Command) { - c.longDescription = s + c.LongDescription = s } } func WithArgument(name string, required bool) Option { return func(c *Command) { - c.arguments = append(c.arguments, &Argument{name, required}) + c.Args = append(c.Args, &Argument{name, required}) } } func WithArguments(args ...*Argument) Option { return func(c *Command) { - c.arguments = append(c.arguments, args...) + c.Args = append(c.Args, args...) } } func WithOption(f opt.Option) Option { return func(c *Command) { - c.opts = append(c.opts, f) + c.Opts = append(c.Opts, f) } } func WithOptions(os ...opt.Option) Option { return func(c *Command) { - c.opts = append(c.opts, os...) + c.Opts = append(c.Opts, os...) } } func WithSubcommand(s *Command) Option { return func(c *Command) { - c.subcommands.Add(c) - s.parent = c + c.Subcommands.Add(s) + s.Parent = c } } func WithSubcommands(ss ...*Command) Option { return func(c *Command) { - c.subcommands.Add(c) for _, s := range ss { - s.parent = c + c.Subcommands.Add(s) + s.Parent = c } } } func WithParent(p *Command) Option { return func(c *Command) { - c.parent = p - p.subcommands.Add(c) + c.Parent = p + p.Subcommands.Add(c) } } diff --git a/set.go b/set.go index 58110a2..2ef3976 100644 --- a/set.go +++ b/set.go @@ -12,11 +12,11 @@ func (s *Set) Add(c *Command) { func (s Set) Get(name string) (*Command, bool) { for _, c := range s { - if c.name == name { + if c.Name == name { return c, true } - for _, alias := range c.aliases { + for _, alias := range c.Aliases { if alias == name { return c, true } @@ -28,7 +28,7 @@ func (s Set) Get(name string) (*Command, bool) { func (s Set) MaxNameWidth() int { max := 0 for _, f := range s { - if w := len(f.name); w > max { + if w := len(f.Name); w > max { max = w } } diff --git a/tpl_funcs.go b/tpl_funcs.go new file mode 100644 index 0000000..dd41bfa --- /dev/null +++ b/tpl_funcs.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + "strings" + "text/template" +) + +var tplFuncs = template.FuncMap{ + "map": tplMap, + "join": tplJoin, + "under": tplUnder, + "varPrefix": tplVarPrefix, +} + +func tplMap(vals ...any) (map[string]any, error) { + if len(vals)%2 != 0 { + return nil, fmt.Errorf("missing value, need one key and one value per kv pair") + } + + m := make(map[string]any) + for i := 0; i < len(vals); i += 2 { + m[vals[i].(string)] = vals[i+1] + } + return m, nil +} + +func tplJoin(strs ...string) string { + return strings.Join(strs, "") +} + +func tplUnder(s string) string { + return strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(s, " ", "_"), "-", "_")) +} + +func tplVarPrefix(s string) string { + if s == "" { + return "" + } + + return tplUnder(s) + "_" +}