From db7d6131f4737bc26e88fb6de236ecdb22ae5c96 Mon Sep 17 00:00:00 2001 From: Tugdual Saunier Date: Sat, 15 Jun 2024 14:30:39 +0200 Subject: [PATCH 1/2] Natively autocomplete `composer` and `console` --- commands/completion.go | 19 ------- commands/completion_others.go | 17 +++++++ commands/completion_posix.go | 82 ++++++++++++++++++++++++++++++ commands/resources/completion.bash | 57 ++++++++++++++++----- commands/resources/completion.fish | 13 +---- commands/resources/completion.zsh | 22 +++----- commands/wrappers.go | 4 +- go.mod | 4 +- go.sum | 2 + local/php/symfony.go | 16 ++++++ main.go | 11 +--- 11 files changed, 177 insertions(+), 70 deletions(-) delete mode 100644 commands/completion.go create mode 100644 commands/completion_others.go create mode 100644 commands/completion_posix.go create mode 100644 local/php/symfony.go diff --git a/commands/completion.go b/commands/completion.go deleted file mode 100644 index 8ece9a9e..00000000 --- a/commands/completion.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build darwin || linux || freebsd || openbsd - -package commands - -import ( - "embed" - - "github.com/symfony-cli/console" -) - -// completionTemplates holds our custom shell completions templates. -// -//go:embed resources/completion.* -var completionTemplates embed.FS - -func init() { - // override console completion templates with our custom ones - console.CompletionTemplates = completionTemplates -} diff --git a/commands/completion_others.go b/commands/completion_others.go new file mode 100644 index 00000000..3224f2e2 --- /dev/null +++ b/commands/completion_others.go @@ -0,0 +1,17 @@ +//go:build !darwin && !linux && !freebsd && !openbsd +// +build !darwin,!linux,!freebsd,!openbsd + +package commands + +import ( + "github.com/posener/complete" + "github.com/symfony-cli/console" +) + +func autocompleteComposerWrapper(context *console.Context, args complete.Args) []string { + return []string{} +} + +func autocompleteSymfonyConsoleWrapper(context *console.Context, args complete.Args) []string { + return []string{} +} diff --git a/commands/completion_posix.go b/commands/completion_posix.go new file mode 100644 index 00000000..a4579730 --- /dev/null +++ b/commands/completion_posix.go @@ -0,0 +1,82 @@ +//go:build darwin || linux || freebsd || openbsd + +package commands + +import ( + "embed" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/posener/complete" + "github.com/symfony-cli/console" + "github.com/symfony-cli/symfony-cli/local/php" + "github.com/symfony-cli/terminal" +) + +// completionTemplates holds our custom shell completions templates. +// +//go:embed resources/completion.* +var completionTemplates embed.FS + +func init() { + // override console completion templates with our custom ones + console.CompletionTemplates = completionTemplates +} + +func autocompleteSymfonyConsoleWrapper(context *console.Context, words complete.Args) []string { + args := buildSymfonyAutocompleteArgs("console", words) + // Composer does not support those options yet, so we only use them for Symfony Console + args = append(args, "-a1", fmt.Sprintf("-s%s", console.GuessShell())) + + os.Exit(php.SymonyConsoleExecutor(args).Execute(false)) + + // unreachable + return []string{} +} + +func autocompleteComposerWrapper(context *console.Context, words complete.Args) []string { + args := buildSymfonyAutocompleteArgs("composer", words) + // Composer does not support multiple shell yet, so we only use the default one + args = append(args, "-sbash") + + res := php.Composer("", args, []string{}, context.App.Writer, context.App.ErrWriter, io.Discard, terminal.Logger) + os.Exit(res.ExitCode()) + + // unreachable + return []string{} +} + +func buildSymfonyAutocompleteArgs(wrappedCommand string, words complete.Args) []string { + current, err := strconv.Atoi(os.Getenv("CURRENT")) + if err != nil { + current = 1 + } else { + // we decrease one position corresponding to `symfony` command + current -= 1 + } + + args := make([]string, 0, len(words.All)) + // build the inputs command line that Symfony expects + for _, input := range words.All { + if input = strings.TrimSpace(input); input != "" { + + // remove quotes from typed values + quote := input[0] + if quote == '\'' || quote == '"' { + input = strings.TrimPrefix(input, string(quote)) + input = strings.TrimSuffix(input, string(quote)) + } + + args = append(args, fmt.Sprintf("-i%s", input)) + } + } + + return append([]string{ + "_complete", "--no-interaction", + fmt.Sprintf("-c%d", current), + fmt.Sprintf("-i%s", wrappedCommand), + }, args...) +} diff --git a/commands/resources/completion.bash b/commands/resources/completion.bash index 61a71d57..52a45e96 100644 --- a/commands/resources/completion.bash +++ b/commands/resources/completion.bash @@ -23,14 +23,6 @@ # - https://github.com/scop/bash-completion/blob/master/completions/sudo # -# this wrapper function allows us to let Symfony knows how to call the -# `bin/console` using the Symfony CLI binary (to ensure the right env and PHP -# versions are used) -_{{ .App.HelpName }}_console() { - # shellcheck disable=SC2068 - {{ .CurrentBinaryInvocation }} console $@ -} - _complete_{{ .App.HelpName }}() { # Use the default completion for shell redirect operators. @@ -45,11 +37,7 @@ _complete_{{ .App.HelpName }}() { for (( i=1; i <= COMP_CWORD; i++ )); do if [[ "${COMP_WORDS[i]}" != -* ]]; then case "${COMP_WORDS[i]}" in - console) - _SF_CMD="_{{ .App.HelpName }}_console" _command_offset $i - return - ;; - composer{{range $name := (.App.Command "php").Names }}|{{$name}}{{end}}{{range $name := (.App.Command "run").Names }}|{{$name}}{{end}}) + {{range $i, $name := (.App.Command "php").Names }}{{if $i}}|{{end}}{{$name}}{{end}}{{range $name := (.App.Command "run").Names }}|{{$name}}{{end}}) _command_offset $i return ;; @@ -57,7 +45,48 @@ _complete_{{ .App.HelpName }}() { fi done - IFS=$'\n' COMPREPLY=( $(COMP_LINE="${COMP_LINE}" COMP_POINT="${COMP_POINT}" COMP_DEBUG="$COMP_DEBUG" {{ .CurrentBinaryPath }} self:autocomplete) ) + # Use newline as only separator to allow space in completion values + IFS=$'\n' + + local cur prev words cword + _get_comp_words_by_ref -n := cur prev words cword + + local sfcomplete + if sfcomplete=$(COMP_LINE="${COMP_LINE}" COMP_POINT="${COMP_POINT}" COMP_DEBUG="$COMP_DEBUG" CURRENT="$cword" {{ .CurrentBinaryPath }} self:autocomplete 2>&1); then + local quote suggestions + quote=${cur:0:1} + + # Use single quotes by default if suggestions contains backslash (FQCN) + if [ "$quote" == '' ] && [[ "$sfcomplete" =~ \\ ]]; then + quote=\' + fi + + if [ "$quote" == \' ]; then + # single quotes: no additional escaping (does not accept ' in values) + suggestions=$(for s in $sfcomplete; do printf $'%q%q%q\n' "$quote" "$s" "$quote"; done) + elif [ "$quote" == \" ]; then + # double quotes: double escaping for \ $ ` " + suggestions=$(for s in $sfcomplete; do + s=${s//\\/\\\\} + s=${s//\$/\\\$} + s=${s//\`/\\\`} + s=${s//\"/\\\"} + printf $'%q%q%q\n' "$quote" "$s" "$quote"; + done) + else + # no quotes: double escaping + suggestions=$(for s in $sfcomplete; do printf $'%q\n' $(printf '%q' "$s"); done) + fi + COMPREPLY=($(IFS=$'\n' compgen -W "$suggestions" -- $(printf -- "%q" "$cur"))) + __ltrim_colon_completions "$cur" + else + if [[ "$sfcomplete" != *"Command \"_complete\" is not defined."* ]]; then + >&2 echo + >&2 echo $sfcomplete + fi + + return 1 + fi } complete -F _complete_{{ .App.HelpName }} {{ .App.HelpName }} diff --git a/commands/resources/completion.fish b/commands/resources/completion.fish index dfe8a110..81991327 100644 --- a/commands/resources/completion.fish +++ b/commands/resources/completion.fish @@ -27,19 +27,10 @@ function __complete_{{ .App.HelpName }} set -lx COMP_LINE (commandline -cp) test -z (commandline -ct) and set COMP_LINE "$COMP_LINE " + set -x CURRENT (count (commandline -oc)) {{ .CurrentBinaryInvocation }} self:autocomplete end -# this wrapper function allows us to call Symfony autocompletion letting it -# knows how to call the `bin/console` using the Symfony CLI binary (to ensure -# the right env and PHP versions are used) -function __complete_{{ .App.HelpName }}_console - set -x _SF_CMD "{{ .CurrentBinaryInvocation }}" "console" - __fish_complete_subcommand -end - -complete -f -c '{{ .App.HelpName }}' -n "__fish_seen_subcommand_from console" -a '(__complete_{{ .App.HelpName }}_console)' -f -complete -f -c '{{ .App.HelpName }}' -n "__fish_seen_subcommand_from composer" -a '(__fish_complete_subcommand)' complete -f -c '{{ .App.HelpName }}' -n "__fish_seen_subcommand_from {{range $i, $name := (.App.Command "php").Names }}{{if $i}} {{end}}{{$name}}{{end}}" -a '(__fish_complete_subcommand)' complete -f -c '{{ .App.HelpName }}' -n "__fish_seen_subcommand_from {{range $i, $name := (.App.Command "run").Names }}{{if $i}} {{end}}{{$name}}{{end}}" -a '(__fish_complete_subcommand --fcs-skip=2)' -complete -f -c '{{ .App.HelpName }}' -n "not __fish_seen_subcommand_from console composer {{range $i, $name := (.App.Command "php").Names }}{{if $i}} {{end}}{{$name}}{{end}} {{range $i, $name := (.App.Command "run").Names }}{{if $i}} {{end}}{{$name}}{{end}}" -a '(__complete_{{ .App.HelpName }})' +complete -f -c '{{ .App.HelpName }}' -n "not __fish_seen_subcommand_from {{range $i, $name := (.App.Command "php").Names }}{{if $i}} {{end}}{{$name}}{{end}} {{range $i, $name := (.App.Command "run").Names }}{{if $i}} {{end}}{{$name}}{{end}}" -a '(__complete_{{ .App.HelpName }})' diff --git a/commands/resources/completion.zsh b/commands/resources/completion.zsh index 3b001f54..77c731db 100644 --- a/commands/resources/completion.zsh +++ b/commands/resources/completion.zsh @@ -26,14 +26,6 @@ # - https://stackoverflow.com/a/13547531 # -# this wrapper function allows us to let Symfony knows how to call the -# `bin/console` using the Symfony CLI binary (to ensure the right env and PHP -# versions are used) -_{{ .App.HelpName }}_console() { - # shellcheck disable=SC2068 - {{ .CurrentBinaryInvocation }} console $@ -} - _complete_{{ .App.HelpName }}() { local lastParam flagPrefix requestComp out comp local -a completions @@ -56,13 +48,10 @@ _complete_{{ .App.HelpName }}() { for ((i = 1; i <= $#words; i++)); do if [[ "${words[i]}" != -* ]]; then case "${words[i]}" in - console) - shift words + console|composer) (( CURRENT-- )) - _SF_CMD="_{{ .App.HelpName }}_console" _normal - return ;; - composer{{range $name := (.App.Command "php").Names }}|{{$name}}{{end}}) + {{range $i, $name := (.App.Command "php").Names }}{{if $i}}|{{end}}{{$name}}{{end}}) shift words (( CURRENT-- )) _normal @@ -82,11 +71,16 @@ _complete_{{ .App.HelpName }}() { while IFS='\n' read -r comp; do if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. # We first need to escape any : as part of the completion itself. comp=${comp//:/\\:} + local tab=$(printf '\t') + comp=${comp//$tab/:} completions+=${comp} fi - done < <(COMP_LINE="$words" ${words[0]} ${_SF_CMD:-${words[1]}} self:autocomplete) + done < <(COMP_LINE="$words" CURRENT="$CURRENT" ${words[0]} ${_SF_CMD:-${words[1]}} self:autocomplete) # Let inbuilt _describe handle completions eval _describe "completions" completions $flagPrefix diff --git a/commands/wrappers.go b/commands/wrappers.go index 24f1e7fe..a106db4c 100644 --- a/commands/wrappers.go +++ b/commands/wrappers.go @@ -31,7 +31,8 @@ var ( Hidden: console.Hide, // we use an alias to avoid the command being shown in the help but // still be available for completion - Aliases: []*console.Alias{{Name: "composer"}}, + Aliases: []*console.Alias{{Name: "composer"}}, + ShellComplete: autocompleteComposerWrapper, Action: func(c *console.Context) error { return console.IncorrectUsageError{ParentError: errors.New(`This command can only be run as "symfony composer"`)} }, @@ -45,6 +46,7 @@ var ( Action: func(c *console.Context) error { return console.IncorrectUsageError{ParentError: errors.New(`This command can only be run as "symfony console"`)} }, + ShellComplete: autocompleteSymfonyConsoleWrapper, } phpWrapper = &console.Command{ Usage: "Runs the named binary using the configured PHP version", diff --git a/go.mod b/go.mod index 64095c78..a1391474 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/nxadm/tail v1.4.11 github.com/olekukonko/tablewriter v0.0.5 github.com/pkg/errors v0.9.1 + github.com/posener/complete v1.2.3 github.com/rjeczalik/notify v0.9.3 github.com/rs/xid v1.5.0 github.com/rs/zerolog v1.33.0 @@ -25,7 +26,7 @@ require ( github.com/soheilhy/cmux v0.1.5 github.com/stoicperlman/fls v0.0.0-20171222144224-f073b7a01081 github.com/symfony-cli/cert v1.0.6 - github.com/symfony-cli/console v1.1.3 + github.com/symfony-cli/console v1.1.4 github.com/symfony-cli/phpstore v1.0.12 github.com/symfony-cli/terminal v1.0.7 golang.org/x/sync v0.7.0 @@ -65,7 +66,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/posener/complete v1.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect diff --git a/go.sum b/go.sum index 6388be5b..fa7feeca 100644 --- a/go.sum +++ b/go.sum @@ -156,6 +156,8 @@ github.com/symfony-cli/console v1.1.2 h1:YVQbl4i03cE0r3QJ/RJs7LWaC3pEy2JI4qhFfL6 github.com/symfony-cli/console v1.1.2/go.mod h1:AB4ZxA593cyS/1NhwnDEUChIPaGuddFqooipam1vyS8= github.com/symfony-cli/console v1.1.3 h1:ejzr9LdNe7d7FLIpeTyZm89nSgnlPhaqK7IPeyglV9o= github.com/symfony-cli/console v1.1.3/go.mod h1:AB4ZxA593cyS/1NhwnDEUChIPaGuddFqooipam1vyS8= +github.com/symfony-cli/console v1.1.4 h1:A/rzNY8HiZd4r6ip2H2HCtnxwYFdC87eYnPL9H/RucM= +github.com/symfony-cli/console v1.1.4/go.mod h1:AB4ZxA593cyS/1NhwnDEUChIPaGuddFqooipam1vyS8= github.com/symfony-cli/phpstore v1.0.12 h1:2mKJrDielSCW+7B+63w6HebmSBcB4qV7uuvNrIjLkoA= github.com/symfony-cli/phpstore v1.0.12/go.mod h1:U29bdJBPs9p28PzLIRKfKfKkaiH0kacdyufl3eSB1d4= github.com/symfony-cli/terminal v1.0.7 h1:57L9PUTE2cHfQtP8Ti8dyiiPEYlQ1NBIDpMJ3RPEGPc= diff --git a/local/php/symfony.go b/local/php/symfony.go new file mode 100644 index 00000000..3a1fa596 --- /dev/null +++ b/local/php/symfony.go @@ -0,0 +1,16 @@ +package php + +import "os" + +// ComposerExecutor returns an Executor prepared to run Symfony Console +func SymonyConsoleExecutor(args []string) *Executor { + consolePath := "bin/console" + if _, err := os.Stat("app/console"); err == nil { + consolePath = "app/console" + } + + return &Executor{ + BinName: "php", + Args: append([]string{"php", consolePath}, args...), + } +} diff --git a/main.go b/main.go index 17004d6f..29818857 100644 --- a/main.go +++ b/main.go @@ -73,15 +73,8 @@ func main() { } // called via "symfony console"? if len(args) >= 2 && args[1] == "console" { - args[1] = "bin/console" - if _, err := os.Stat("app/console"); err == nil { - args[1] = "app/console" - } - e := &php.Executor{ - BinName: "php", - Args: args, - ExtraEnv: getCliExtraEnv(), - } + e := php.SymonyConsoleExecutor(args[2:]) + e.ExtraEnv = getCliExtraEnv() os.Exit(e.Execute(false)) } // called via "symfony composer"? From 533cd83395daf6ed20fa78cb1901ae06d05dbc4d Mon Sep 17 00:00:00 2001 From: Tugdual Saunier Date: Fri, 12 Jul 2024 13:13:23 +0200 Subject: [PATCH 2/2] Rework the SymfonyExecutor to prevent non-friendly user errors when the console binary is not found --- commands/completion_posix.go | 5 +++-- commands/wrappers.go | 2 +- local/php/symfony.go | 21 ++++++++++++++++----- main.go | 7 ++++--- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/commands/completion_posix.go b/commands/completion_posix.go index a4579730..97557993 100644 --- a/commands/completion_posix.go +++ b/commands/completion_posix.go @@ -31,9 +31,10 @@ func autocompleteSymfonyConsoleWrapper(context *console.Context, words complete. // Composer does not support those options yet, so we only use them for Symfony Console args = append(args, "-a1", fmt.Sprintf("-s%s", console.GuessShell())) - os.Exit(php.SymonyConsoleExecutor(args).Execute(false)) + if executor, err := php.SymonyConsoleExecutor(args); err == nil { + os.Exit(executor.Execute(false)) + } - // unreachable return []string{} } diff --git a/commands/wrappers.go b/commands/wrappers.go index a106db4c..896e50ba 100644 --- a/commands/wrappers.go +++ b/commands/wrappers.go @@ -44,7 +44,7 @@ var ( // still be available for completion Aliases: []*console.Alias{{Name: "console"}}, Action: func(c *console.Context) error { - return console.IncorrectUsageError{ParentError: errors.New(`This command can only be run as "symfony console"`)} + return errors.New(`No Symfony console detected to run "symfony console"`) }, ShellComplete: autocompleteSymfonyConsoleWrapper, } diff --git a/local/php/symfony.go b/local/php/symfony.go index 3a1fa596..14e83c22 100644 --- a/local/php/symfony.go +++ b/local/php/symfony.go @@ -1,16 +1,27 @@ package php -import "os" +import ( + "os" -// ComposerExecutor returns an Executor prepared to run Symfony Console -func SymonyConsoleExecutor(args []string) *Executor { + "github.com/pkg/errors" +) + +// ComposerExecutor returns an Executor prepared to run Symfony Console. +// It returns an error if no console binary is found. +func SymonyConsoleExecutor(args []string) (*Executor, error) { consolePath := "bin/console" - if _, err := os.Stat("app/console"); err == nil { + + if _, err := os.Stat(consolePath); err != nil { + // Fallback to app/console for projects created with older versions of Symfony consolePath = "app/console" + + if _, err2 := os.Stat(consolePath); err2 != nil { + return nil, errors.WithStack(err) + } } return &Executor{ BinName: "php", Args: append([]string{"php", consolePath}, args...), - } + }, nil } diff --git a/main.go b/main.go index 29818857..2d89d1e9 100644 --- a/main.go +++ b/main.go @@ -73,9 +73,10 @@ func main() { } // called via "symfony console"? if len(args) >= 2 && args[1] == "console" { - e := php.SymonyConsoleExecutor(args[2:]) - e.ExtraEnv = getCliExtraEnv() - os.Exit(e.Execute(false)) + if executor, err := php.SymonyConsoleExecutor(args[2:]); err == nil { + executor.ExtraEnv = getCliExtraEnv() + os.Exit(executor.Execute(false)) + } } // called via "symfony composer"? if len(args) >= 2 && args[1] == "composer" {