"""Bash completion script generator. Generates static bash completion scripts using COMPREPLY and compgen. Targets bash 3.2+ with no external dependencies. """ import re from typing import TYPE_CHECKING from cyclopts.annotations import is_iterable_type from cyclopts.completion._base import ( CompletionAction, CompletionData, clean_choice_text, escape_for_shell_pattern, extract_completion_data, get_completion_action, ) if TYPE_CHECKING: from cyclopts import App def generate_completion_script(app: "App", prog_name: str) -> str: """Generate bash completion script. Parameters ---------- app : App The Cyclopts application to generate completion for. prog_name : str Program name (alphanumeric with hyphens/underscores). Returns ------- str Complete bash 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.") func_name = prog_name.replace("-", "_") completion_data = extract_completion_data(app) lines = [ f"# Bash completion for {prog_name}", "# Generated by Cyclopts", "", f"_{func_name}() {{", " local cur prev", "", ] lines.extend(_generate_completion_function_body(completion_data, prog_name, app)) lines.extend(["}"]) lines.append("") lines.append(f"complete -F _{func_name} {prog_name}") lines.append("") return "\n".join(lines) def _escape_bash_choice(choice: str) -> str: r"""Escape single quotes for bash strings.""" return choice.replace("'", "'\\''") def _escape_bash_description(text: str) -> str: r"""Escape description text for bash comments.""" text = text.replace("\n", " ") text = text.replace("\r", " ") return text def _map_completion_action_to_bash(action: CompletionAction) -> str: """Map completion action to bash compgen flags. Parameters ---------- action : CompletionAction Completion action type. Returns ------- str Compgen flags ("-f", "-d", or ""). """ if action == CompletionAction.FILES: return "-f" elif action == CompletionAction.DIRECTORIES: return "-d" return "" def _generate_completion_function_body( completion_data: dict[tuple[str, ...], CompletionData], prog_name: str, app: "App", ) -> list[str]: """Generate the body of the bash completion function. Parameters ---------- completion_data : dict All extracted completion data. prog_name : str Program name. app : App Application instance. Returns ------- list[str] Lines of bash code for the completion function body. """ lines = [] lines.append(' cur="${COMP_WORDS[COMP_CWORD]}"') lines.append(' prev="${COMP_WORDS[COMP_CWORD-1]}"') lines.append("") lines.extend(_generate_command_path_detection(completion_data)) lines.append("") lines.extend(_generate_completion_logic(completion_data, prog_name, app)) return lines def _generate_command_path_detection(completion_data: dict[tuple[str, ...], CompletionData]) -> list[str]: """Generate bash code to detect the current command path. This function generates two passes through COMP_WORDS: 1. First pass builds cmd_path by identifying valid command names 2. Second pass counts positionals (non-option words after the command path) The two-pass approach is necessary because we need to know the full command path length before we can correctly identify which words are positionals. Note: all_commands is built globally across all command levels. If a positional argument value happens to match a command name from a different level, it could be incorrectly classified (though this represents poor CLI design). Parameters ---------- completion_data : dict All extracted completion data. Returns ------- list[str] Lines of bash code for command path detection. """ options_with_values = set() all_commands = 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) for registered_command in data.commands: for cmd_name in registered_command.names: if not cmd_name.startswith("-"): all_commands.add(cmd_name) lines = [] lines.append(" # Build list of options that take values (to skip their arguments)") if options_with_values: escaped_opts = [_escape_bash_choice(opt) for opt in sorted(options_with_values)] opts_str = " ".join(escaped_opts) lines.append(f" local options_with_values='{opts_str}'") else: lines.append(" local options_with_values=''") lines.append("") lines.append(" # Build list of all valid command names (to distinguish from positionals)") if all_commands: escaped_cmds = [_escape_bash_choice(cmd) for cmd in sorted(all_commands)] cmds_str = " ".join(escaped_cmds) lines.append(f" local all_commands='{cmds_str}'") else: lines.append(" local all_commands=''") lines.append("") lines.append(" # Detect command path by collecting valid command words only") lines.append(" local -a cmd_path=()") lines.append(" local i skip_next=0") lines.append(" for ((i=1; i list[str]: """Generate the main completion logic using case statements. Parameters ---------- completion_data : dict All extracted completion data. prog_name : str Program name. app : App Application instance. Returns ------- list[str] Lines of bash code for completion logic. """ lines = [] help_flags = tuple(app.help_flags) if app.help_flags else () version_flags = tuple(app.version_flags) if app.version_flags else () lines.append(" # Determine command level and generate completions") lines.append(' case "${#cmd_path[@]}" in') max_depth = max(len(path) for path in completion_data.keys()) for depth in range(max_depth + 1): relevant_paths = [path for path in completion_data.keys() if len(path) == depth] if not relevant_paths: continue lines.append(f" {depth})") if depth == 0: lines.extend(_generate_completions_for_path(completion_data, (), " ", help_flags, version_flags)) else: lines.append(' case "${cmd_path[@]}" in') for path in sorted(relevant_paths): # Escape glob characters in command names for case pattern matching escaped_path = [escape_for_shell_pattern(cmd) for cmd in path] path_str = " ".join(escaped_path) lines.append(f' "{path_str}")') lines.extend( _generate_completions_for_path(completion_data, path, " ", help_flags, version_flags) ) lines.append(" ;;") lines.append(" *)") lines.append(" ;;") lines.append(" esac") lines.append(" ;;") lines.append(" *)") lines.append(" ;;") lines.append(" esac") return lines def _generate_completions_for_path( completion_data: dict[tuple[str, ...], CompletionData], command_path: tuple[str, ...], indent: str, help_flags: tuple[str, ...], version_flags: tuple[str, ...], ) -> list[str]: """Generate completions for a specific command path. Parameters ---------- completion_data : dict All extracted completion data. command_path : tuple[str, ...] Current command path. indent : str Indentation string. help_flags : tuple[str, ...] Help flag names. version_flags : tuple[str, ...] Version flag names. Returns ------- list[str] Lines of bash code for completions at this command path. """ if command_path not in completion_data: return [f"{indent}COMPREPLY=()"] data = completion_data[command_path] lines = [] options = [] keyword_args = [arg for arg in data.arguments if not arg.is_positional_only() and arg.show] for argument in keyword_args: for name in argument.parameter.name or []: if name.startswith("-"): options.append(name) for name in argument.negatives: if name.startswith("-"): options.append(name) flag_commands = [] for registered_command in data.commands: for name in registered_command.names: if name.startswith("-"): flag_commands.append(name) for flag in help_flags: if flag.startswith("-") and flag not in options and flag not in flag_commands: options.append(flag) for flag in version_flags: if flag.startswith("-") and flag not in options and flag not in flag_commands: options.append(flag) options.extend(flag_commands) commands = [] for registered_command in data.commands: for cmd_name in registered_command.names: if not cmd_name.startswith("-"): commands.append(cmd_name) positional_args = [arg for arg in data.arguments if arg.index is not None and arg.show] positional_args.sort(key=lambda a: a.index if a.index is not None else 0) lines.append(f"{indent}if [[ ${{cur}} == -* ]]; then") if options: escaped_options = [_escape_bash_choice(opt) for opt in options] options_str = " ".join(escaped_options) lines.append(f"{indent} COMPREPLY=( $(compgen -W '{options_str}' -- \"${{cur}}\") )") else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent}else") needs_value_completion = _check_if_prev_needs_value(data.arguments) if needs_value_completion: value_completion_lines = _generate_value_completion_for_prev( data.arguments, commands, positional_args, f"{indent} " ) lines.extend(value_completion_lines) elif commands: escaped_commands = [_escape_bash_choice(cmd) for cmd in commands] commands_str = " ".join(escaped_commands) lines.append(f"{indent} COMPREPLY=( $(compgen -W '{commands_str}' -- \"${{cur}}\") )") elif positional_args: lines.extend(_generate_positional_completion(positional_args, f"{indent} ")) else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent}fi") return lines def _generate_positional_completion(positional_args, indent: str) -> list[str]: """Generate position-aware positional argument completion. Parameters ---------- positional_args : list List of positional arguments sorted by index. indent : str Indentation string. Returns ------- list[str] Lines of bash code for position-aware positional completion. """ lines = [] if len(positional_args) == 1: # Single positional - simple case choices = positional_args[0].get_choices(force=True) action = get_completion_action(positional_args[0].hint) if choices: escaped_choices = [_escape_bash_choice(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) lines.append(f"{indent}COMPREPLY=( $(compgen -W '{choices_str}' -- \"${{cur}}\") )") else: compgen_flag = _map_completion_action_to_bash(action) if compgen_flag: lines.append(f'{indent}COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') else: lines.append(f"{indent}COMPREPLY=()") else: # Multiple positionals - use case statement for position-aware completion lines.append(f"{indent}case ${{positional_count}} in") for idx, argument in enumerate(positional_args): choices = argument.get_choices(force=True) action = get_completion_action(argument.hint) lines.append(f"{indent} {idx})") if choices: escaped_choices = [_escape_bash_choice(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) lines.append(f"{indent} COMPREPLY=( $(compgen -W '{choices_str}' -- \"${{cur}}\") )") else: compgen_flag = _map_completion_action_to_bash(action) if compgen_flag: lines.append(f'{indent} COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent} ;;") # Default case for positions beyond defined positionals # If any positional is a collection type, use it as the default iterable_arg = next((arg for arg in positional_args if is_iterable_type(arg.hint)), None) lines.append(f"{indent} *)") if iterable_arg: choices = iterable_arg.get_choices(force=True) action = get_completion_action(iterable_arg.hint) if choices: escaped_choices = [_escape_bash_choice(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) lines.append(f"{indent} COMPREPLY=( $(compgen -W '{choices_str}' -- \"${{cur}}\") )") else: compgen_flag = _map_completion_action_to_bash(action) if compgen_flag: lines.append(f'{indent} COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') else: lines.append(f"{indent} COMPREPLY=()") else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent} ;;") lines.append(f"{indent}esac") return lines def _check_if_prev_needs_value(arguments) -> bool: """Check if any options take values, requiring prev-word completion logic. Parameters ---------- arguments : ArgumentCollection Arguments to check. Returns ------- bool True if any option (starts with -) takes a value (is not a flag). """ for argument in arguments: if not argument.is_flag(): for name in argument.parameter.name or []: if name.startswith("-"): return True return False def _generate_value_completion_for_prev(arguments, commands: list[str], positional_args, indent: str) -> list[str]: """Generate value completion based on previous word. Parameters ---------- arguments : ArgumentCollection Arguments with potential values. commands : list[str] Available commands at this level. positional_args : list List of positional arguments sorted by index. indent : str Indentation string. Returns ------- list[str] Lines of bash code for value completion. """ lines = [] lines.append(f'{indent}case "${{prev}}" in') has_cases = False for argument in arguments: if argument.is_flag(): continue names = [name for name in (argument.parameter.name or []) if name.startswith("-")] if not names: continue has_cases = True choices = argument.get_choices(force=True) action = get_completion_action(argument.hint) for name in names: lines.append(f"{indent} {name})") if choices: escaped_choices = [_escape_bash_choice(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) lines.append(f"{indent} COMPREPLY=( $(compgen -W '{choices_str}' -- \"${{cur}}\") )") else: compgen_flag = _map_completion_action_to_bash(action) if compgen_flag: lines.append(f'{indent} COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent} ;;") if has_cases: lines.append(f"{indent} *)") if commands: escaped_commands = [_escape_bash_choice(cmd) for cmd in commands] commands_str = " ".join(escaped_commands) lines.append(f"{indent} COMPREPLY=( $(compgen -W '{commands_str}' -- \"${{cur}}\") )") elif positional_args: lines.extend(_generate_positional_completion(positional_args, f"{indent} ")) else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent} ;;") lines.append(f"{indent}esac") else: lines = [] if commands: escaped_commands = [_escape_bash_choice(cmd) for cmd in commands] commands_str = " ".join(escaped_commands) lines.append(f"{indent}COMPREPLY=( $(compgen -W '{commands_str}' -- \"${{cur}}\") )") elif positional_args: lines.extend(_generate_positional_completion(positional_args, indent)) else: lines.append(f"{indent}COMPREPLY=()") return lines