diff --git a/CHANGES.md b/CHANGES.md index dec59ad91..5275b824e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ Unreleased +- Add built-in shell completion support for PowerShell (Windows PowerShell + 5.1+ and pwsh 7+) alongside the existing `bash`, `zsh`, and `fish` + completers. Use `_FOO_BAR_COMPLETE=powershell_source foo-bar` to generate + the completion script. - Supported versions of Windows enable ANSI terminal styles by default. Colorama is no longer a dependency and is not used. {issue}`2986` {pr}`3505` - {class}`Argument` accepts a `help` parameter, and help output includes diff --git a/docs/shell-completion.md b/docs/shell-completion.md index a8bc941ce..38368197d 100644 --- a/docs/shell-completion.md +++ b/docs/shell-completion.md @@ -29,7 +29,7 @@ depending on the shell you are using. Click will output it when called with `_{F `{shell}_source`. `{FOO_BAR}` is the executable name in uppercase with dashes replaced by underscores. It is conventional but not strictly required for environment variable names to be in upper case. This convention helps distinguish environment variables from regular shell variables and commands, making scripts and configuration files more -readable and easier to maintain. The built-in shells are `bash`, `zsh`, and `fish`. +readable and easier to maintain. The built-in shells are `bash`, `zsh`, `fish`, and `powershell`. Provide your users with the following instructions customized to your program name. This uses `foo-bar` as an example. @@ -62,6 +62,16 @@ Provide your users with the following instructions customized to your program na This is the same file used for the activation script method below. For Fish it's probably always easier to use that method. + + .. group-tab:: PowerShell + + Add this to your PowerShell profile (the path of ``$PROFILE``): + + .. code-block:: powershell + + $env:_FOO_BAR_COMPLETE = 'powershell_source' + foo-bar | Out-String | Invoke-Expression + Remove-Item Env:_FOO_BAR_COMPLETE ``` Using `eval` means that the command is invoked and evaluated every time a shell is started, which can delay shell @@ -106,6 +116,22 @@ of time and distribute them with your program to save your users a step. .. code-block:: fish _FOO_BAR_COMPLETE=fish_source foo-bar > ~/.config/fish/completions/foo-bar.fish + + .. group-tab:: PowerShell + + Save the script somewhere. + + .. code-block:: powershell + + $env:_FOO_BAR_COMPLETE = 'powershell_source' + foo-bar | Out-File -Encoding utf8 ~/.foo-bar-complete.ps1 + Remove-Item Env:_FOO_BAR_COMPLETE + + Source the file in your PowerShell profile (the path of ``$PROFILE``): + + .. code-block:: powershell + + . ~/.foo-bar-complete.ps1 ``` After modifying the shell config, you need to start a new shell in order for the changes to be loaded. diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index 5652214b0..62f5c82a6 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -206,6 +206,68 @@ def __getattr__(self, name: str) -> t.Any: "(%(complete_func)s)"; """ +# Compatible with Windows PowerShell 5.1+ and PowerShell (pwsh) 7+. +# Uses Register-ArgumentCompleter -Native, which receives the command AST +# as parsed by PowerShell. The command text is forwarded verbatim through +# COMP_WORDS so the Python side can reuse the same shlex-based splitting +# used by the bash/zsh completers. +_SOURCE_POWERSHELL = """\ +Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $prev_complete = $env:%(complete_var)s + $prev_words = $env:COMP_WORDS + $prev_cword = $env:COMP_CWORD + + $env:%(complete_var)s = 'powershell_complete' + $env:COMP_WORDS = $commandAst.ToString() + if ($wordToComplete) { + $env:COMP_CWORD = $commandAst.CommandElements.Count - 1 + } else { + $env:COMP_CWORD = $commandAst.CommandElements.Count + } + + try { + $response = & %(prog_name)s 2>$null + } finally { + $env:%(complete_var)s = $prev_complete + $env:COMP_WORDS = $prev_words + $env:COMP_CWORD = $prev_cword + } + + if (-not $response) { return } + + $prefix = "$wordToComplete*" + $lines = $response -split "`n" + for ($i = 0; $i + 2 -lt $lines.Count; $i += 3) { + $type = $lines[$i] + $value = $lines[$i + 1] + $descr = $lines[$i + 2] + if (-not $type) { continue } + + if ($type -eq 'plain') { + $tip = if ($descr -and $descr -ne '_') { $descr } else { $value } + [System.Management.Automation.CompletionResult]::new( + $value, $value, 'ParameterValue', $tip) + } elseif ($type -eq 'dir') { + Get-ChildItem -Directory -Path $prefix -ErrorAction SilentlyContinue | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_.FullName, $_.Name, 'ProviderContainer', $_.FullName) + } + } elseif ($type -eq 'file') { + Get-ChildItem -Path $prefix -ErrorAction SilentlyContinue | + ForEach-Object { + $kind = 'ProviderItem' + if ($_.PSIsContainer) { $kind = 'ProviderContainer' } + [System.Management.Automation.CompletionResult]::new( + $_.FullName, $_.Name, $kind, $_.FullName) + } + } + } +} +""" + class _SourceVarsDict(t.TypedDict): complete_func: str @@ -456,9 +518,44 @@ def format_completion(self, item: CompletionItem[str]) -> str: return f"{item.type},{item.value}" +class PowerShellComplete(ShellComplete): + """Shell completion for PowerShell (Windows PowerShell 5.1+ and pwsh 7+). + + .. versionadded:: 8.5 + """ + + name: t.ClassVar[str] = "powershell" + source_template: t.ClassVar[str] = _SOURCE_POWERSHELL + + def get_completion_args(self) -> tuple[list[str], str]: + cwords = split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item: CompletionItem[str]) -> str: + # PowerShell parses the response by splitting on newlines and + # consuming three lines per completion (type, value, help). + # Multi-line help text would break that framing, so collapse + # newlines and carriage returns to spaces. The literal "_" + # sentinel matches what ZshComplete uses for "no help text". + if item.help: + help_ = item.help.replace("\r", " ").replace("\n", " ") + else: + help_ = "_" + return f"{item.type}\n{item.value}\n{help_}" + + _available_shells: t.Final[dict[str, type[ShellComplete]]] = { "bash": BashComplete, "fish": FishComplete, + "powershell": PowerShellComplete, "zsh": ZshComplete, } diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 1e3fd9909..a06a1cde8 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -13,6 +13,7 @@ from click.shell_completion import add_completion_class from click.shell_completion import CompletionItem from click.shell_completion import FishComplete +from click.shell_completion import PowerShellComplete from click.shell_completion import shell_complete from click.shell_completion import ShellComplete from click.types import Choice @@ -353,6 +354,17 @@ def test_full_source(runner, shell): assert f"_CLI_COMPLETE={shell}_complete" in result.output +def test_full_source_powershell(runner): + # The PowerShell source script sets the completion env var via + # `$env:_CLI_COMPLETE = 'powershell_complete'`, so the substring + # `_CLI_COMPLETE=powershell_complete` used by test_full_source above + # is not present. Assert on the PowerShell-specific markers instead. + cli = Group("cli", commands=[Command("a"), Command("b")]) + result = runner.invoke(cli, env={"_CLI_COMPLETE": "powershell_source"}) + assert "Register-ArgumentCompleter" in result.output + assert "powershell_complete" in result.output + + @pytest.mark.parametrize( ("shell", "env", "expect"), [ @@ -363,6 +375,12 @@ def test_full_source(runner, shell): ("fish", {"COMP_WORDS": "", "COMP_CWORD": ""}, "plain,a\nplain,b\tbee\n"), ("fish", {"COMP_WORDS": "a b", "COMP_CWORD": "b"}, "plain,b\tbee\n"), ("fish", {"COMP_WORDS": 'a "b', "COMP_CWORD": '"b'}, "plain,b\tbee\n"), + ( + "powershell", + {"COMP_WORDS": "", "COMP_CWORD": "0"}, + "plain\na\n_\nplain\nb\nbee\n", + ), + ("powershell", {"COMP_WORDS": "a b", "COMP_CWORD": "1"}, "plain\nb\nbee\n"), ], ) @pytest.mark.usefixtures("_patch_for_completion") @@ -585,3 +603,16 @@ def test_fish_format_completion_escapes_help(): # The newline is escaped to the literal characters backslash-n and the tab # becomes a space, so each completion stays on one line for fish. assert fc.format_completion(item) == "plain,--at\tfirst\\nsecond third" + + +def test_powershell_format_completion_escapes_help(): + pc = PowerShellComplete(Command("x"), {}, "x", "_X_COMPLETE") + # Newlines (LF and CRLF) collapse to spaces so the help text stays + # on a single line and doesn't break the 3-lines-per-item framing + # consumed by the PowerShell completion script. + item = CompletionItem("--at", help="first\nsecond\r\nthird") + assert pc.format_completion(item) == "plain\n--at\nfirst second third" + # Items without help text use the "_" sentinel, matching the format + # produced by ZshComplete. + item = CompletionItem("--at") + assert pc.format_completion(item) == "plain\n--at\n_"