Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PowerShell completion generation #300

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -434,4 +434,7 @@ echo "fpath+=~/.zfunc" >> ~/.zshrc

# Fish
[program] completions fish > ~/.config/fish/completions/[program].fish

# PowerShell
[program] completions PowerShell > $PROFILE
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See other review note

```
37 changes: 36 additions & 1 deletion src/cleo/commands/completions/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,39 @@
%(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 """
Copy link
Contributor

@KotlinIsland KotlinIsland Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

poetry then ctrl+space doesn't show the commands, only the options:

image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

poetry cache c doesn't complete to anything

"""($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*" }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use CompletionResult for a richer user experience:

            New-Object -Type System.Management.Automation.CompletionResult -ArgumentList lock, lock, Method, "Locks the project dependencies."

}

Register-ArgumentCompleter -Native -CommandName %(script_name)s """
"""-ScriptBlock $%(function)s"""
)

TEMPLATES = {
"bash": BASH_TEMPLATE,
"zsh": ZSH_TEMPLATE,
"fish": FISH_TEMPLATE,
"PowerShell": POWERSHELL_TEMPLATE,
}
51 changes: 50 additions & 1 deletion src/cleo/commands/completions_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class CompletionsCommand(Command):
)
]

SUPPORTED_SHELLS = ("bash", "zsh", "fish")
SUPPORTED_SHELLS = ("bash", "zsh", "fish", "PowerShell")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we want to recognize ps/pwsh as forms of PowerShell?

Additionally, would we want to ignore case for this?

> poetry completions powershell

[shell] argument must be one of bash, zsh, fish, PowerShell


hidden = True

Expand Down Expand Up @@ -106,6 +106,13 @@ class CompletionsCommand(Command):

For the new completions to take affect.

<option=bold>PowerShell</>:

PowerShell profiles are stored at $PROFILE path, so you can simply \
run command like this:

`<options=bold>{script_name} {command_name} PowerShell > $PROFILE</>`
Copy link
Contributor

@KotlinIsland KotlinIsland Jan 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to delete the users existing profile:

Suggested change
`<options=bold>{script_name} {command_name} PowerShell > $PROFILE</>`
`<options=bold>{script_name} {command_name} PowerShell >> $PROFILE</>`

Additionally, this directory does not exist by default, and >/>> will not create it:

👉 "a" >> $PROFILE
Out-File: Could not find a part of the path 'C:\Users\AMONGUS\Documents\PowerShell\Microsoft.PowerShell_profile.ps1'.


<options=bold>CUSTOM LOCATIONS</>:

Alternatively, you could save these files to the place of your choosing, \
Expand Down Expand Up @@ -135,6 +142,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}")

Expand Down Expand Up @@ -280,9 +289,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."
Expand Down
25 changes: 25 additions & 0 deletions tests/commands/completion/fixtures/PowerShell.txt
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions tests/commands/completion/test_completions_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")