"""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.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<COMP_CWORD; i++)); do")
lines.append(' local word="${COMP_WORDS[i]}"')
lines.append(" if [[ $skip_next -eq 1 ]]; then")
lines.append(" skip_next=0")
lines.append(" continue")
lines.append(" fi")
lines.append(" if [[ $word =~ ^- ]]; then")
lines.append(" # Check if this option takes a value")
lines.append(' if [[ " $options_with_values " =~ " $word " ]]; then')
lines.append(" skip_next=1")
lines.append(" fi")
lines.append(" else")
lines.append(" # Non-option word - only add to cmd_path if it's a valid command")
lines.append(' if [[ " $all_commands " =~ " $word " ]]; then')
lines.append(' cmd_path+=("$word")')
lines.append(" fi")
lines.append(" fi")
lines.append(" done")
lines.append("")
lines.append(" # Count positionals (non-option words after command path)")
lines.append(" local positional_count=0")
lines.append(" local cmd_path_len=${#cmd_path[@]}")
lines.append(" skip_next=0")
lines.append(" local cmd_depth=0")
lines.append(" for ((i=1; i<COMP_CWORD; i++)); do")
lines.append(' local word="${COMP_WORDS[i]}"')
lines.append(" if [[ $skip_next -eq 1 ]]; then")
lines.append(" skip_next=0")
lines.append(" continue")
lines.append(" fi")
lines.append(" if [[ $word =~ ^- ]]; then")
lines.append(' if [[ " $options_with_values " =~ " $word " ]]; then')
lines.append(" skip_next=1")
lines.append(" fi")
lines.append(" else")
lines.append(" # Non-option word")
lines.append(" if [[ $cmd_depth -lt $cmd_path_len ]]; then")
lines.append(" # Still in command path")
lines.append(" ((cmd_depth++))")
lines.append(" else")
lines.append(" # Past command path, this is a positional")
lines.append(" ((positional_count++))")
lines.append(" fi")
lines.append(" fi")
lines.append(" done")
return lines
def _generate_completion_logic(
completion_data: dict[tuple[str, ...], CompletionData],
prog_name: str,
app: "App",
) -> 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
lines.append(f"{indent} *)")
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