"""Fish completion script generator. Generates static fish completion scripts using `complete -c COMMAND` statements. Completions auto-load from ~/.config/fish/completions/PROGNAME.fish. """ import re from typing import TYPE_CHECKING from cyclopts.completion._base import ( CompletionAction, CompletionData, clean_choice_text, extract_completion_data, get_completion_action, strip_markup, ) if TYPE_CHECKING: from cyclopts import App from cyclopts.command_spec import CommandSpec def generate_completion_script(app: "App", prog_name: str) -> str: """Generate fish completion script. Parameters ---------- app : App The Cyclopts application to generate completion for. prog_name : str Program name for completion (alphanumeric with hyphens/underscores). Returns ------- str Complete fish completion script. Raises ------ ValueError If prog_name contains invalid characters. """ if not prog_name or not re.match(r"^[a-zA-Z0-9_-]+$", prog_name): raise ValueError(f"Invalid prog_name: {prog_name!r}. Must be alphanumeric with hyphens/underscores.") completion_data = extract_completion_data(app) lines = [ f"# Fish completion for {prog_name}", "# Generated by Cyclopts", "", ] has_nested_commands = any(len(path) > 0 for path in completion_data.keys()) if has_nested_commands: lines.extend(_generate_helper_functions(prog_name, completion_data)) lines.append("") help_flags = tuple(app.help_flags) if app.help_flags else () version_flags = tuple(app.version_flags) if app.version_flags else () lines.extend(_generate_completions(completion_data, prog_name, help_flags, version_flags)) return "\n".join(lines) + "\n" def _escape_fish_string(text: str) -> str: r"""Escape single quotes for fish strings.""" return text.replace("'", r"'\''") def _escape_fish_description(text: str) -> str: """Escape description text for fish.""" text = text.replace("\n", " ") text = text.replace("\r", " ") return _escape_fish_string(text) def _generate_helper_functions( prog_name: str, completion_data: dict[tuple[str, ...], CompletionData], ) -> list[str]: """Generate helper function for command path detection. Parameters ---------- prog_name : str Program name. completion_data : dict Completion data used to identify options that take values. Returns ------- list[str] Lines defining the helper function. """ options_with_values = set() for data in completion_data.values(): for argument in data.arguments: if not argument.is_flag() and argument.parameter.name: for name in argument.parameter.name: if name.startswith("-"): options_with_values.add(name) func_name = f"__fish_{prog_name}_using_command" lines = [ "# Helper function to check exact command path sequence", f"function {func_name}", " set -l cmd (commandline -opc)", " set -l subcommands", ] if options_with_values: escaped_opts = " ".join(_escape_fish_string(opt) for opt in sorted(options_with_values)) lines.append(f" set -l options_with_values '{escaped_opts}'") else: lines.append(" set -l options_with_values ''") lines.extend( [ " set -l skip_next 0", " # Extract non-option words (commands) from command line", " for i in (seq 2 (count $cmd))", " set -l word $cmd[$i]", " if test $skip_next -eq 1", " set skip_next 0", " continue", " end", " if string match -qr -- '^-' $word", " # Check if this option takes a value (exact match)", ' if string match -q -- "* $word *" " $options_with_values "', " set skip_next 1", " end", " else", " # Non-option word is a command", " set -a subcommands $word", " end", " end", " # Check if subcommand sequence matches expected path", " if test (count $subcommands) -ne (count $argv)", " return 1", " end", " for i in (seq 1 (count $argv))", " if test $subcommands[$i] != $argv[$i]", " return 1", " end", " end", " return 0", "end", ] ) return lines def _map_completion_action_to_fish(action: CompletionAction) -> str: """Map completion action to fish flags. Parameters ---------- action : CompletionAction Completion action type. Returns ------- str Fish completion flags ("-r -F" for files, "-r -a '(...)'" for directories, "" otherwise). """ if action == CompletionAction.FILES: return "-r -F" if action == CompletionAction.DIRECTORIES: return "-r -a '(__fish_complete_directories)'" return "" def _generate_completions( completion_data: dict[tuple[str, ...], CompletionData], prog_name: str, help_flags: tuple[str, ...], version_flags: tuple[str, ...], ) -> list[str]: """Generate all fish completion commands. Parameters ---------- completion_data : dict Extracted completion data. prog_name : str Program name. help_flags : tuple[str, ...] Help flags. version_flags : tuple[str, ...] Version flags. Returns ------- list[str] Completion command lines. """ lines = [] for command_path, _data in sorted(completion_data.items()): lines.extend( _generate_completions_for_path( completion_data, command_path, prog_name, help_flags, version_flags, ) ) if command_path != max(completion_data.keys(), key=len): lines.append("") return lines def _generate_completions_for_path( completion_data: dict[tuple[str, ...], CompletionData], command_path: tuple[str, ...], prog_name: str, help_flags: tuple[str, ...], version_flags: tuple[str, ...], ) -> list[str]: """Generate completions for a specific command path. Parameters ---------- completion_data : dict Extracted completion data. command_path : tuple[str, ...] Command path. prog_name : str Program name. help_flags : tuple[str, ...] Help flags. version_flags : tuple[str, ...] Version flags. Returns ------- list[str] Completion command lines. """ if command_path not in completion_data: return [] data = completion_data[command_path] lines = [] condition = _get_condition_for_path(command_path, prog_name) lines.extend(_generate_subcommand_completions(data, command_path, prog_name, condition)) keyword_args = [arg for arg in data.arguments if not arg.is_positional_only() and arg.show] if keyword_args or help_flags or version_flags: lines.extend(_generate_option_section_header(command_path)) lines.extend(_generate_help_version_completions(prog_name, condition, help_flags, version_flags)) lines.extend(_generate_keyword_arg_completions(keyword_args, prog_name, condition, data.help_format)) lines.extend(_generate_command_option_completions(data.commands, prog_name, condition, data.help_format)) return lines def _generate_subcommand_completions( data: CompletionData, command_path: tuple[str, ...], prog_name: str, condition: str, ) -> list[str]: """Generate completions for subcommands. Parameters ---------- data : CompletionData Completion data. command_path : tuple[str, ...] Command path. prog_name : str Program name. condition : str Fish condition. Returns ------- list[str] Completion command lines. """ commands = [ name for registered_command in data.commands for name in registered_command.names if not name.startswith("-") ] if not commands: return [] lines = [] if command_path: lines.append(f"# Subcommands for: {' '.join(command_path)}") else: lines.append("# Root-level commands") for registered_command in data.commands: for cmd_name in registered_command.names: if cmd_name.startswith("-"): continue desc = _get_description_from_app(registered_command.app, data.help_format) escaped_desc = _escape_fish_description(desc) escaped_cmd = _escape_fish_string(cmd_name) lines.append(f"complete -c {prog_name} {condition} -a '{escaped_cmd}' -d '{escaped_desc}'") return lines def _generate_option_section_header(command_path: tuple[str, ...]) -> list[str]: """Generate section header comment for options. Parameters ---------- command_path : tuple[str, ...] Command path. Returns ------- list[str] Comment line. """ if command_path: return [f"# Options for: {' '.join(command_path)}"] return ["# Root-level options"] def _generate_help_version_completions( prog_name: str, condition: str, help_flags: tuple[str, ...], version_flags: tuple[str, ...], ) -> list[str]: """Generate completions for help and version flags. Parameters ---------- prog_name : str Program name. condition : str Fish condition. help_flags : tuple[str, ...] Help flags. version_flags : tuple[str, ...] Version flags. Returns ------- list[str] Completion command lines. """ lines = [] for flag in help_flags: if flag.startswith("--"): long_name = flag[2:] lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d 'Display this message and exit.'") elif flag.startswith("-") and len(flag) == 2: short_name = flag[1] lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d 'Display this message and exit.'") for flag in version_flags: if flag.startswith("--"): long_name = flag[2:] lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d 'Display application version.'") elif flag.startswith("-") and len(flag) == 2: short_name = flag[1] lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d 'Display application version.'") return lines def _generate_keyword_arg_completions( keyword_args: list, prog_name: str, condition: str, help_format: str, ) -> list[str]: """Generate completions for keyword arguments. Parameters ---------- keyword_args : list Keyword arguments. prog_name : str Program name. condition : str Fish condition. help_format : str Help text format. Returns ------- list[str] Completion command lines. """ lines = [] for argument in keyword_args: desc = strip_markup(argument.parameter.help or "", format=help_format) escaped_desc = _escape_fish_description(desc) is_flag = argument.is_flag() choices = argument.get_choices(force=True) action = get_completion_action(argument.hint) for name in argument.parameter.name or []: if not name.startswith("-"): continue if name.startswith("--"): long_name = name[2:] line_parts = [f"complete -c {prog_name} {condition} -l {long_name}"] elif len(name) == 2: short_name = name[1] line_parts = [f"complete -c {prog_name} {condition} -s {short_name}"] else: continue if is_flag: line_parts.append(f"-d '{escaped_desc}'") elif choices: escaped_choices = [_escape_fish_string(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) line_parts.append(f"-x -a '{choices_str}' -d '{escaped_desc}'") else: action_flags = _map_completion_action_to_fish(action) if action_flags: line_parts.append(f"{action_flags} -d '{escaped_desc}'") else: line_parts.append(f"-r -d '{escaped_desc}'") lines.append(" ".join(line_parts)) for name in argument.negatives: if not name.startswith("-"): continue if name.startswith("--"): long_name = name[2:] lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d '{escaped_desc}'") elif len(name) == 2: short_name = name[1] lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d '{escaped_desc}'") return lines def _generate_command_option_completions( commands: list, prog_name: str, condition: str, help_format: str, ) -> list[str]: """Generate completions for commands that look like options. Parameters ---------- commands : list List of RegisteredCommand tuples. prog_name : str Program name. condition : str Fish condition. help_format : str Help text format. Returns ------- list[str] Completion command lines. """ lines = [] for registered_command in commands: for cmd_name in registered_command.names: if not cmd_name.startswith("-"): continue desc = _get_description_from_app(registered_command.app, help_format) escaped_desc = _escape_fish_description(desc) if cmd_name.startswith("--"): long_name = cmd_name[2:] lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d '{escaped_desc}'") elif len(cmd_name) == 2: short_name = cmd_name[1] lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d '{escaped_desc}'") return lines def _get_condition_for_path(command_path: tuple[str, ...], prog_name: str) -> str: """Generate fish condition string for a command path. Parameters ---------- command_path : tuple[str, ...] Command path (empty for root). prog_name : str Program name. Returns ------- str Fish condition flag. """ if not command_path: return "-n __fish_use_subcommand" func_name = f"__fish_{prog_name}_using_command" escaped_commands = " ".join(_escape_fish_string(cmd) for cmd in command_path) return f"-n '{func_name} {escaped_commands}'" def _get_description_from_app(cmd_app: "App | CommandSpec", help_format: str) -> str: """Extract description from App. Parameters ---------- cmd_app : App | CommandSpec Command app or spec. help_format : str Help text format. Returns ------- str Description text. """ from cyclopts.help.help import docstring_parse try: parsed = docstring_parse(cmd_app.help, "plaintext") text = parsed.short_description or "" except Exception: text = str(cmd_app.help or "") return strip_markup(text, format=help_format)