"""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
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", help_format: str) -> str:
"""Extract description from App.
Parameters
----------
cmd_app : App
Command app.
help_format : str
Help text format.
Returns
-------
str
Description text.
"""
from cyclopts.help.help import docstring_parse
if not cmd_app.help:
return ""
try:
parsed = docstring_parse(cmd_app.help, "plaintext")
text = parsed.short_description or ""
except Exception:
text = str(cmd_app.help)
return strip_markup(text, format=help_format)