diff --git a/falyx/completer.py b/falyx/completer.py index cca7ae7..9b74f37 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -1,21 +1,32 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -"""Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI -menus using Prompt Toolkit. +"""Prompt Toolkit completion support for routed Falyx command input. -This completer supports: -- Command key and alias completion (e.g. `R`, `HELP`, `X`) -- Argument flag completion for registered commands (e.g. `--tag`, `--name`) -- Context-aware suggestions based on cursor position and argument structure -- Interactive value completions (e.g. choices and suggestions defined per argument) -- File/path-friendly behavior, quoting completions with spaces automatically +This module defines `FalyxCompleter`, the interactive completion layer used by +Falyx menu and prompt-driven CLI sessions. The completer is routing-aware: it +delegates namespace traversal to `Falyx.resolve_completion_route()` and only +hands control to a command's `CommandArgumentParser` after a leaf command has +been identified. +Completion behavior is split into two phases: -Completions are generated from: -- Registered commands in `Falyx` -- Argument metadata and `suggest_next()` from `CommandArgumentParser` +1. Namespace completion + While the user is still selecting a command or namespace entry, completion + candidates are derived from the active namespace via + `iter_completion_names`. Namespace-level help flags such as `-h`, `--help`, + `-T`, and `--tldr` are also suggested when appropriate. +2. Leaf-command completion + Once routing reaches a concrete command, the remaining argv fragment is + delegated to `CommandArgumentParser.suggest_next()` so command-specific + flags, values, choices, and positional suggestions can be surfaced. -Integrated with the `Falyx.prompt_session` to enhance the interactive experience. +The completer also supports preview-prefixed input such as `?deploy`, preserves +shell-safe quoting for suggestions containing whitespace, and integrates +directly with Prompt Toolkit's completion API by yielding `Completion` +instances. + +Typical usage: + session = PromptSession(completer=FalyxCompleter(falyx)) """ from __future__ import annotations @@ -27,183 +38,177 @@ from typing import TYPE_CHECKING, Iterable from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.document import Document -from falyx.namespace import FalyxNamespace - if TYPE_CHECKING: from falyx import Falyx class FalyxCompleter(Completer): - """Prompt Toolkit completer for Falyx CLI command input. + """Prompt Toolkit completer for routed Falyx input. - This completer provides real-time, context-aware suggestions for: - - Command keys and aliases (resolved via Falyx._entry_map) - - CLI argument flags and values for each command - - Suggestions and choices defined in the associated CommandArgumentParser + `FalyxCompleter` provides context-aware completions for interactive Falyx + sessions. It first asks the owning `Falyx` instance to resolve the current + input into a partial completion route. Based on that route, it either: - It leverages `CommandArgumentParser.suggest_next()` to compute valid completions - based on current argument state, including: - - Remaining required or optional flags - - Flag value suggestions (choices or custom completions) - - Next positional argument hints - - Inserts longest common prefix (LCP) completions when applicable - - Handles special cases like quoted strings and spaces - - Supports dynamic argument suggestions (e.g. flags, file paths, etc.) + - suggests visible entries from the active namespace, or + - delegates argument completion to the resolved command's argument parser. + + This keeps completion aligned with Falyx's routing model so nested + namespaces, preview-prefixed commands, and command-local argument parsing + all behave consistently with actual execution. Args: - falyx (Falyx): The active Falyx instance providing command and parser context. + falyx (Falyx): Active Falyx application instance used to resolve routes + and retrieve completion candidates. """ - def __init__(self, falyx: "Falyx"): - self.falyx = falyx - - @property - def _command_names(self) -> list[str]: - names: list[str] = [] - seen: set[str] = set() - - def add(name: str): - normalized = name.upper() - if normalized not in seen: - seen.add(normalized) - names.append(name) - - for command in self.falyx.commands.values(): - add(command.key) - for alias in command.aliases: - add(alias) - - for command in self.falyx.builtins.values(): - add(command.key) - for alias in command.aliases: - add(alias) - - if self.falyx.history_command: - add(self.falyx.history_command.key) - for alias in self.falyx.history_command.aliases: - add(alias) - - add(self.falyx.exit_command.key) - for alias in self.falyx.exit_command.aliases: - add(alias) - - return names - - def _resolve_command_for_completion(self, token: str): - normalized = token.upper().strip() - entry_map = self.falyx._entry_map - - if normalized in entry_map: - return entry_map[normalized] - - matches = [] - seen = set() - for key, command in entry_map.items(): - if key.startswith(normalized) and id(command) not in seen: - matches.append(command) - seen.add(id(command)) - - if len(matches) == 1: - return matches[0] - return None - - def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: - """Compute completions for the current user input. - - Analyzes the input buffer, determines whether the user is typing: - • A command key/alias - • A flag/option - • An argument value - - and yields appropriate completions. + def __init__(self, falyx: Falyx): + """Initialize the completer with a bound Falyx instance. Args: - document (Document): The current Prompt Toolkit document (input buffer & cursor). - complete_event: The triggering event (TAB key, menu display, etc.) — not used here. + falyx (Falyx): Active Falyx application that owns the routing and + command metadata used for completion. + """ + self.falyx = falyx + + def get_completions(self, document: Document, complete_event): + """Yield completions for the current input buffer. + + This method is the main Prompt Toolkit completion entrypoint. It parses + the text before the cursor, determines whether the user is still routing + through namespaces or has already reached a leaf command, and then + yields matching `Completion` objects. + + Behavior: + - Splits the current input using `shlex.split()`. + - Detects preview-mode input prefixed with `?`. + - Separates committed tokens from the active stub under the cursor. + - Resolves the partial route through `Falyx.resolve_completion_route()`. + - Suggests namespace entries and namespace help flags while routing. + - Delegates leaf-command completion to + `CommandArgumentParser.suggest_next()` once a command is resolved. + - Preserves shell-safe quoting for suggestions containing spaces. + + Args: + document (Document): Prompt Toolkit document representing the current + input buffer and cursor position. + complete_event: Prompt Toolkit completion event metadata. It is not + currently inspected directly. Yields: - Completion: One or more completions matching the current stub text. + Completion: Completion candidates appropriate to the current routed + input state. + + Notes: + - Invalid shell quoting causes completion to stop silently rather + than raising. + - Command-specific completion is only attempted after a concrete leaf + command has been resolved. """ text = document.text_before_cursor try: tokens = shlex.split(text) - cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t")) + cursor_at_end = text.endswith((" ", "\t")) except ValueError: return - if tokens and not cursor_at_end_of_token and tokens[0].startswith("?"): - stub = tokens[0][1:] - suggestions = [c.text for c in self._suggest_commands(stub)] - prefixed = [f"?{s}" for s in suggestions] - yield from self._yield_lcp_completions(prefixed, tokens[0]) + is_preview = False + if tokens and tokens[0].startswith("?"): + is_preview = True + tokens[0] = tokens[0][1:] + + if cursor_at_end: + committed_tokens = tokens + stub = "" + else: + committed_tokens = tokens[:-1] if tokens else [] + stub = tokens[-1] if tokens else "" + + context = self.falyx.get_current_invocation_context().model_copy( + update={"is_preview": is_preview} + ) + + route = self.falyx.resolve_completion_route( + committed_tokens, + stub=stub, + cursor_at_end_of_token=cursor_at_end, + context=context, + is_preview=is_preview, + ) + + # Still selecting an entry in the current namespace + if route.expecting_entry: + suggestions = self._suggest_namespace_entries(route.namespace, route.stub) + + # Only here should namespace-level help/TLDR be suggested. + if not route.command and (not route.stub or route.stub.startswith("-")): + suggestions.extend( + flag + for flag in ("-h", "--help", "-T", "--tldr") + if flag.startswith(route.stub) + ) + + if route.is_preview: + suggestions = [f"?{s}" for s in suggestions] + current_stub = f"?{route.stub}" if route.stub else "?" + else: + current_stub = route.stub + + yield from self._yield_lcp_completions(suggestions, current_stub) return - if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): - # Suggest command keys and aliases - stub = tokens[0] if tokens else "" - suggestions = [c.text for c in self._suggest_commands(stub)] - yield from self._yield_lcp_completions(suggestions, stub) + # Leaf command: CAP owns the rest + if not route.command or not route.command.arg_parser: return - # Identify command - command_key = tokens[0].upper() - command = self._resolve_command_for_completion(command_key) - if isinstance(command, FalyxNamespace): - completer = command.namespace._get_completer() - for completion in completer.get_completions( - Document(" ".join(tokens[1:])), complete_event - ): - yield completion - return - if not command or not command.arg_parser: - return - - # If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it - parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1] - stub = "" if cursor_at_end_of_token else tokens[-1] + leaf_tokens = list(route.leaf_argv) + if route.stub: + leaf_tokens.append(route.stub) try: - suggestions = command.arg_parser.suggest_next( - parsed_args + ([stub] if stub else []), cursor_at_end_of_token + suggestions = route.command.arg_parser.suggest_next( + leaf_tokens, + route.cursor_at_end_of_token, ) - yield from self._yield_lcp_completions(suggestions, stub) except Exception: return - def _suggest_commands(self, prefix: str) -> Iterable[Completion]: - """Suggest top-level command keys and aliases based on the given prefix. + yield from self._yield_lcp_completions(suggestions, route.stub) - Filters all known commands (and `exit`, `help`, `history` built-ins) - to only those starting with the given prefix. + def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]: + """Return matching visible entry names for a namespace prefix. + + This helper filters the current namespace's visible completion names so + only entries beginning with the provided prefix are returned. Case of the + returned value is adjusted to follow the case style of the typed prefix. Args: - prefix (str): The current typed prefix. - - Yields: - Completion: Matching keys or aliases from all registered commands. - """ - for name in self._command_names: - if name.upper().startswith(prefix.upper()): - text = name.lower() if prefix.islower() else name - yield Completion(text, start_position=-len(prefix), display=text) - - def _ensure_quote(self, text: str) -> str: - """Ensure that a suggestion is shell-safe by quoting if needed. - - Adds quotes around completions containing whitespace so they can - be inserted into the CLI without breaking tokenization. - - Args: - text (str): The input text to quote. + namespace (Falyx): Namespace whose entries should be searched for + completion candidates. + prefix (str): Current partially typed entry name. Returns: - str: The quoted text, suitable for shell command usage. + list[str]: Matching namespace entry keys and aliases. + """ + results: list[str] = [] + for name in namespace.iter_completion_names: + if name.upper().startswith(prefix.upper()): + results.append(name.lower() if prefix.islower() else name) + return results + + def _ensure_quote(self, text: str) -> str: + """Quote a completion candidate when it contains whitespace. + + Args: + text (str): Raw completion candidate. + + Returns: + str: Shell-safe candidate wrapped in double quotes when needed. """ if " " in text or "\t" in text: return f'"{text}"' return text - def _yield_lcp_completions(self, suggestions, stub): + def _yield_lcp_completions(self, suggestions, stub) -> Iterable[Completion]: """Yield completions for the current stub using longest-common-prefix logic. Behavior: @@ -219,26 +224,35 @@ class FalyxCompleter(Completer): Yields: Completion: Completion objects for the Prompt Toolkit menu. """ - matches = [s for s in suggestions if s.startswith(stub)] + + if not suggestions: + return + + matches = list(dict.fromkeys(s for s in suggestions if s.startswith(stub))) if not matches: return lcp = os.path.commonprefix(matches) if len(matches) == 1: + match = matches[0] yield Completion( - self._ensure_quote(matches[0]), + self._ensure_quote(match), start_position=-len(stub), - display=matches[0], + display=match, + ) + return + + if len(lcp) > len(stub) and not lcp.startswith("-"): + yield Completion( + self._ensure_quote(lcp), + start_position=-len(stub), + display=lcp, + ) + + for match in matches: + yield Completion( + self._ensure_quote(match), + start_position=-len(stub), + display=match, ) - elif len(lcp) > len(stub) and not lcp.startswith("-"): - yield Completion(lcp, start_position=-len(stub), display=lcp) - for match in matches: - yield Completion( - self._ensure_quote(match), start_position=-len(stub), display=match - ) - else: - for match in matches: - yield Completion( - self._ensure_quote(match), start_position=-len(stub), display=match - ) diff --git a/falyx/completer_types.py b/falyx/completer_types.py new file mode 100644 index 0000000..6b99c4f --- /dev/null +++ b/falyx/completer_types.py @@ -0,0 +1,86 @@ +"""Completion route models for routed Falyx autocompletion. + +This module defines `CompletionRoute`, a lightweight value object used by the +Falyx completion system to describe the partially resolved state of interactive +input during autocompletion. + +`CompletionRoute` sits at the boundary between namespace routing and +command-local argument completion. It captures enough information for the +completer to determine whether it should continue suggesting namespace entries +or delegate to a resolved command's argument parser. + +Typical usage: + - A user types part of a namespace path or command key. + - Falyx resolves as much of that path as possible. + - The resulting `CompletionRoute` describes the active namespace, any + resolved leaf command, the remaining argv fragment, and the current + token stub under the cursor. + - `FalyxCompleter` uses this information to decide what completions to + surface next. + +This module is intentionally small and focused. It does not perform routing or +completion itself; it only models the routed state needed by the completer. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from falyx.context import InvocationContext + +if TYPE_CHECKING: + from falyx.command import Command + from falyx.falyx import Falyx + + +@dataclass(slots=True) +class CompletionRoute: + """Represents a partially resolved route used during autocompletion. + + A `CompletionRoute` describes the current routed state of user input while + Falyx is generating interactive completions. It distinguishes between two + broad states: + + - namespace-routing state, where the user is still selecting a visible entry + within the current namespace + - leaf-command state, where a concrete command has been resolved and the + remaining input should be completed by that command's argument parser + + Attributes: + namespace (Falyx): The active namespace in which completion is currently + taking place. + context (InvocationContext): Invocation-path context used to preserve the + routed command path and render context-aware help or usage text. + command (Command | None): The resolved leaf command, if routing has + already reached a concrete command. Remains `None` while the user is + still navigating namespaces. + leaf_argv (list[str]): Remaining command-local argv tokens that belong to + the resolved leaf command. These are typically passed to the + command's argument parser for completion. + stub (str): The current token fragment under the cursor. This is the + partial text that completion candidates should replace or extend. + cursor_at_end_of_token (bool): Whether the cursor is positioned at the + end of a completed token boundary, such as immediately after a + trailing space. + expecting_entry (bool): Whether completion should suggest namespace + entries rather than command-local arguments. + is_preview (bool): Whether the input is in preview mode, such as when + the user begins the invocation with `?`. + + Notes: + - This model is completion-only and is intentionally separate from + full execution routing types such as `RouteResult`. + - `CompletionRoute` does not validate or parse command arguments; it + only records the routed state needed to decide what should complete + next. + """ + + namespace: Falyx + context: InvocationContext + command: Command | None = None + leaf_argv: list[str] = field(default_factory=list) + stub: str = "" + cursor_at_end_of_token: bool = False + expecting_entry: bool = False + is_preview: bool = False diff --git a/falyx/falyx.py b/falyx/falyx.py index c9b3962..ad71a44 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -62,6 +62,7 @@ from falyx.bottom_bar import BottomBar from falyx.command import Command from falyx.command_executor import CommandExecutor from falyx.completer import FalyxCompleter +from falyx.completer_types import CompletionRoute from falyx.console import console from falyx.context import InvocationContext from falyx.debug import log_after, log_before, log_error, log_success @@ -80,6 +81,7 @@ from falyx.mode import FalyxMode from falyx.namespace import FalyxNamespace from falyx.options_manager import OptionsManager from falyx.parser import CommandArgumentParser, FalyxParser, RootParseResult +from falyx.parser.parser_types import FalyxTLDRExample, FalyxTLDRInput from falyx.prompt_utils import rich_text_to_prompt_text from falyx.protocols import ArgParserProtocol from falyx.retry import RetryPolicy @@ -243,6 +245,7 @@ class Falyx: else: self.history = None self.enable_help_tips = enable_help_tips + self._tldr_examples: list[FalyxTLDRExample] = [] self._register_default_builtins() self._register_options() self._executor = CommandExecutor( @@ -251,6 +254,51 @@ class Falyx: console=self.console, ) + def _print_suggestions_message(self, key: str, suggestions: list[str]) -> None: + """Prints a message with suggestions for the user.""" + if not suggestions: + self.console.print( + f"[{OneColors.DARK_RED}]❌ No command, alias, or namespace found for '{key}'.[/]" + ) + return None + self.console.print( + f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command, alias, or namespace '{key}'. Did you mean: [/]" + f"{', '.join(suggestions)[:10]}" + ) + + def add_tldr_example( + self, + *, + entry_key: str, + usage: str, + description: str, + ) -> None: + """Adds a TLDR example to the Falyx instance.""" + self._tldr_examples.append( + FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description) + ) + + def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None: + """Adds TLDR examples to the Falyx instance.""" + for example in examples: + if isinstance(example, FalyxTLDRExample): + self._tldr_examples.append(example) + elif len(example) == 3: + entry_key, usage, description = example + self._tldr_examples.append( + FalyxTLDRExample( + entry_key=entry_key, + usage=usage, + description=description, + ) + ) + else: + raise FalyxError( + f"Invalid TLDR example format: {example}. " + "Examples must be either FalyxTLDRExample instances " + "or tuples of (entry_key, usage, description).", + ) + def get_current_invocation_context(self) -> InvocationContext: """Returns the current invocation context.""" return InvocationContext( @@ -296,6 +344,46 @@ class Falyx: if not self.options.get("invocation_path"): self.options.set("invocation_path", self.program) + @property + def iter_completion_names(self) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + + def add(name: str) -> None: + normalized = name.upper().strip() + if normalized not in seen: + seen.add(normalized) + names.append(name) + + for command in self.commands.values(): + if not command.hidden: + add(command.key) + for alias in command.aliases: + add(alias) + + for namespace in self.namespaces.values(): + if not namespace.hidden: + add(namespace.key) + for alias in namespace.aliases: + add(alias) + + for command in self.builtins.values(): + if not command.hidden: + add(command.key) + for alias in command.aliases: + add(alias) + + if self.history_command and not self.history_command.hidden: + add(self.history_command.key) + for alias in self.history_command.aliases: + add(alias) + + add(self.exit_command.key) + for alias in self.exit_command.aliases: + add(alias) + + return names + @property def _entry_map(self) -> dict[str, Command | FalyxNamespace]: """Builds a mapping of all valid input names to Command objects. @@ -318,19 +406,6 @@ class Falyx: else: mapping[norm] = entry - for special in [self.exit_command, self.history_command]: - if special: - register(special.key, special) - for alias in special.aliases: - register(alias, special) - register(special.description, special) - - for command in self.builtins.values(): - register(command.key, command) - for alias in command.aliases: - register(alias, command) - register(command.description, command) - for command in self.commands.values(): register(command.key, command) for alias in command.aliases: @@ -341,6 +416,20 @@ class Falyx: register(namespace.key, namespace) for alias in namespace.aliases: register(alias, namespace) + + for command in self.builtins.values(): + register(command.key, command) + for alias in command.aliases: + register(alias, command) + register(command.description, command) + + for special in [self.history_command, self.exit_command]: + if special: + register(special.key, special) + for alias in special.aliases: + register(alias, special) + register(special.description, special) + return mapping def get_title(self) -> str: @@ -582,18 +671,49 @@ class Falyx: if self.enable_help_tips: self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") - async def _render_unknown_route(self, route: RouteResult) -> None: - context = route.context - typed_key = context.typed_path[0].upper() - await route.namespace.render_namespace_help(context) - self.console.print( - f"[{OneColors.DARK_RED}]❌ Unknown Command or FalyxNamespace [{typed_key}]" - ) - return None + def get_usage(self, context: InvocationContext) -> str: + has_namespaces = any(not ns.hidden for ns in self.namespaces.values()) + target = "command" if not has_namespaces else "command or namespace" + if not context.typed_path and context.is_cli_mode: + return escape(f"[-h] [-T] [-v] [-d] [-n] <{target}> [args...]") + elif not context.typed_path: + return escape(f"[-h] [-T] <{target}> [args...]") + return escape(f"<{target}> [args...]") async def _render_namespace_tldr_help(self, context: InvocationContext) -> None: - # TODO: Create namespace tldr - console.print(context.markup_path) + if not self._tldr_examples: + self.console.print( + f"[bold]No TLDR examples available for '{self.get_title()}'.[/bold]" + ) + return None + usage = self.usage or self.get_usage(context) + prefix = context.markup_path + self.console.print( + f"[bold]usage:[/bold] {prefix} [{self.usage_style}]{usage}[/{self.usage_style}]" + ) + if self.description: + self.console.print( + f"\n[{self.description_style}]{self.description}[/{self.description_style}]" + ) + if self._tldr_examples: + self.console.print("\n[bold]examples:[/bold]") + for example in self._tldr_examples: + entry, _ = self.resolve_entry(example.entry_key) + if not entry: + self.console.print( + f"[{OneColors.LIGHT_YELLOW}]⚠️ TLDR example references unknown entry '{example.entry_key}'.[/]" + ) + continue + command = f"[{entry.style}]{example.entry_key}[/{entry.style}]" + usage = f"{prefix} {command} {example.usage.strip()}" + description = example.description.strip() + block = f"[bold]{usage}[/bold]" + self.console.print( + Padding( + Panel(block, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) async def render_namespace_help( self, context: InvocationContext, tldr: bool = False @@ -607,7 +727,7 @@ class Falyx: async def _render_cli_help(self, context: InvocationContext) -> None: """Renders the CLI help menu with all available commands and options.""" - usage = self.usage or "[GLOBAL OPTIONS] [COMMAND] [OPTIONS]" + usage = self.usage or self.get_usage(context) self.console.print( f"[bold]usage:[/bold] {context.markup_path} [{self.usage_style}]{usage}[/{self.usage_style}]" ) @@ -617,6 +737,7 @@ class Falyx: ) self.console.print("\n[bold]global options:[/bold]") self.console.print(f" {'-h, --help':<22}{'Show this help message and exit.'}") + self.console.print(f" {'-T, --tldr':<22}{'Show quick usage examples and exit.'}") self.console.print( f" {'-v, --verbose':<22}{'Enable verbose debug logging for the session.'}" ) @@ -672,21 +793,15 @@ class Falyx: tag: str = "", key: str | None = None, tldr: bool = False, + namespace_tldr: bool = False, invocation_context: InvocationContext | None = None, ) -> None: """Renders the help menu with command details, usage examples, and tips.""" context = invocation_context or self.get_current_invocation_context() if key: - entry, suggestions = self.resolve_entry(key) - if suggestions: - self.console.print( - f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown entry '{key}'. Did you mean:[/]" - f"{', '.join(suggestions)[:10]}" - ) - return None - base_context = self._help_target_base_context(context) + entry, suggestions = self.resolve_entry(key) if isinstance(entry, Command): await self._render_command_help( command=entry, @@ -699,10 +814,9 @@ class Falyx: tldr=tldr, ) else: - # TODO: Should print something helpful here - self.console.print( - f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]" - ) + await self.render_namespace_help(base_context) + self._print_suggestions_message(key, suggestions) + return None elif tldr: await self._render_command_help( self.help_command, @@ -711,10 +825,8 @@ class Falyx: ) elif tag: await self._render_tag_help(tag) - elif self.options.get("mode") == FalyxMode.MENU: - await self._render_menu_help() else: - await self._render_cli_help(context) + await self.render_namespace_help(context, namespace_tldr) def _get_help_command(self) -> Command: """Returns the help command for the menu.""" @@ -766,23 +878,16 @@ class Falyx: async def _preview(self, key: str) -> None: """Previews the execution of a command without actually running it.""" entry, suggestions = self.resolve_entry(key) - if suggestions: - self.console.print( - f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown entry '{key}'. Did you mean:[/]" - f"{', '.join(suggestions)[:10]}" - ) - return None if isinstance(entry, FalyxNamespace): self.console.print( f"❌ Entry '{key}' is a namespace. Please specify a command to preview.", style=OneColors.DARK_RED, ) - return None - if not isinstance(entry, Command): - self.console.print(f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]") - return None - self.console.print(f"Preview of command '{entry.key}': {entry.description}") - await entry.preview() + elif isinstance(entry, Command): + self.console.print(f"Preview of command '{entry.key}': {entry.description}") + await entry.preview() + else: + self._print_suggestions_message(key, suggestions) def _get_preview_command(self) -> Command: """Returns the preview command for Falyx.""" @@ -1371,6 +1476,13 @@ class Falyx: return route, args, kwargs, execution_args + async def _render_unknown_route(self, route: RouteResult) -> None: + context = route.context + typed_key = context.typed_path[0].upper() + await route.namespace.render_namespace_help(context) + self._print_suggestions_message(typed_key, route.suggestions) + return None + async def _dispatch_route( self, route: RouteResult, @@ -1507,6 +1619,74 @@ class Falyx: summary_last_result=summary_last_result, ) + def resolve_completion_route( + self, + committed_tokens: list[str], + *, + stub: str, + cursor_at_end_of_token: bool, + context: InvocationContext, + is_preview: bool = False, + ) -> CompletionRoute: + """Route only until the leaf-command boundary. + + Unlike resolve_route(), this method tolerates incomplete trailing input. + It stops either: + - inside a namespace, where the next token should be an entry, or + - at a leaf command, where remaining tokens belong to that command's argv. + """ + namespace = self + route_context = context + remaining = list(committed_tokens) + + while remaining: + head = remaining.pop(0) + entry, _ = namespace.resolve_entry(head) + + if entry is None: + # Still routing namespace entries; could not resolve this token. + # Let the completer suggest entries or namespace-level help flags. + return CompletionRoute( + namespace=namespace, + context=route_context, + command=None, + leaf_argv=[], + stub=head if not remaining else stub, + cursor_at_end_of_token=cursor_at_end_of_token, + expecting_entry=True, + is_preview=is_preview, + ) + + route_context = route_context.with_path_segment(head, style=entry.style) + + if isinstance(entry, FalyxNamespace): + namespace = entry.namespace + continue + + # Leaf command found: everything after this belongs to CAP unchanged. + return CompletionRoute( + namespace=namespace, + context=route_context, + command=entry, + leaf_argv=remaining, + stub=stub, + cursor_at_end_of_token=cursor_at_end_of_token, + expecting_entry=False, + is_preview=is_preview, + ) + + # No committed leaf yet: next token should be a namespace entry. + return CompletionRoute( + namespace=namespace, + context=route_context, + command=None, + leaf_argv=[], + stub=stub, + cursor_at_end_of_token=cursor_at_end_of_token, + expecting_entry=True, + is_preview=is_preview, + ) + async def resolve_route( self, tokens: list[str], @@ -1696,7 +1876,7 @@ class Falyx: self._apply_parse_result(parse_result) if parse_result.mode == FalyxMode.HELP: - await self.render_help() + await self.render_help(namespace_tldr=parse_result.tldr_requested) sys.exit(0) try: diff --git a/falyx/parser/__init__.py b/falyx/parser/__init__.py index 880bee3..0f35d88 100644 --- a/falyx/parser/__init__.py +++ b/falyx/parser/__init__.py @@ -16,5 +16,5 @@ __all__ = [ "ArgumentAction", "CommandArgumentParser", "FalyxParser", - "ParseResult", + "RootParseResult", ] diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index c213f84..d73c953 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -66,7 +66,13 @@ from falyx.options_manager import OptionsManager from falyx.parser.argument import Argument from falyx.parser.argument_action import ArgumentAction from falyx.parser.group import ArgumentGroup, MutuallyExclusiveGroup -from falyx.parser.parser_types import ArgumentState, TLDRExample, false_none, true_none +from falyx.parser.parser_types import ( + ArgumentState, + TLDRExample, + TLDRInput, + false_none, + true_none, +) from falyx.parser.utils import coerce_value from falyx.signals import HelpSignal @@ -134,7 +140,7 @@ class CommandArgumentParser: help_text: str = "", help_epilog: str = "", aliases: list[str] | None = None, - tldr_examples: list[tuple[str, str]] | None = None, + tldr_examples: list[TLDRInput] | None = None, program: str | None = None, options_manager: OptionsManager | None = None, ) -> None: @@ -250,31 +256,46 @@ class CommandArgumentParser: ) self._register_argument(help) - def add_tldr_examples(self, examples: list[tuple[str, str]]) -> None: + def _add_tldr(self): + """Add TLDR argument to the parser.""" + tldr = Argument( + flags=("--tldr", "-T"), + action=ArgumentAction.TLDR, + help="Show quick usage examples.", + dest="tldr", + ) + self._register_argument(tldr) + + def add_tldr_example(self, usage: str, description: str) -> None: + """Add a single TLDR example to the parser.""" + self._tldr_examples.append(TLDRExample(usage=usage, description=description)) + if "tldr" not in self._dest_set: + self._add_tldr() + + def add_tldr_examples(self, examples: list[TLDRInput]) -> None: """ Add TLDR examples to the parser. Args: - examples (list[tuple[str, str]]): List of (usage, description) tuples. + examples (list[TLDRInput]): List of TLDRExample instances or (usage, description) tuples. """ - if not all( - isinstance(example, tuple) and len(example) == 2 for example in examples - ): - raise CommandArgumentError( - "TLDR examples must be a list of (usage, description) tuples" - ) - - for usage, description in examples: - self._tldr_examples.append(TLDRExample(usage=usage, description=description)) + for example in examples: + if isinstance(example, TLDRExample): + self._tldr_examples.append(example) + elif isinstance(example, tuple) and len(example) == 2: + usage, description = example + self._tldr_examples.append( + TLDRExample(usage=usage, description=description) + ) + else: + raise CommandArgumentError( + f"Invalid TLDR example format: {example}. " + "Examples must be either TLDRExample instances " + "or tuples of (usage, description)." + ) if "tldr" not in self._dest_set: - tldr = Argument( - ("--tldr", "-T"), - action=ArgumentAction.TLDR, - help="Show quick usage examples.", - dest="tldr", - ) - self._register_argument(tldr) + self._add_tldr() def add_argument_group( self, diff --git a/falyx/parser/falyx_parser.py b/falyx/parser/falyx_parser.py index df5bd27..146fddd 100644 --- a/falyx/parser/falyx_parser.py +++ b/falyx/parser/falyx_parser.py @@ -14,6 +14,7 @@ class RootOptions: debug_hooks: bool = False never_prompt: bool = False help: bool = False + tldr: bool = False class FalyxParser: @@ -27,13 +28,17 @@ class FalyxParser: """ ROOT_FLAG_ALIASES: dict[str, str] = { + "-n": "never_prompt", "--never-prompt": "never_prompt", "-v": "verbose", "--verbose": "verbose", + "-d": "debug_hooks", "--debug-hooks": "debug_hooks", "?": "help", "-h": "help", "--help": "help", + "-T": "tldr", + "--tldr": "tldr", } @classmethod @@ -78,13 +83,14 @@ class FalyxParser: argv = argv or [] root, remaining = cls._parse_root_options(argv) - if root.help: + if root.help or root.tldr: return RootParseResult( mode=FalyxMode.HELP, raw_argv=argv, never_prompt=root.never_prompt, verbose=root.verbose, debug_hooks=root.debug_hooks, + tldr_requested=root.tldr, ) return RootParseResult( diff --git a/falyx/parser/parse_result.py b/falyx/parser/parse_result.py index d648bbb..e84bb69 100644 --- a/falyx/parser/parse_result.py +++ b/falyx/parser/parse_result.py @@ -12,3 +12,4 @@ class RootParseResult: debug_hooks: bool = False never_prompt: bool = False remaining_argv: list[str] = field(default_factory=list) + tldr_requested: bool = False diff --git a/falyx/parser/parser_types.py b/falyx/parser/parser_types.py index c803cf4..93ba10a 100644 --- a/falyx/parser/parser_types.py +++ b/falyx/parser/parser_types.py @@ -17,7 +17,7 @@ These tools support richer expressiveness and user-friendly ergonomics in Falyx's declarative command-line interfaces. """ from dataclasses import dataclass -from typing import Any +from typing import Any, TypeAlias from falyx.parser.argument import Argument @@ -50,6 +50,21 @@ class TLDRExample: description: str +TLDRInput: TypeAlias = TLDRExample | tuple[str, str] + + +@dataclass(frozen=True) +class FalyxTLDRExample: + """Represents a usage example for Falyx TLDR output, with optional metadata.""" + + entry_key: str + usage: str + description: str + + +FalyxTLDRInput: TypeAlias = FalyxTLDRExample | tuple[str, str, str] + + def true_none(value: Any) -> bool | None: """Return True if value is not None, else None.""" if value is None: diff --git a/tests/test_completer/test_completer.py b/tests/test_completer/test_completer.py index 42d21bc..26d26b0 100644 --- a/tests/test_completer/test_completer.py +++ b/tests/test_completer/test_completer.py @@ -1,3 +1,5 @@ +import re + import pytest from prompt_toolkit.completion import Completion from prompt_toolkit.document import Document @@ -7,99 +9,241 @@ from falyx.completer import FalyxCompleter from falyx.parser import CommandArgumentParser +def completion_texts(completions) -> list[str]: + return [c.text for c in completions] + + @pytest.fixture def falyx(): flx = Falyx() - parser = CommandArgumentParser( + + run_parser = CommandArgumentParser( command_key="R", command_description="Run Command", ) - parser.add_argument( - "--tag", - ) - parser.add_argument( - "--name", - ) + run_parser.add_argument("--tag") + run_parser.add_argument("--name") + flx.add_command( "R", "Run Command", - lambda x: None, + lambda: None, aliases=["RUN"], - arg_parser=parser, + arg_parser=run_parser, ) + + ops = Falyx(program="ops") + + deploy_parser = CommandArgumentParser( + command_key="D", + command_description="Deploy Command", + ) + deploy_parser.add_argument("--target") + deploy_parser.add_argument("--region") + + ops.add_command( + "D", + "Deploy Command", + lambda: None, + aliases=["DEPLOY"], + arg_parser=deploy_parser, + ) + + flx.add_submenu( + "OPS", + "Operations", + ops, + aliases=["OPERATIONS"], + ) + return flx -def test_suggest_commands(falyx): +def test_suggest_namespace_entries_root(falyx): completer = FalyxCompleter(falyx) - completions = list(completer._suggest_commands("R")) - assert any(c.text == "R" for c in completions) - assert any(c.text == "RUN" for c in completions) + + completions = completer._suggest_namespace_entries(falyx, "R") + + assert "R" in completions + assert "RUN" in completions + + completions = completer._suggest_namespace_entries(falyx, "r") + + assert "r" in completions + assert "run" in completions -def test_suggest_commands_empty(falyx): +def test_suggest_namespace_entries_submenu(falyx): completer = FalyxCompleter(falyx) - completions = list(completer._suggest_commands("")) - assert any(c.text == "X" for c in completions) - assert any(c.text == "H" for c in completions) + ops = falyx.namespaces["OPS"].namespace + + completions = completer._suggest_namespace_entries(ops, "D") + + assert "D" in completions + assert "DEPLOY" in completions -def test_suggest_commands_no_match(falyx): +def test_get_completions_no_input_shows_root_entries(falyx): completer = FalyxCompleter(falyx) - completions = list(completer._suggest_commands("Z")) - assert not completions + results = list(completer.get_completions(Document(""), None)) + texts = completion_texts(results) -def test_get_completions_no_input(falyx): - completer = FalyxCompleter(falyx) - doc = Document("") - results = list(completer.get_completions(doc, None)) assert any(isinstance(c, Completion) for c in results) - assert any(c.text == "X" for c in results) + assert "R" in texts + assert "OPS" in texts + assert "X" in texts -def test_get_completions_no_match(falyx): +def test_get_completions_partial_root_entry(falyx): completer = FalyxCompleter(falyx) - doc = Document("Z") - completions = list(completer.get_completions(doc, None)) - assert not completions - doc = Document("Z Z") - completions = list(completer.get_completions(doc, None)) - assert not completions + + results = list(completer.get_completions(Document("OP"), None)) + texts = completion_texts(results) + + assert "OPS" in texts + assert "OPERATIONS" in texts -def test_get_completions_partial_command(falyx): +def test_get_completions_no_match_returns_empty(falyx): completer = FalyxCompleter(falyx) - doc = Document("R") - results = list(completer.get_completions(doc, None)) - assert any(c.text in ("R", "RUN") for c in results) + + assert list(completer.get_completions(Document("Z"), None)) == [] + assert list(completer.get_completions(Document("OPS Z"), None)) == [] -def test_get_completions_with_flag(falyx): +def test_get_completions_namespace_boundary_suggests_help_flags(falyx): completer = FalyxCompleter(falyx) - doc = Document("R ") - results = list(completer.get_completions(doc, None)) - assert "--tag" in [c.text for c in results] + + results = list(completer.get_completions(Document("OPS -"), None)) + texts = completion_texts(results) + + assert "-h" in texts + assert "--help" in texts + assert "-T" in texts + assert "--tldr" in texts -def test_get_completions_partial_flag(falyx): +def test_get_completions_preview_prefix_is_preserved(falyx): completer = FalyxCompleter(falyx) - doc = Document("R --t") - results = list(completer.get_completions(doc, None)) - assert all(c.start_position <= 0 for c in results) - assert any(c.text.startswith("--t") or c.display == "--tag" for c in results) + + results = list(completer.get_completions(Document("?R"), None)) + texts = completion_texts(results) + + assert any(text.startswith("?R") for text in texts) + + +def test_get_completions_preview_prefix_for_namespace_entries(falyx): + completer = FalyxCompleter(falyx) + + results = list(completer.get_completions(Document("?OP"), None)) + texts = completion_texts(results) + + assert "?OPS" in texts or "?OPERATIONS" in texts + + +def test_get_completions_leaf_command_delegates_flags_to_root_command_parser( + falyx, monkeypatch +): + completer = FalyxCompleter(falyx) + + seen = {} + + def fake_suggest_next(args, cursor_at_end_of_token): + seen["args"] = list(args) + seen["cursor_at_end_of_token"] = cursor_at_end_of_token + return ["--tag"] + + monkeypatch.setattr( + falyx.commands["R"].arg_parser, + "suggest_next", + fake_suggest_next, + ) + + results = list(completer.get_completions(Document("R --t"), None)) + texts = completion_texts(results) + + assert seen["args"] == ["--t"] + assert seen["cursor_at_end_of_token"] is False + assert "--tag" in texts + + +def test_get_completions_leaf_command_delegates_flags_to_submenu_command_parser( + falyx, monkeypatch +): + completer = FalyxCompleter(falyx) + ops = falyx.namespaces["OPS"].namespace + deploy = ops.commands["D"] + + seen = {} + + def fake_suggest_next(args, cursor_at_end_of_token): + seen["args"] = list(args) + seen["cursor_at_end_of_token"] = cursor_at_end_of_token + return ["--target"] + + monkeypatch.setattr( + deploy.arg_parser, + "suggest_next", + fake_suggest_next, + ) + + results = list(completer.get_completions(Document("OPS D --t"), None)) + texts = completion_texts(results) + + assert seen["args"] == ["--t"] + assert seen["cursor_at_end_of_token"] is False + assert "--target" in texts + + +def test_get_completions_leaf_command_receives_empty_stub_after_space(falyx, monkeypatch): + completer = FalyxCompleter(falyx) + + seen = {} + + def fake_suggest_next(args, cursor_at_end_of_token): + seen["args"] = list(args) + seen["cursor_at_end_of_token"] = cursor_at_end_of_token + return ["--tag", "--name"] + + monkeypatch.setattr( + falyx.commands["R"].arg_parser, + "suggest_next", + fake_suggest_next, + ) + + results = list(completer.get_completions(Document("R "), None)) + texts = completion_texts(results) + + assert seen["args"] == [] + assert seen["cursor_at_end_of_token"] is True + assert "--tag" in texts + assert "--name" in texts def test_get_completions_bad_input(falyx): completer = FalyxCompleter(falyx) - doc = Document('R "unclosed quote') - results = list(completer.get_completions(doc, None)) + + results = list(completer.get_completions(Document('R "unclosed quote'), None)) + assert results == [] -def test_get_completions_exception_handling(falyx): +def test_get_completions_exception_handling(falyx, monkeypatch): completer = FalyxCompleter(falyx) - falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0 - doc = Document("R --tag") - results = list(completer.get_completions(doc, None)) + + def boom(*args, **kwargs): + raise ZeroDivisionError("boom") + + monkeypatch.setattr(falyx.commands["R"].arg_parser, "suggest_next", boom) + + results = list(completer.get_completions(Document("R --tag"), None)) + assert results == [] + + +def test_ensure_quote_wraps_whitespace(falyx): + completer = FalyxCompleter(falyx) + + assert completer._ensure_quote("hello world") == '"hello world"' + assert completer._ensure_quote("hello") == "hello" diff --git a/tests/test_completer/test_lcp_completions.py b/tests/test_completer/test_lcp_completions.py index 824bad3..cb2a895 100644 --- a/tests/test_completer/test_lcp_completions.py +++ b/tests/test_completer/test_lcp_completions.py @@ -1,38 +1,42 @@ from types import SimpleNamespace import pytest -from prompt_toolkit.document import Document from falyx.completer import FalyxCompleter -@pytest.fixture -def fake_falyx(): - fake_arg_parser = SimpleNamespace( - suggest_next=lambda tokens, end: ["AETHERWARP", "AETHERZOOM"] - ) - fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser) - return SimpleNamespace( - exit_command=SimpleNamespace(key="X", aliases=["EXIT"]), - help_command=SimpleNamespace(key="H", aliases=["HELP"]), - history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]), - commands={"R": fake_command}, - _entry_map={"R": fake_command, "RUN": fake_command, "X": fake_command}, - ) +def completion_texts(completions) -> list[str]: + return [c.text for c in completions] -def test_lcp_completions(fake_falyx): - completer = FalyxCompleter(fake_falyx) - doc = Document("R A") - results = list(completer.get_completions(doc, None)) - assert any(c.text == "AETHER" for c in results) - assert any(c.text == "AETHERWARP" for c in results) - assert any(c.text == "AETHERZOOM" for c in results) +def test_lcp_completions(): + completer = FalyxCompleter(SimpleNamespace()) + suggestions = ["AETHERWARP", "AETHERZOOM"] + stub = "A" + completions = list(completer._yield_lcp_completions(suggestions, stub)) + texts = completion_texts(completions) + + assert "AETHER" in texts + assert "AETHERWARP" in texts + assert "AETHERZOOM" in texts -def test_lcp_completions_space(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_lcp_completions_space(): + completer = FalyxCompleter(SimpleNamespace()) suggestions = ["London", "New York", "San Francisco"] stub = "N" completions = list(completer._yield_lcp_completions(suggestions, stub)) - assert any(c.text == '"New York"' for c in completions) + texts = completion_texts(completions) + assert '"New York"' in texts + + +def test_lcp_completions_does_not_collapse_flags(): + completer = FalyxCompleter(SimpleNamespace()) + suggestions = ["--tag", "--target"] + stub = "--t" + completions = list(completer._yield_lcp_completions(suggestions, stub)) + texts = completion_texts(completions) + + assert "--tag" in texts + assert "--target" in texts + assert "--ta" not in texts