From 7123217e5870d21fb87f858c2d087803bcb28694 Mon Sep 17 00:00:00 2001 From: Evan Fiordeliso Date: Sun, 12 Nov 2023 23:54:52 -0500 Subject: [PATCH] Add semi-working implementation of fishshell completions --- cmd/example-cmd.go | 39 +++++++++++++++++++++ command.go | 40 +++++++++++---------- completions_fish.go | 60 ++++++++++++++++++++++++++++++-- examples/hello-world/cmd/main.go | 1 + examples/hello-world/go.mod | 2 +- examples/hello-world/go.sum | 8 ++--- help.go | 27 +++++++------- option.go | 24 ++++++------- set.go | 6 ++-- tpl_funcs.go | 28 +++++++++++++++ 10 files changed, 179 insertions(+), 56 deletions(-) create mode 100644 cmd/example-cmd.go create mode 100644 tpl_funcs.go 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_fish.go b/completions_fish.go index e2cb825..09eff7a 100644 --- a/completions_fish.go +++ b/completions_fish.go @@ -9,11 +9,65 @@ import ( func WriteFishCompletions(out io.Writer, rootCmd *Command) error { return fishTpl.Execute(out, map[string]any{ - "rootCmd": rootCmd, - "globalOpts": opt.Globals(), + "RootCmd": rootCmd, + "GlobalOpts": opt.Globals(), }) } -var fishTpl = template.Must(template.New("fish").Parse(` +var fishTpl = template.Must(template.New("fish").Funcs(tplFuncs).Parse(` +set -l progName {{ .RootCmd.Name }} +set -l commands {{- range .RootCmd.Subcommands }} {{ .Name }}{{ end }} +{{- /* Option template */ -}} +{{ define "opt" }} +complete -c $progName +{{- if ne .Opt.ShortName "" }} -s {{ .Opt.ShortName }} {{ end -}} +{{- if ne .Opt.Name "" }} -l {{ .Opt.Name }} {{ end -}} +{{- if .Cond }} -n "{{ .Cond }}" {{ end -}} +-d '{{ .Opt.Description }}' +{{- end }} + +{{- /* Command template */ -}} +{{ define "cmd" }} +{{ $parentVarPrefix := "" -}} +{{- $varPrefix := join .Cmd.Name "_" -}} +{{- if .VarPrefix -}} +{{- $varPrefix = join .VarPrefix $varPrefix -}} +{{- $parentVarPrefix = .VarPrefix -}} +{{- end -}} + +{{ $parentCond := "" }} +{{- $cond := join "__fish_seen_subcommand_from " .Cmd.Name }} +{{- if .Prefix -}} +{{- $cond = join .Prefix $cond -}} +{{- $parentCond = .Prefix -}} +{{- end -}} + +set -l {{ $varPrefix }}commands {{- range .Cmd.Subcommands }} {{ .Name }}{{ end }} +complete -f -c $progName -n "{{ $parentCond }}not __fish_seen_subcommand_from ${{ $parentVarPrefix }}commands" -a {{ .Cmd.Name }} -d "{{ .Cmd.ShortDescription }}" + +{{- range .Cmd.Opts }} +{{- template "opt" (map "Opt" . "Cond" $cond) -}} +{{ end -}} + +{{ $cmdName := .Cmd.Name }} +{{- range .Cmd.Subcommands }} +{{- template "cmd" (map "Cmd" . "Prefix" (join $cond "; ") "VarPrefix" $varPrefix) -}} +{{ end -}} +{{ end }} + +{{- /* Top-level commands */ -}} +{{ range .RootCmd.Subcommands }} +{{- template "cmd" (map "Cmd" .) -}} +{{ end }} + +{{- /* Root command options */ -}} +{{ range .RootCmd.Opts }} +{{- template "opt" (map "Opt" . "Cond" "not __fish_seen_subcommand_from $commands") -}} +{{ end }} + +{{- /* Global options */ -}} +{{ range .GlobalOpts }} +{{- template "opt" (map "Opt" .) -}} +{{ end }} `)) 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/examples/hello-world/go.mod b/examples/hello-world/go.mod index 896927e..8ef0058 100644 --- a/examples/hello-world/go.mod +++ b/examples/hello-world/go.mod @@ -2,4 +2,4 @@ module go.fifitido.net/cmd/examples/hello-world go 1.21.3 -require go.fifitido.net/cmd v0.0.0-20231110055906-31e40ecc826a +require go.fifitido.net/cmd v0.2.0 diff --git a/examples/hello-world/go.sum b/examples/hello-world/go.sum index 98fb57a..2671f9e 100644 --- a/examples/hello-world/go.sum +++ b/examples/hello-world/go.sum @@ -1,6 +1,2 @@ -go.fifitido.net/cmd v0.0.0-20231110043437-c32ce2efcd5f h1:v+sjO+t6cGEtDhStOnuypYBrkOMO4suiDVxL93rOUCs= -go.fifitido.net/cmd v0.0.0-20231110043437-c32ce2efcd5f/go.mod h1:8SaDxCG1m6WwShUlZApSbNleCvV7oTfqZIyYu4aAqO0= -go.fifitido.net/cmd v0.0.0-20231110054944-def39983fdfa h1:Z62sZG2rnKMqa0jI+WD1t8vO9ESQsQa4j6ad9OFeREM= -go.fifitido.net/cmd v0.0.0-20231110054944-def39983fdfa/go.mod h1:8SaDxCG1m6WwShUlZApSbNleCvV7oTfqZIyYu4aAqO0= -go.fifitido.net/cmd v0.0.0-20231110055906-31e40ecc826a h1:qT/xBA3vcVJYgaPoei70yRlLoB1CdaL3FPzi0kZ+JF0= -go.fifitido.net/cmd v0.0.0-20231110055906-31e40ecc826a/go.mod h1:8SaDxCG1m6WwShUlZApSbNleCvV7oTfqZIyYu4aAqO0= +go.fifitido.net/cmd v0.2.0 h1:QEq4Gbts4DjnzXLwxV4xaSI3kleVXmtvSFcuOGjBGqc= +go.fifitido.net/cmd v0.2.0/go.mod h1:8SaDxCG1m6WwShUlZApSbNleCvV7oTfqZIyYu4aAqO0= 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..ae08936 --- /dev/null +++ b/tpl_funcs.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + "strings" + "text/template" +) + +var tplFuncs = template.FuncMap{ + "map": tplMap, + "join": tplJoin, +} + +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, "") +}