From 4b49294409bca6503ad9435f3906dbe019b70c81 Mon Sep 17 00:00:00 2001 From: Fedor Kuznetsov Date: Tue, 20 Dec 2022 03:10:43 +0500 Subject: [PATCH 1/3] Add PowerShell completion generation I'm not an expert in PowerShell or CLI's, but this developments may help someone else complete it Should be called like (on Windows): `script completions PowerShell > $HOME\Documents\PowerShell\Profile.ps1` or (on Linux or macOS): `script completions PowerShell > ~/.config/powershell/profile.ps1` If I understand correctly, where PowerShell saves its configurations --- src/cleo/commands/completions/templates.py | 32 +++++++++++++- src/cleo/commands/completions_command.py | 44 ++++++++++++++++++- .../completion/fixtures/PowerShell.txt | 25 +++++++++++ .../completion/test_completions_command.py | 23 ++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 tests/commands/completion/fixtures/PowerShell.txt diff --git a/src/cleo/commands/completions/templates.py b/src/cleo/commands/completions/templates.py index 7cf7c54e..6997c85b 100644 --- a/src/cleo/commands/completions/templates.py +++ b/src/cleo/commands/completions/templates.py @@ -122,4 +122,34 @@ %(cmds_opts)s""" -TEMPLATES = {"bash": BASH_TEMPLATE, "zsh": ZSH_TEMPLATE, "fish": FISH_TEMPLATE} +POWERSHELL_TEMPLATE = """\ +$%(function)s = { + param( + [string] $wordToComplete, + [System.Management.Automation.Language.Ast] $commandAst, + [int] $cursorPosition + ) + + $options = %(opts)s + $commands = %(cmds)s + + if ($wordToComplete -notlike '--*' -and $wordToComplete -notlike "" -and ($commandAst.CommandElements.Count -eq "2")) { + return $commands | Where-Object { $_ -like "$wordToComplete*" } + } + + $result = $commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_ -notlike '--*' } + switch ($result -Join " " ) { +%(cmds_opts)s + } + + return $options | Where-Object { $_ -like "$wordToComplete*" } +} + +Register-ArgumentCompleter -Native -CommandName %(script_name)s -ScriptBlock $%(function)s""" + +TEMPLATES = { + "bash": BASH_TEMPLATE, + "zsh": ZSH_TEMPLATE, + "fish": FISH_TEMPLATE, + "PowerShell": POWERSHELL_TEMPLATE, +} diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index b7d682ff..8e1ba907 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -37,7 +37,7 @@ class CompletionsCommand(Command): ) ] - SUPPORTED_SHELLS = ("bash", "zsh", "fish") + SUPPORTED_SHELLS = ("bash", "zsh", "fish", "PowerShell") hidden = True @@ -135,6 +135,8 @@ def render(self, shell: str) -> str: return self.render_zsh() if shell == "fish": return self.render_fish() + if shell == "PowerShell": + return self.render_power_shell() raise RuntimeError(f"Unrecognized shell: {shell}") @@ -280,9 +282,49 @@ def sanitize(s: str) -> str: "cmds_names": " ".join(cmds_names), } + def render_power_shell(self) -> str: + script_name, script_path = self._get_script_name_and_path() + function = self._generate_function_name(script_name, script_path) + + assert self.application + # Global options + opts = [ + f'"--{opt.name}"' + for opt in sorted(self.application.definition.options, key=lambda o: o.name) + ] + + # Command + options + cmds = [] + cmds_opts = [] + for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""): + if cmd.hidden or not cmd.enabled or not cmd.name: + continue + + command_name = f'"{cmd.name}"' + cmds.append(command_name) + if len(cmd.definition.options) == 0: + continue + options = ", ".join( + f'"--{opt.name}"' + for opt in sorted(cmd.definition.options, key=lambda o: o.name) + ) + cmds_opts += [f" {command_name} {{ $options += {options}; Break; }}"] + + return TEMPLATES["PowerShell"] % { + "function": function, + "script_name": script_name, + "opts": ", ".join(opts), + "cmds": ", ".join(cmds), + "cmds_opts": "\n".join(cmds_opts), + } + def get_shell_type(self) -> str: shell = os.getenv("SHELL") + if not shell: + if len(os.getenv("PSModulePath", "").split(os.pathsep)) >= 3: + return "PowerShell" + raise RuntimeError( "Could not read SHELL environment variable. " "Please specify your shell type by passing it as the first argument." diff --git a/tests/commands/completion/fixtures/PowerShell.txt b/tests/commands/completion/fixtures/PowerShell.txt new file mode 100644 index 00000000..e1c5f906 --- /dev/null +++ b/tests/commands/completion/fixtures/PowerShell.txt @@ -0,0 +1,25 @@ +$_my_function = { + param( + [string] $wordToComplete, + [System.Management.Automation.Language.Ast] $commandAst, + [int] $cursorPosition + ) + + $options = "--ansi", "--help", "--no-ansi", "--no-interaction", "--quiet", "--verbose", "--version" + $commands = "command:with:colons", "hello", "help", "list", "spaced command" + + if ($wordToComplete -notlike '--*' -and $wordToComplete -notlike "" -and ($commandAst.CommandElements.Count -eq "2")) { + return $commands | Where-Object { $_ -like "$wordToComplete*" } + } + + $result = $commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_ -notlike '--*' } + switch ($result -Join " " ) { + "command:with:colons" { $options += "--goodbye"; Break; } + "hello" { $options += "--dangerous-option", "--option-without-description"; Break; } + "spaced command" { $options += "--goodbye"; Break; } + } + + return $options | Where-Object { $_ -like "$wordToComplete*" } +} + +Register-ArgumentCompleter -Native -CommandName script -ScriptBlock $_my_function diff --git a/tests/commands/completion/test_completions_command.py b/tests/commands/completion/test_completions_command.py index 190326a2..1ec9d1dc 100644 --- a/tests/commands/completion/test_completions_command.py +++ b/tests/commands/completion/test_completions_command.py @@ -96,3 +96,26 @@ def test_fish(mocker: MockerFixture) -> None: expected = f.read() assert expected == tester.io.fetch_output().replace("\r\n", "\n") + + +def test_power_shell(mocker: MockerFixture) -> None: + mocker.patch( + "cleo.io.inputs.string_input.StringInput.script_name", + new_callable=mocker.PropertyMock, + return_value="/path/to/my/script", + ) + mocker.patch( + "cleo.commands.completions_command.CompletionsCommand._generate_function_name", + return_value="_my_function", + ) + + command = app.find("completions") + tester = CommandTester(command) + tester.execute("PowerShell") + + with open( + os.path.join(os.path.dirname(__file__), "fixtures", "PowerShell.txt") + ) as f: + expected = f.read() + + assert expected == tester.io.fetch_output().replace("\r\n", "\n") From bd313b4c1205f4e2e270e451e84bd6b452af8f68 Mon Sep 17 00:00:00 2001 From: Fedor Kuznetsov Date: Tue, 20 Dec 2022 03:21:46 +0500 Subject: [PATCH 2/3] Fix line length --- src/cleo/commands/completions/templates.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/cleo/commands/completions/templates.py b/src/cleo/commands/completions/templates.py index 6997c85b..88a8b1d0 100644 --- a/src/cleo/commands/completions/templates.py +++ b/src/cleo/commands/completions/templates.py @@ -122,7 +122,8 @@ %(cmds_opts)s""" -POWERSHELL_TEMPLATE = """\ +POWERSHELL_TEMPLATE = ( + """\ $%(function)s = { param( [string] $wordToComplete, @@ -133,11 +134,13 @@ $options = %(opts)s $commands = %(cmds)s - if ($wordToComplete -notlike '--*' -and $wordToComplete -notlike "" -and ($commandAst.CommandElements.Count -eq "2")) { + if ($wordToComplete -notlike '--*' -and $wordToComplete -notlike "" -and """ + """($commandAst.CommandElements.Count -eq "2")) { return $commands | Where-Object { $_ -like "$wordToComplete*" } } - $result = $commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_ -notlike '--*' } + $result = $commandAst.CommandElements | Select-Object -Skip 1 | """ + """Where-Object { $_ -notlike '--*' } switch ($result -Join " " ) { %(cmds_opts)s } @@ -145,7 +148,9 @@ return $options | Where-Object { $_ -like "$wordToComplete*" } } -Register-ArgumentCompleter -Native -CommandName %(script_name)s -ScriptBlock $%(function)s""" +Register-ArgumentCompleter -Native -CommandName %(script_name)s """ + """-ScriptBlock $%(function)s""" +) TEMPLATES = { "bash": BASH_TEMPLATE, From d2a179ec0f9c41187beb626a38cff518549880c4 Mon Sep 17 00:00:00 2001 From: Fedor Kuznetsov Date: Tue, 20 Dec 2022 19:09:01 +0500 Subject: [PATCH 3/3] Add documentation --- README.md | 5 ++++- src/cleo/commands/completions_command.py | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c1b169d..c1a00428 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ the `call_silent()` method instead. ### Autocompletion -Cleo supports automatic (tab) completion in `bash`, `zsh` and `fish`. +Cleo supports automatic (tab) completion in `bash`, `zsh`, `fish` and `PowerShell`. By default, your application will have a `completions` command. To register these completions for your application, run one of the following in a terminal (replacing `[program]` with the command you use to run your application): @@ -434,4 +434,7 @@ echo "fpath+=~/.zfunc" >> ~/.zshrc # Fish [program] completions fish > ~/.config/fish/completions/[program].fish + +# PowerShell +[program] completions PowerShell > $PROFILE ``` diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index 8e1ba907..b25fcc26 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -106,6 +106,13 @@ class CompletionsCommand(Command): For the new completions to take affect. +PowerShell: + +PowerShell profiles are stored at $PROFILE path, so you can simply \ +run command like this: + +`{script_name} {command_name} PowerShell > $PROFILE` + CUSTOM LOCATIONS: Alternatively, you could save these files to the place of your choosing, \