refactor: make completer routing-aware for namespaces

- route completions through resolve_completion_route instead of one-level command lookup
- add CompletionRoute to model partial completion state
- suggest namespace entries and namespace-level help/TLDR flags while routing
- delegate leaf argv completion to CommandArgumentParser after command resolution
- restore LCP completion behavior with deduping and flag-safe handling
- add namespace completion name iteration and TLDR example support to Falyx
- update completer and completion route documentation
This commit is contained in:
2026-04-12 14:04:06 -04:00
parent 8ece2a5de6
commit dcec792d32
10 changed files with 782 additions and 311 deletions

View File

@@ -1,21 +1,32 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI """Prompt Toolkit completion support for routed Falyx command input.
menus using Prompt Toolkit.
This completer supports: This module defines `FalyxCompleter`, the interactive completion layer used by
- Command key and alias completion (e.g. `R`, `HELP`, `X`) Falyx menu and prompt-driven CLI sessions. The completer is routing-aware: it
- Argument flag completion for registered commands (e.g. `--tag`, `--name`) delegates namespace traversal to `Falyx.resolve_completion_route()` and only
- Context-aware suggestions based on cursor position and argument structure hands control to a command's `CommandArgumentParser` after a leaf command has
- Interactive value completions (e.g. choices and suggestions defined per argument) been identified.
- File/path-friendly behavior, quoting completions with spaces automatically
Completion behavior is split into two phases:
Completions are generated from: 1. Namespace completion
- Registered commands in `Falyx` While the user is still selecting a command or namespace entry, completion
- Argument metadata and `suggest_next()` from `CommandArgumentParser` 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 from __future__ import annotations
@@ -27,183 +38,177 @@ from typing import TYPE_CHECKING, Iterable
from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document from prompt_toolkit.document import Document
from falyx.namespace import FalyxNamespace
if TYPE_CHECKING: if TYPE_CHECKING:
from falyx import Falyx from falyx import Falyx
class FalyxCompleter(Completer): 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: `FalyxCompleter` provides context-aware completions for interactive Falyx
- Command keys and aliases (resolved via Falyx._entry_map) sessions. It first asks the owning `Falyx` instance to resolve the current
- CLI argument flags and values for each command input into a partial completion route. Based on that route, it either:
- Suggestions and choices defined in the associated CommandArgumentParser
It leverages `CommandArgumentParser.suggest_next()` to compute valid completions - suggests visible entries from the active namespace, or
based on current argument state, including: - delegates argument completion to the resolved command's argument parser.
- Remaining required or optional flags
- Flag value suggestions (choices or custom completions) This keeps completion aligned with Falyx's routing model so nested
- Next positional argument hints namespaces, preview-prefixed commands, and command-local argument parsing
- Inserts longest common prefix (LCP) completions when applicable all behave consistently with actual execution.
- Handles special cases like quoted strings and spaces
- Supports dynamic argument suggestions (e.g. flags, file paths, etc.)
Args: 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"): def __init__(self, falyx: Falyx):
self.falyx = falyx """Initialize the completer with a bound Falyx instance.
@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.
Args: Args:
document (Document): The current Prompt Toolkit document (input buffer & cursor). falyx (Falyx): Active Falyx application that owns the routing and
complete_event: The triggering event (TAB key, menu display, etc.) — not used here. 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: 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 text = document.text_before_cursor
try: try:
tokens = shlex.split(text) tokens = shlex.split(text)
cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t")) cursor_at_end = text.endswith((" ", "\t"))
except ValueError: except ValueError:
return return
if tokens and not cursor_at_end_of_token and tokens[0].startswith("?"): is_preview = False
stub = tokens[0][1:] if tokens and tokens[0].startswith("?"):
suggestions = [c.text for c in self._suggest_commands(stub)] is_preview = True
prefixed = [f"?{s}" for s in suggestions] tokens[0] = tokens[0][1:]
yield from self._yield_lcp_completions(prefixed, tokens[0])
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 return
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): # Leaf command: CAP owns the rest
# Suggest command keys and aliases if not route.command or not route.command.arg_parser:
stub = tokens[0] if tokens else ""
suggestions = [c.text for c in self._suggest_commands(stub)]
yield from self._yield_lcp_completions(suggestions, stub)
return return
# Identify command leaf_tokens = list(route.leaf_argv)
command_key = tokens[0].upper() if route.stub:
command = self._resolve_command_for_completion(command_key) leaf_tokens.append(route.stub)
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]
try: try:
suggestions = command.arg_parser.suggest_next( suggestions = route.command.arg_parser.suggest_next(
parsed_args + ([stub] if stub else []), cursor_at_end_of_token leaf_tokens,
route.cursor_at_end_of_token,
) )
yield from self._yield_lcp_completions(suggestions, stub)
except Exception: except Exception:
return return
def _suggest_commands(self, prefix: str) -> Iterable[Completion]: yield from self._yield_lcp_completions(suggestions, route.stub)
"""Suggest top-level command keys and aliases based on the given prefix.
Filters all known commands (and `exit`, `help`, `history` built-ins) def _suggest_namespace_entries(self, namespace: Falyx, prefix: str) -> list[str]:
to only those starting with the given prefix. """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: Args:
prefix (str): The current typed prefix. namespace (Falyx): Namespace whose entries should be searched for
completion candidates.
Yields: prefix (str): Current partially typed entry name.
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.
Returns: 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: if " " in text or "\t" in text:
return f'"{text}"' return f'"{text}"'
return 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. """Yield completions for the current stub using longest-common-prefix logic.
Behavior: Behavior:
@@ -219,26 +224,35 @@ class FalyxCompleter(Completer):
Yields: Yields:
Completion: Completion objects for the Prompt Toolkit menu. 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: if not matches:
return return
lcp = os.path.commonprefix(matches) lcp = os.path.commonprefix(matches)
if len(matches) == 1: if len(matches) == 1:
match = matches[0]
yield Completion( yield Completion(
self._ensure_quote(matches[0]), self._ensure_quote(match),
start_position=-len(stub), start_position=-len(stub),
display=matches[0], display=match,
) )
elif len(lcp) > len(stub) and not lcp.startswith("-"): return
yield Completion(lcp, start_position=-len(stub), display=lcp)
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: for match in matches:
yield Completion( yield Completion(
self._ensure_quote(match), start_position=-len(stub), display=match self._ensure_quote(match),
) start_position=-len(stub),
else: display=match,
for match in matches:
yield Completion(
self._ensure_quote(match), start_position=-len(stub), display=match
) )

86
falyx/completer_types.py Normal file
View File

@@ -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

View File

@@ -62,6 +62,7 @@ from falyx.bottom_bar import BottomBar
from falyx.command import Command from falyx.command import Command
from falyx.command_executor import CommandExecutor from falyx.command_executor import CommandExecutor
from falyx.completer import FalyxCompleter from falyx.completer import FalyxCompleter
from falyx.completer_types import CompletionRoute
from falyx.console import console from falyx.console import console
from falyx.context import InvocationContext from falyx.context import InvocationContext
from falyx.debug import log_after, log_before, log_error, log_success 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.namespace import FalyxNamespace
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser, FalyxParser, RootParseResult 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.prompt_utils import rich_text_to_prompt_text
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
@@ -243,6 +245,7 @@ class Falyx:
else: else:
self.history = None self.history = None
self.enable_help_tips = enable_help_tips self.enable_help_tips = enable_help_tips
self._tldr_examples: list[FalyxTLDRExample] = []
self._register_default_builtins() self._register_default_builtins()
self._register_options() self._register_options()
self._executor = CommandExecutor( self._executor = CommandExecutor(
@@ -251,6 +254,51 @@ class Falyx:
console=self.console, 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: def get_current_invocation_context(self) -> InvocationContext:
"""Returns the current invocation context.""" """Returns the current invocation context."""
return InvocationContext( return InvocationContext(
@@ -296,6 +344,46 @@ class Falyx:
if not self.options.get("invocation_path"): if not self.options.get("invocation_path"):
self.options.set("invocation_path", self.program) 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 @property
def _entry_map(self) -> dict[str, Command | FalyxNamespace]: def _entry_map(self) -> dict[str, Command | FalyxNamespace]:
"""Builds a mapping of all valid input names to Command objects. """Builds a mapping of all valid input names to Command objects.
@@ -318,19 +406,6 @@ class Falyx:
else: else:
mapping[norm] = entry 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(): for command in self.commands.values():
register(command.key, command) register(command.key, command)
for alias in command.aliases: for alias in command.aliases:
@@ -341,6 +416,20 @@ class Falyx:
register(namespace.key, namespace) register(namespace.key, namespace)
for alias in namespace.aliases: for alias in namespace.aliases:
register(alias, namespace) 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 return mapping
def get_title(self) -> str: def get_title(self) -> str:
@@ -582,18 +671,49 @@ class Falyx:
if self.enable_help_tips: if self.enable_help_tips:
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
async def _render_unknown_route(self, route: RouteResult) -> None: def get_usage(self, context: InvocationContext) -> str:
context = route.context has_namespaces = any(not ns.hidden for ns in self.namespaces.values())
typed_key = context.typed_path[0].upper() target = "command" if not has_namespaces else "command or namespace"
await route.namespace.render_namespace_help(context) if not context.typed_path and context.is_cli_mode:
self.console.print( return escape(f"[-h] [-T] [-v] [-d] [-n] <{target}> [args...]")
f"[{OneColors.DARK_RED}]❌ Unknown Command or FalyxNamespace [{typed_key}]" elif not context.typed_path:
) return escape(f"[-h] [-T] <{target}> [args...]")
return None return escape(f"<{target}> [args...]")
async def _render_namespace_tldr_help(self, context: InvocationContext) -> None: async def _render_namespace_tldr_help(self, context: InvocationContext) -> None:
# TODO: Create namespace tldr if not self._tldr_examples:
console.print(context.markup_path) 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( async def render_namespace_help(
self, context: InvocationContext, tldr: bool = False self, context: InvocationContext, tldr: bool = False
@@ -607,7 +727,7 @@ class Falyx:
async def _render_cli_help(self, context: InvocationContext) -> None: async def _render_cli_help(self, context: InvocationContext) -> None:
"""Renders the CLI help menu with all available commands and options.""" """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( self.console.print(
f"[bold]usage:[/bold] {context.markup_path} [{self.usage_style}]{usage}[/{self.usage_style}]" 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("\n[bold]global options:[/bold]")
self.console.print(f" {'-h, --help':<22}{'Show this help message and exit.'}") 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( self.console.print(
f" {'-v, --verbose':<22}{'Enable verbose debug logging for the session.'}" f" {'-v, --verbose':<22}{'Enable verbose debug logging for the session.'}"
) )
@@ -672,21 +793,15 @@ class Falyx:
tag: str = "", tag: str = "",
key: str | None = None, key: str | None = None,
tldr: bool = False, tldr: bool = False,
namespace_tldr: bool = False,
invocation_context: InvocationContext | None = None, invocation_context: InvocationContext | None = None,
) -> None: ) -> None:
"""Renders the help menu with command details, usage examples, and tips.""" """Renders the help menu with command details, usage examples, and tips."""
context = invocation_context or self.get_current_invocation_context() context = invocation_context or self.get_current_invocation_context()
if key: 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) base_context = self._help_target_base_context(context)
entry, suggestions = self.resolve_entry(key)
if isinstance(entry, Command): if isinstance(entry, Command):
await self._render_command_help( await self._render_command_help(
command=entry, command=entry,
@@ -699,10 +814,9 @@ class Falyx:
tldr=tldr, tldr=tldr,
) )
else: else:
# TODO: Should print something helpful here await self.render_namespace_help(base_context)
self.console.print( self._print_suggestions_message(key, suggestions)
f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]" return None
)
elif tldr: elif tldr:
await self._render_command_help( await self._render_command_help(
self.help_command, self.help_command,
@@ -711,10 +825,8 @@ class Falyx:
) )
elif tag: elif tag:
await self._render_tag_help(tag) await self._render_tag_help(tag)
elif self.options.get("mode") == FalyxMode.MENU:
await self._render_menu_help()
else: else:
await self._render_cli_help(context) await self.render_namespace_help(context, namespace_tldr)
def _get_help_command(self) -> Command: def _get_help_command(self) -> Command:
"""Returns the help command for the menu.""" """Returns the help command for the menu."""
@@ -766,23 +878,16 @@ class Falyx:
async def _preview(self, key: str) -> None: async def _preview(self, key: str) -> None:
"""Previews the execution of a command without actually running it.""" """Previews the execution of a command without actually running it."""
entry, suggestions = self.resolve_entry(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
if isinstance(entry, FalyxNamespace): if isinstance(entry, FalyxNamespace):
self.console.print( self.console.print(
f"❌ Entry '{key}' is a namespace. Please specify a command to preview.", f"❌ Entry '{key}' is a namespace. Please specify a command to preview.",
style=OneColors.DARK_RED, style=OneColors.DARK_RED,
) )
return None elif isinstance(entry, Command):
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}") self.console.print(f"Preview of command '{entry.key}': {entry.description}")
await entry.preview() await entry.preview()
else:
self._print_suggestions_message(key, suggestions)
def _get_preview_command(self) -> Command: def _get_preview_command(self) -> Command:
"""Returns the preview command for Falyx.""" """Returns the preview command for Falyx."""
@@ -1371,6 +1476,13 @@ class Falyx:
return route, args, kwargs, execution_args 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( async def _dispatch_route(
self, self,
route: RouteResult, route: RouteResult,
@@ -1507,6 +1619,74 @@ class Falyx:
summary_last_result=summary_last_result, 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( async def resolve_route(
self, self,
tokens: list[str], tokens: list[str],
@@ -1696,7 +1876,7 @@ class Falyx:
self._apply_parse_result(parse_result) self._apply_parse_result(parse_result)
if parse_result.mode == FalyxMode.HELP: if parse_result.mode == FalyxMode.HELP:
await self.render_help() await self.render_help(namespace_tldr=parse_result.tldr_requested)
sys.exit(0) sys.exit(0)
try: try:

View File

@@ -16,5 +16,5 @@ __all__ = [
"ArgumentAction", "ArgumentAction",
"CommandArgumentParser", "CommandArgumentParser",
"FalyxParser", "FalyxParser",
"ParseResult", "RootParseResult",
] ]

View File

@@ -66,7 +66,13 @@ from falyx.options_manager import OptionsManager
from falyx.parser.argument import Argument from falyx.parser.argument import Argument
from falyx.parser.argument_action import ArgumentAction from falyx.parser.argument_action import ArgumentAction
from falyx.parser.group import ArgumentGroup, MutuallyExclusiveGroup 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.parser.utils import coerce_value
from falyx.signals import HelpSignal from falyx.signals import HelpSignal
@@ -134,7 +140,7 @@ class CommandArgumentParser:
help_text: str = "", help_text: str = "",
help_epilog: str = "", help_epilog: str = "",
aliases: list[str] | None = None, aliases: list[str] | None = None,
tldr_examples: list[tuple[str, str]] | None = None, tldr_examples: list[TLDRInput] | None = None,
program: str | None = None, program: str | None = None,
options_manager: OptionsManager | None = None, options_manager: OptionsManager | None = None,
) -> None: ) -> None:
@@ -250,32 +256,47 @@ class CommandArgumentParser:
) )
self._register_argument(help) 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."""
Add TLDR examples to the parser.
Args:
examples (list[tuple[str, str]]): List of (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))
if "tldr" not in self._dest_set:
tldr = Argument( tldr = Argument(
("--tldr", "-T"), flags=("--tldr", "-T"),
action=ArgumentAction.TLDR, action=ArgumentAction.TLDR,
help="Show quick usage examples.", help="Show quick usage examples.",
dest="tldr", dest="tldr",
) )
self._register_argument(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[TLDRInput]): List of TLDRExample instances or (usage, description) tuples.
"""
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:
self._add_tldr()
def add_argument_group( def add_argument_group(
self, self,
name: str, name: str,

View File

@@ -14,6 +14,7 @@ class RootOptions:
debug_hooks: bool = False debug_hooks: bool = False
never_prompt: bool = False never_prompt: bool = False
help: bool = False help: bool = False
tldr: bool = False
class FalyxParser: class FalyxParser:
@@ -27,13 +28,17 @@ class FalyxParser:
""" """
ROOT_FLAG_ALIASES: dict[str, str] = { ROOT_FLAG_ALIASES: dict[str, str] = {
"-n": "never_prompt",
"--never-prompt": "never_prompt", "--never-prompt": "never_prompt",
"-v": "verbose", "-v": "verbose",
"--verbose": "verbose", "--verbose": "verbose",
"-d": "debug_hooks",
"--debug-hooks": "debug_hooks", "--debug-hooks": "debug_hooks",
"?": "help", "?": "help",
"-h": "help", "-h": "help",
"--help": "help", "--help": "help",
"-T": "tldr",
"--tldr": "tldr",
} }
@classmethod @classmethod
@@ -78,13 +83,14 @@ class FalyxParser:
argv = argv or [] argv = argv or []
root, remaining = cls._parse_root_options(argv) root, remaining = cls._parse_root_options(argv)
if root.help: if root.help or root.tldr:
return RootParseResult( return RootParseResult(
mode=FalyxMode.HELP, mode=FalyxMode.HELP,
raw_argv=argv, raw_argv=argv,
never_prompt=root.never_prompt, never_prompt=root.never_prompt,
verbose=root.verbose, verbose=root.verbose,
debug_hooks=root.debug_hooks, debug_hooks=root.debug_hooks,
tldr_requested=root.tldr,
) )
return RootParseResult( return RootParseResult(

View File

@@ -12,3 +12,4 @@ class RootParseResult:
debug_hooks: bool = False debug_hooks: bool = False
never_prompt: bool = False never_prompt: bool = False
remaining_argv: list[str] = field(default_factory=list) remaining_argv: list[str] = field(default_factory=list)
tldr_requested: bool = False

View File

@@ -17,7 +17,7 @@ These tools support richer expressiveness and user-friendly ergonomics in
Falyx's declarative command-line interfaces. Falyx's declarative command-line interfaces.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, TypeAlias
from falyx.parser.argument import Argument from falyx.parser.argument import Argument
@@ -50,6 +50,21 @@ class TLDRExample:
description: str 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: def true_none(value: Any) -> bool | None:
"""Return True if value is not None, else None.""" """Return True if value is not None, else None."""
if value is None: if value is None:

View File

@@ -1,3 +1,5 @@
import re
import pytest import pytest
from prompt_toolkit.completion import Completion from prompt_toolkit.completion import Completion
from prompt_toolkit.document import Document from prompt_toolkit.document import Document
@@ -7,99 +9,241 @@ from falyx.completer import FalyxCompleter
from falyx.parser import CommandArgumentParser from falyx.parser import CommandArgumentParser
def completion_texts(completions) -> list[str]:
return [c.text for c in completions]
@pytest.fixture @pytest.fixture
def falyx(): def falyx():
flx = Falyx() flx = Falyx()
parser = CommandArgumentParser(
run_parser = CommandArgumentParser(
command_key="R", command_key="R",
command_description="Run Command", command_description="Run Command",
) )
parser.add_argument( run_parser.add_argument("--tag")
"--tag", run_parser.add_argument("--name")
)
parser.add_argument(
"--name",
)
flx.add_command( flx.add_command(
"R", "R",
"Run Command", "Run Command",
lambda x: None, lambda: None,
aliases=["RUN"], 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 return flx
def test_suggest_commands(falyx): def test_suggest_namespace_entries_root(falyx):
completer = FalyxCompleter(falyx) completer = FalyxCompleter(falyx)
completions = list(completer._suggest_commands("R"))
assert any(c.text == "R" for c in completions) completions = completer._suggest_namespace_entries(falyx, "R")
assert any(c.text == "RUN" for c in completions)
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) completer = FalyxCompleter(falyx)
completions = list(completer._suggest_commands("")) ops = falyx.namespaces["OPS"].namespace
assert any(c.text == "X" for c in completions)
assert any(c.text == "H" for c in completions) 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) 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(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) completer = FalyxCompleter(falyx)
doc = Document("Z")
completions = list(completer.get_completions(doc, None)) results = list(completer.get_completions(Document("OP"), None))
assert not completions texts = completion_texts(results)
doc = Document("Z Z")
completions = list(completer.get_completions(doc, None)) assert "OPS" in texts
assert not completions assert "OPERATIONS" in texts
def test_get_completions_partial_command(falyx): def test_get_completions_no_match_returns_empty(falyx):
completer = FalyxCompleter(falyx) completer = FalyxCompleter(falyx)
doc = Document("R")
results = list(completer.get_completions(doc, None)) assert list(completer.get_completions(Document("Z"), None)) == []
assert any(c.text in ("R", "RUN") for c in results) 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) completer = FalyxCompleter(falyx)
doc = Document("R ")
results = list(completer.get_completions(doc, None)) results = list(completer.get_completions(Document("OPS -"), None))
assert "--tag" in [c.text for c in results] 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) completer = FalyxCompleter(falyx)
doc = Document("R --t")
results = list(completer.get_completions(doc, None)) results = list(completer.get_completions(Document("?R"), None))
assert all(c.start_position <= 0 for c in results) texts = completion_texts(results)
assert any(c.text.startswith("--t") or c.display == "--tag" for c in 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): def test_get_completions_bad_input(falyx):
completer = FalyxCompleter(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 == [] assert results == []
def test_get_completions_exception_handling(falyx): def test_get_completions_exception_handling(falyx, monkeypatch):
completer = FalyxCompleter(falyx) completer = FalyxCompleter(falyx)
falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0
doc = Document("R --tag") def boom(*args, **kwargs):
results = list(completer.get_completions(doc, None)) raise ZeroDivisionError("boom")
monkeypatch.setattr(falyx.commands["R"].arg_parser, "suggest_next", boom)
results = list(completer.get_completions(Document("R --tag"), None))
assert results == [] 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"

View File

@@ -1,38 +1,42 @@
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
from prompt_toolkit.document import Document
from falyx.completer import FalyxCompleter from falyx.completer import FalyxCompleter
@pytest.fixture def completion_texts(completions) -> list[str]:
def fake_falyx(): return [c.text for c in completions]
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 test_lcp_completions(fake_falyx): def test_lcp_completions():
completer = FalyxCompleter(fake_falyx) completer = FalyxCompleter(SimpleNamespace())
doc = Document("R A") suggestions = ["AETHERWARP", "AETHERZOOM"]
results = list(completer.get_completions(doc, None)) stub = "A"
assert any(c.text == "AETHER" for c in results) completions = list(completer._yield_lcp_completions(suggestions, stub))
assert any(c.text == "AETHERWARP" for c in results) texts = completion_texts(completions)
assert any(c.text == "AETHERZOOM" for c in results)
assert "AETHER" in texts
assert "AETHERWARP" in texts
assert "AETHERZOOM" in texts
def test_lcp_completions_space(fake_falyx): def test_lcp_completions_space():
completer = FalyxCompleter(fake_falyx) completer = FalyxCompleter(SimpleNamespace())
suggestions = ["London", "New York", "San Francisco"] suggestions = ["London", "New York", "San Francisco"]
stub = "N" stub = "N"
completions = list(completer._yield_lcp_completions(suggestions, stub)) 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