diff --git a/Taskfile.yml b/Taskfile.yml index cc60cb636..5e467688e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -27,8 +27,13 @@ tasks: - go install -v ./cmd/task generate: + desc: Run all generation tasks + aliases: [g, gen] + deps: [generate:mocks, generate:fixtures] + + generate:mocks: desc: Runs Go generate to create mocks - aliases: [gen, g] + aliases: [g:mocks, gen:mocks] deps: [install:mockery] sources: - "internal/fingerprint/checker.go" @@ -38,6 +43,16 @@ tasks: - "{{.BIN}}/mockery --dir ./internal/fingerprint --name SourcesCheckable" - "{{.BIN}}/mockery --dir ./internal/fingerprint --name StatusCheckable" + generate:fixtures: + desc: Generates fixtures + aliases: [g:fixtures, gen:fixtures] + sources: + - "internal/completion/templates/*" + generates: + - "internal/completion/testdata/**/*.golden" + cmds: + - go test ./internal/completion -update + install:mockery: desc: Installs mockgen; a tool to generate mock files vars: diff --git a/cmd/task/task.go b/cmd/task/task.go index 09334c96a..f98895239 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -15,6 +15,7 @@ import ( "github.com/go-task/task/v3" "github.com/go-task/task/v3/args" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/completion" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/sort" ver "github.com/go-task/task/v3/internal/version" @@ -47,6 +48,7 @@ var flags struct { version bool help bool init bool + completion string list bool listAll bool listJson bool @@ -104,6 +106,7 @@ func run() error { pflag.BoolVar(&flags.version, "version", false, "Show Task version.") pflag.BoolVarP(&flags.help, "help", "h", false, "Shows Task usage.") pflag.BoolVarP(&flags.init, "init", "i", false, "Creates a new Taskfile.yml in the current folder.") + pflag.StringVar(&flags.completion, "completion", "", "Generates shell completion script.") pflag.BoolVarP(&flags.list, "list", "l", false, "Lists tasks with description of current Taskfile.") pflag.BoolVarP(&flags.listAll, "list-all", "a", false, "Lists tasks with or without a description.") pflag.BoolVarP(&flags.listJson, "json", "j", false, "Formats task list as JSON.") @@ -226,6 +229,15 @@ func run() error { return err } + if flags.completion != "" { + script, err := completion.Compile(flags.completion, e.Taskfile.Tasks) + if err != nil { + return err + } + fmt.Println(script) + return nil + } + if listOptions.ShouldListTasks() { foundTasks, err := e.ListTasks(listOptions) if err != nil { diff --git a/go.mod b/go.mod index ef7524c6b..037255241 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/radovskyb/watcher v1.0.7 github.com/sajari/fuzzy v1.0.0 + github.com/sebdah/goldie/v2 v2.5.3 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.2 golang.org/x/exp v0.0.0-20230212135524-a684f29349b6 @@ -24,6 +25,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sergi/go-diff v1.0.0 // indirect github.com/stretchr/objx v0.5.0 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/term v0.3.0 // indirect diff --git a/go.sum b/go.sum index ad6422953..f711de026 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,7 @@ github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM= github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= @@ -30,12 +31,17 @@ github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= +github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= +github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= diff --git a/internal/completion/completion.go b/internal/completion/completion.go new file mode 100644 index 000000000..851ed0a15 --- /dev/null +++ b/internal/completion/completion.go @@ -0,0 +1,71 @@ +package completion + +import ( + "bytes" + "embed" + "fmt" + "os" + "path/filepath" + "text/template" + + "github.com/spf13/pflag" + + "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/taskfile" +) + +type TemplateValues struct { + Entrypoint string + Flags []*pflag.Flag + Tasks []*taskfile.Task +} + +//go:embed templates/* +var templates embed.FS + +func Compile(completion string, tasks taskfile.Tasks) (string, error) { + // Get the file extension for the selected shell + var ext string + switch completion { + case "bash": + ext = "bash" + case "fish": + ext = "fish" + case "powershell": + ext = "ps1" + case "zsh": + ext = "zsh" + default: + return "", fmt.Errorf("unknown completion shell: %s", completion) + } + + // Load the template + templateName := fmt.Sprintf("task.tpl.%s", ext) + tpl, err := template.New(templateName). + Funcs(templater.TemplateFuncs). + ParseFS(templates, filepath.Join("templates", templateName)) + if err != nil { + return "", err + } + + values := TemplateValues{ + Entrypoint: os.Args[0], + Flags: getFlagNames(), + Tasks: tasks.Values(), + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, values); err != nil { + return "", err + } + + return buf.String(), nil +} + +func getFlagNames() []*pflag.Flag { + var flags []*pflag.Flag + pflag.VisitAll(func(flag *pflag.Flag) { + flags = append(flags, flag) + }) + return flags +} diff --git a/internal/completion/completion_test.go b/internal/completion/completion_test.go new file mode 100644 index 000000000..e4103b3a1 --- /dev/null +++ b/internal/completion/completion_test.go @@ -0,0 +1,72 @@ +package completion + +import ( + "os" + "testing" + + "github.com/sebdah/goldie/v2" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/internal/orderedmap" + "github.com/go-task/task/v3/taskfile" +) + +var ( + tasks = taskfile.Tasks{ + OrderedMap: orderedmap.FromMapWithOrder( + map[string]*taskfile.Task{ + "foo": { + Task: "foo", + Cmds: []*taskfile.Cmd{ + {Cmd: "echo foo"}, + }, + Desc: "Prints foo", + Aliases: []string{"f"}, + }, + "bar": { + Task: "bar", + Cmds: []*taskfile.Cmd{ + {Cmd: "echo bar"}, + }, + Desc: "Prints bar", + Aliases: []string{"b"}, + }, + }, + []string{"foo", "bar"}, + ), + } + flags struct { + foo string + bar int + noDesc bool + } +) + +func init() { + os.Args[0] = "task" + pflag.StringVarP(&flags.foo, "foo", "f", "default", "A regular flag") + pflag.IntVar(&flags.bar, "bar", 99, "A flag with no short variant") + pflag.BoolVar(&flags.noDesc, "no-desc", true, "") +} + +func TestCompile(t *testing.T) { + tests := []struct { + shell string + }{ + {shell: "bash"}, + {shell: "fish"}, + {shell: "powershell"}, + {shell: "zsh"}, + } + + for _, tt := range tests { + t.Run(tt.shell, func(t *testing.T) { + completion, err := Compile(tt.shell, tasks) + require.NoError(t, err) + + g := goldie.New(t, goldie.WithTestNameForDir(true)) + g.Assert(t, tt.shell, []byte(completion)) + }) + } +} diff --git a/completion/bash/task.bash b/internal/completion/templates/task.tpl.bash similarity index 100% rename from completion/bash/task.bash rename to internal/completion/templates/task.tpl.bash diff --git a/completion/fish/task.fish b/internal/completion/templates/task.tpl.fish similarity index 100% rename from completion/fish/task.fish rename to internal/completion/templates/task.tpl.fish diff --git a/completion/ps/task.ps1 b/internal/completion/templates/task.tpl.ps1 similarity index 100% rename from completion/ps/task.ps1 rename to internal/completion/templates/task.tpl.ps1 diff --git a/completion/zsh/_task b/internal/completion/templates/task.tpl.zsh similarity index 100% rename from completion/zsh/_task rename to internal/completion/templates/task.tpl.zsh diff --git a/internal/completion/testdata/TestCompile/bash.golden b/internal/completion/testdata/TestCompile/bash.golden new file mode 100644 index 000000000..de93e4c83 --- /dev/null +++ b/internal/completion/testdata/TestCompile/bash.golden @@ -0,0 +1,55 @@ +# vim: set tabstop=2 shiftwidth=2 expandtab: + +_GO_TASK_COMPLETION_LIST_OPTION='--list-all' + +function _task() +{ + local cur prev words cword + _init_completion -n : || return + + # Check for `--` within command-line and quit or strip suffix. + local i + for i in "${!words[@]}"; do + if [ "${words[$i]}" == "--" ]; then + # Do not complete words following `--` passed to CLI_ARGS. + [ $cword -gt $i ] && return + # Remove the words following `--` to not put --list in CLI_ARGS. + words=( "${words[@]:0:$i}" ) + break + fi + done + + # Handle special arguments of options. + case "$prev" in + -d|--dir) + _filedir -d + return $? + ;; + -t|--taskfile) + _filedir yaml || return $? + _filedir yml + return $? + ;; + -o|--output) + COMPREPLY=( $( compgen -W "interleaved group prefixed" -- $cur ) ) + return 0 + ;; + esac + + # Handle normal options. + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "$(_parse_help $1)" -- $cur ) ) + return 0 + ;; + esac + + # Prepare task name completions. + local tasks=( $( "${words[@]}" --silent $_GO_TASK_COMPLETION_LIST_OPTION 2> /dev/null ) ) + COMPREPLY=( $( compgen -W "${tasks[*]}" -- "$cur" ) ) + + # Post-process because task names might contain colons. + __ltrim_colon_completions "$cur" +} + +complete -F _task task diff --git a/internal/completion/testdata/TestCompile/fish.golden b/internal/completion/testdata/TestCompile/fish.golden new file mode 100644 index 000000000..ee2d0a516 --- /dev/null +++ b/internal/completion/testdata/TestCompile/fish.golden @@ -0,0 +1,37 @@ +set GO_TASK_PROGNAME task + +function __task_get_tasks --description "Prints all available tasks with their description" + # Read the list of tasks (and potential errors) + $GO_TASK_PROGNAME --list-all 2>&1 | read -lz rawOutput + + # Return on non-zero exit code (for cases when there is no Taskfile found or etc.) + if test $status -ne 0 + return + end + + # Grab names and descriptions (if any) of the tasks + set -l output (echo $rawOutput | sed -e '1d; s/\* \(.*\):\s*\(.*\)\s*(aliases.*/\1\t\2/' -e 's/\* \(.*\):\s*\(.*\)/\1\t\2/'| string split0) + if test $output + echo $output + end +end + +complete -c $GO_TASK_PROGNAME -d 'Runs the specified task(s). Falls back to the "default" task if no task name was specified, or lists all tasks if an unknown task name was +specified.' -xa "(__task_get_tasks)" + +complete -c $GO_TASK_PROGNAME -s c -l color -d 'colored output (default true)' +complete -c $GO_TASK_PROGNAME -s d -l dir -d 'sets directory of execution' +complete -c $GO_TASK_PROGNAME -l dry -d 'compiles and prints tasks in the order that they would be run, without executing them' +complete -c $GO_TASK_PROGNAME -s f -l force -d 'forces execution even when the task is up-to-date' +complete -c $GO_TASK_PROGNAME -s h -l help -d 'shows Task usage' +complete -c $GO_TASK_PROGNAME -s i -l init -d 'creates a new Taskfile.yml in the current folder' +complete -c $GO_TASK_PROGNAME -s l -l list -d 'lists tasks with description of current Taskfile' +complete -c $GO_TASK_PROGNAME -s o -l output -d 'sets output style: [interleaved|group|prefixed]' -xa "interleaved group prefixed" +complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'executes tasks provided on command line in parallel' +complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disables echoing' +complete -c $GO_TASK_PROGNAME -l status -d 'exits with non-zero exit code if any of the given tasks is not up-to-date' +complete -c $GO_TASK_PROGNAME -l summary -d 'show summary about a task' +complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose which Taskfile to run. Defaults to "Taskfile.yml"' +complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'enables verbose mode' +complete -c $GO_TASK_PROGNAME -l version -d 'show Task version' +complete -c $GO_TASK_PROGNAME -s w -l watch -d 'enables watch of the given task' diff --git a/internal/completion/testdata/TestCompile/powershell.golden b/internal/completion/testdata/TestCompile/powershell.golden new file mode 100644 index 000000000..eba7631c3 --- /dev/null +++ b/internal/completion/testdata/TestCompile/powershell.golden @@ -0,0 +1,8 @@ +$scriptBlock = { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) + $reg = "\* ($commandName.+?):" + $listOutput = $(task --list-all) + $listOutput | Select-String $reg -AllMatches | ForEach-Object { $_.Matches.Groups[1].Value } +} + +Register-ArgumentCompleter -CommandName task -ScriptBlock $scriptBlock diff --git a/internal/completion/testdata/TestCompile/zsh.golden b/internal/completion/testdata/TestCompile/zsh.golden new file mode 100644 index 000000000..afe8f4926 --- /dev/null +++ b/internal/completion/testdata/TestCompile/zsh.golden @@ -0,0 +1,62 @@ +#compdef task + +local context state state_descr line +typeset -A opt_args + +_GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}" + +# Listing commands from Taskfile.yml +function __task_list() { + local -a scripts cmd + local -i enabled=0 + local taskfile item task desc + + cmd=(task) + taskfile="${(v)opt_args[(i)-t|--taskfile]}" + + if [[ -n "$taskfile" && -f "$taskfile" ]]; then + enabled=1 + cmd+=(--taskfile "$taskfile") + else + for taskfile in Taskfile{,.dist}.{yaml,yml}; do + if [[ -f "$taskfile" ]]; then + enabled=1 + break + fi + done + fi + + (( enabled )) || return 0 + + scripts=() + for item in "${(@)${(f)$("${cmd[@]}" $_GO_TASK_COMPLETION_LIST_OPTION)}[2,-1]#\* }"; do + task="${item%%:[[:space:]]*}" + desc="${item##[^[:space:]]##[[:space:]]##}" + scripts+=( "${task//:/\\:}:$desc" ) + done + _describe 'Task to run' scripts +} + +_arguments \ + '(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: ' \ + '(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]' \ + '(-f --force)'{-f,--force}'[run even if task is up-to-date]' \ + '(-c --color)'{-c,--color}'[colored output]' \ + '(-d --dir)'{-d,--dir}'[dir to run in]:execution dir:_dirs' \ + '(--dry)--dry[dry-run mode, compile and print tasks only]' \ + '(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)' \ + '(--output-group-begin)--output-group-begin[message template before grouped output]:template text: ' \ + '(--output-group-end)--output-group-end[message template after grouped output]:template text: ' \ + '(-s --silent)'{-s,--silent}'[disable echoing]' \ + '(--status)--status[exit non-zero if supplied tasks not up-to-date]' \ + '(--summary)--summary[show summary\: field from tasks instead of running them]' \ + '(-t --taskfile)'{-t,--taskfile}'[specify a different taskfile]:taskfile:_files' \ + '(-v --verbose)'{-v,--verbose}'[verbose mode]' \ + '(-w --watch)'{-w,--watch}'[watch-mode for given tasks, re-run when inputs change]' \ + + '(operation)' \ + {-l,--list}'[list describable tasks]' \ + {-a,--list-all}'[list all tasks]' \ + {-i,--init}'[create new Taskfile.yml]' \ + '(-*)'{-h,--help}'[show help]' \ + '(-*)--version[show version and exit]' \ + '*: :__task_list' diff --git a/internal/templater/funcs.go b/internal/templater/funcs.go index 21eaf8fc5..c899abeff 100644 --- a/internal/templater/funcs.go +++ b/internal/templater/funcs.go @@ -6,12 +6,13 @@ import ( "strings" "text/template" - sprig "github.com/go-task/slim-sprig" "mvdan.cc/sh/v3/shell" "mvdan.cc/sh/v3/syntax" + + sprig "github.com/go-task/slim-sprig" ) -var templateFuncs template.FuncMap +var TemplateFuncs template.FuncMap func init() { taskFuncs := template.FuncMap{ @@ -51,8 +52,8 @@ func init() { taskFuncs["ToSlash"] = taskFuncs["toSlash"] taskFuncs["ExeExt"] = taskFuncs["exeExt"] - templateFuncs = sprig.TxtFuncMap() + TemplateFuncs = sprig.TxtFuncMap() for k, v := range taskFuncs { - templateFuncs[k] = v + TemplateFuncs[k] = v } } diff --git a/internal/templater/templater.go b/internal/templater/templater.go index 46ff5eb04..a6650f0f6 100644 --- a/internal/templater/templater.go +++ b/internal/templater/templater.go @@ -29,7 +29,7 @@ func (r *Templater) Replace(str string) string { return "" } - templ, err := template.New("").Funcs(templateFuncs).Parse(str) + templ, err := template.New("").Funcs(TemplateFuncs).Parse(str) if err != nil { r.err = err return ""