diff --git a/bootstrap.fish b/bootstrap.fish index 97eb75796..3e76063d5 100644 --- a/bootstrap.fish +++ b/bootstrap.fish @@ -83,7 +83,7 @@ set -e _pw_sourced set -e _PW_BOOTSTRAP_PATH set -e SETUP_SH -# TODO(tonymd): Source fish pw_cli shell completion. +source $PW_ROOT/pw_cli/py/pw_cli/shell_completion/pw.fish pw_cleanup diff --git a/pw_build/py/pw_build/project_builder_presubmit_runner.py b/pw_build/py/pw_build/project_builder_presubmit_runner.py index a42b1ac80..a0214bc95 100644 --- a/pw_build/py/pw_build/project_builder_presubmit_runner.py +++ b/pw_build/py/pw_build/project_builder_presubmit_runner.py @@ -638,7 +638,7 @@ def main( force_pw_watch: bool = False, ) -> int: """Build upstream Pigweed presubmit steps.""" - # pylint: disable=too-many-locals + # pylint: disable=too-many-locals,too-many-branches parser = get_parser(presubmit_programs, build_recipes) args = parser.parse_args() @@ -665,14 +665,18 @@ def main( else: charset = ASCII_CHARSET - if build_recipes and args.tab_complete_recipe is not None: - _tab_complete_recipe(build_recipes, text=args.tab_complete_recipe) + if args.tab_complete_recipe is not None: + if build_recipes: + _tab_complete_recipe(build_recipes, text=args.tab_complete_recipe) + # Must exit if there are no build_recipes. return 0 - if presubmit_programs and args.tab_complete_presubmit_step is not None: - _tab_complete_presubmit_step( - presubmit_programs, text=args.tab_complete_presubmit_step - ) + if args.tab_complete_presubmit_step is not None: + if presubmit_programs: + _tab_complete_presubmit_step( + presubmit_programs, text=args.tab_complete_presubmit_step + ) + # Must exit if there are no presubmit_programs. return 0 # List valid steps + recipes. diff --git a/pw_cli/py/BUILD.gn b/pw_cli/py/BUILD.gn index 2e4adde02..e29ad075a 100644 --- a/pw_cli/py/BUILD.gn +++ b/pw_cli/py/BUILD.gn @@ -43,6 +43,7 @@ pw_python_package("py") { "pw_cli/pw_command_plugins.py", "pw_cli/requires.py", "pw_cli/shell_completion/__init__.py", + "pw_cli/shell_completion/fish/__init__.py", "pw_cli/shell_completion/zsh/__init__.py", "pw_cli/shell_completion/zsh/pw/__init__.py", "pw_cli/shell_completion/zsh/pw_build/__init__.py", @@ -63,7 +64,9 @@ pw_python_package("py") { mypy_ini = "$dir_pigweed/.mypy.ini" inputs = [ "pw_cli/shell_completion/common.bash", + "pw_cli/shell_completion/fish/pw.fish", "pw_cli/shell_completion/pw.bash", + "pw_cli/shell_completion/pw.fish", "pw_cli/shell_completion/pw.zsh", "pw_cli/shell_completion/pw_build.bash", "pw_cli/shell_completion/pw_build.zsh", diff --git a/pw_cli/py/pw_cli/__main__.py b/pw_cli/py/pw_cli/__main__.py index f9eaa3768..3b543a3af 100644 --- a/pw_cli/py/pw_cli/__main__.py +++ b/pw_cli/py/pw_cli/__main__.py @@ -31,10 +31,15 @@ def main() -> NoReturn: pw_cli.log.install(level=args.loglevel, debug_log=args.debug_log) - # Print the banner unless --no-banner or --tab-complete-command is provided. - # Note: args.tab_complete_command may be the empty string '' so check for - # None instead. - if args.banner and args.tab_complete_command is None: + # Print the banner unless --no-banner or a tab completion arg is + # present. + # Note: args.tab_complete_{command,option} may be the empty string + # '' so check for None instead. + if ( + args.banner + and args.tab_complete_option is None + and args.tab_complete_command is None + ): arguments.print_banner() _LOG.debug('Executing the pw command from %s', args.directory) @@ -53,7 +58,7 @@ def main() -> NoReturn: if args.tab_complete_command is not None: for name, plugin in sorted(pw_command_plugins.plugin_registry.items()): if name.startswith(args.tab_complete_command): - if args.tab_complete_format == 'zsh': + if args.tab_complete_format in ('fish', 'zsh'): print(':'.join([name, plugin.help()])) else: print(name) diff --git a/pw_cli/py/pw_cli/arguments.py b/pw_cli/py/pw_cli/arguments.py index 936dc1401..cac04b832 100644 --- a/pw_cli/py/pw_cli/arguments.py +++ b/pw_cli/py/pw_cli/arguments.py @@ -14,12 +14,13 @@ """Defines arguments for the pw command.""" import argparse -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum +from functools import cached_property import logging from pathlib import Path import sys -from typing import NoReturn +from typing import Any, NoReturn from pw_cli import argument_types, plugins from pw_cli.branding import banner @@ -41,39 +42,92 @@ class ShellCompletionFormat(Enum): """Supported shell tab completion modes.""" BASH = 'bash' + FISH = 'fish' ZSH = 'zsh' @dataclass(frozen=True) class ShellCompletion: - option_strings: list[str] = field(default_factory=list) - help: str | None = None - choices: list[str] | None = None - flag: bool = True + """Transforms argparse actions into bash, fish, zsh shell completions.""" - def bash_completion(self, text: str) -> list[str]: + action: argparse.Action + parser: argparse.ArgumentParser + + @property + def option_strings(self) -> list[str]: + return list(self.action.option_strings) + + @cached_property + def help(self) -> str: + return self.parser._get_formatter()._expand_help( # pylint: disable=protected-access + self.action + ) + + @property + def choices(self) -> list[str]: + return list(self.action.choices) if self.action.choices else [] + + @property + def flag(self) -> bool: + return self.action.nargs == 0 + + @property + def default(self) -> Any: + return self.action.default + + def bash_option(self, text: str) -> list[str]: result: list[str] = [] for option_str in self.option_strings: if option_str.startswith(text): result.append(option_str) return result - def zsh_completion(self, text: str) -> list[str]: + def zsh_option(self, text: str) -> list[str]: result: list[str] = [] for option_str in self.option_strings: - if option_str.startswith(text): - short_and_long_opts = ' '.join(self.option_strings) - # '(-h --help)-h[Display help message and exit]' - # '(-h --help)--help[Display help message and exit]' - help_text = self.help if self.help else '' - state_str = '' - if not self.flag: - state_str = ': :->' + option_str + if not option_str.startswith(text): + continue - result.append( - f'({short_and_long_opts}){option_str}[{help_text}]' - f'{state_str}' - ) + short_and_long_opts = ' '.join(self.option_strings) + # '(-h --help)-h[Display help message and exit]' + # '(-h --help)--help[Display help message and exit]' + help_text = self.help if self.help else '' + + state_str = '' + if not self.flag: + state_str = ': :->' + option_str + + result.append( + f'({short_and_long_opts}){option_str}[{help_text}]' + f'{state_str}' + ) + return result + + def fish_option(self, text: str) -> list[str]: + result: list[str] = [] + for option_str in self.option_strings: + if not option_str.startswith(text): + continue + + output: list[str] = [] + if option_str.startswith('--'): + output.append(f'--long-option\t{option_str.lstrip("-")}') + elif option_str.startswith('-'): + output.append(f'--short-option\t{option_str.lstrip("-")}') + + if self.choices: + choice_str = " ".join(self.choices) + output.append('--exclusive') + output.append('--arguments') + output.append(f'(string split " " "{choice_str}")') + elif self.action.type == Path: + output.append('--require-parameter') + output.append('--force-files') + + if self.help: + output.append(f'--description\t"{self.help}"') + + result.append('\t'.join(output)) return result @@ -81,12 +135,7 @@ def get_options_and_help( parser: argparse.ArgumentParser, ) -> list[ShellCompletion]: return list( - ShellCompletion( - option_strings=list(action.option_strings), - help=action.help, - choices=list(action.choices) if action.choices else [], - flag=action.nargs == 0, - ) + ShellCompletion(action=action, parser=parser) for action in parser._actions # pylint: disable=protected-access ) @@ -99,9 +148,11 @@ def print_completions_for_option( matched_lines: list[str] = [] for completion in get_options_and_help(parser): if tab_completion_format == ShellCompletionFormat.ZSH.value: - matched_lines.extend(completion.zsh_completion(text)) + matched_lines.extend(completion.zsh_option(text)) + if tab_completion_format == ShellCompletionFormat.FISH.value: + matched_lines.extend(completion.fish_option(text)) else: - matched_lines.extend(completion.bash_completion(text)) + matched_lines.extend(completion.bash_option(text)) for line in matched_lines: print(line) @@ -179,6 +230,7 @@ def arg_parser() -> argparse.ArgumentParser: ) argparser.add_argument( '--debug-log', + type=Path, help=( 'Additionally log to this file at debug level; does not affect ' 'terminal output' diff --git a/pw_cli/py/pw_cli/shell_completion/fish/__init__.py b/pw_cli/py/pw_cli/shell_completion/fish/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pw_cli/py/pw_cli/shell_completion/fish/pw.fish b/pw_cli/py/pw_cli/shell_completion/fish/pw.fish new file mode 100644 index 000000000..d2751dd39 --- /dev/null +++ b/pw_cli/py/pw_cli/shell_completion/fish/pw.fish @@ -0,0 +1,82 @@ +# Copyright 2024 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# Only generate completions if $PW_ROOT is set +if test -z "$PW_ROOT" + exit +end + +set status_message_length 0 +function print_status_message + printf '\033[s\033[1B\033[0G' + set -l status_message "Regenerating completions." + set status_message_length (math 1 + (string length --visible $status_message)) + printf $status_message + printf '\033[u' +end + +function print_status_indicator + printf '\033[s\033[1B\033[' + printf $status_message_length + printf G + printf '.' + printf '\033[u' + set status_message_length (math $status_message_length + 1) +end + +print_status_message + +complete -c pw --no-files + +# pw completion +set --local --append _pw_subcommands (pw --no-banner --tab-complete-format=fish --tab-complete-command "") +print_status_indicator +set --local --append _pw_options (pw --no-banner --tab-complete-format=fish --tab-complete-option "") +print_status_indicator + +# pw short and long options +for option in $_pw_options + set -l complete_args (string split \t -- "$option") + + complete --command pw --condition "__fish_use_subcommand; and not __fish_seen_subcommand_from $_pw_subcommand_names" $complete_args +end + +# pw subcommands +for subcommand in $_pw_subcommands + set --local command_and_help (string split --max 1 ":" "$subcommand") + set --local command $command_and_help[1] + set --local help $command_and_help[2] + set --append _pw_subcommand_names $command + + complete --command pw --condition __fish_use_subcommand --arguments "$command" --description "$help" +end + +# pw build completion +if contains build $_pw_subcommand_names + set --local --append _pw_build_options (pw --no-banner build --tab-complete-format=fish --tab-complete-option "") + print_status_indicator + set --local --append _pw_build_recipes (pw --no-banner build --tab-complete-format=fish --tab-complete-recipe "") + print_status_indicator + set --local --append _pw_build_presubmit_steps (pw --no-banner build --tab-complete-format=fish --tab-complete-presubmit-step "") + print_status_indicator + + complete --command pw --condition "__fish_seen_subcommand_from build" -s r -l recipe -x -a "$_pw_build_recipes" + complete --command pw --condition "__fish_seen_subcommand_from build" -s s -l step -x -a "$_pw_build_presubmit_steps" + + for option in $_pw_build_options + set -l complete_args (string split \t -- "$option") + + complete --command pw --condition "__fish_seen_subcommand_from build" $complete_args + end +end diff --git a/pw_cli/py/pw_cli/shell_completion/pw.fish b/pw_cli/py/pw_cli/shell_completion/pw.fish new file mode 100644 index 000000000..2adf887f4 --- /dev/null +++ b/pw_cli/py/pw_cli/shell_completion/pw.fish @@ -0,0 +1,22 @@ +# Copyright 2024 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# This script must be tested on fish 3.6.0 + +set _pw_fish_completion_path (path resolve (status current-dirname)/fish) + +# Add the fish subdirectory to the completion path +if not contains $_pw_fish_completion_path $fish_complete_path + set -x --append fish_complete_path $_pw_fish_completion_path +end diff --git a/pw_cli/py/setup.cfg b/pw_cli/py/setup.cfg index 290b0a364..cbc9135db 100644 --- a/pw_cli/py/setup.cfg +++ b/pw_cli/py/setup.cfg @@ -34,8 +34,10 @@ pw_cli = py.typed shell_completion/common.bash shell_completion/pw.bash + shell_completion/pw.fish shell_completion/pw.zsh shell_completion/pw_build.bash shell_completion/pw_build.zsh + shell_completion/fish/pw.fish shell_completion/zsh/pw/_pw shell_completion/zsh/pw_build/_pw_build