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:
@@ -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
|
||||
)
|
||||
|
||||
86
falyx/completer_types.py
Normal file
86
falyx/completer_types.py
Normal 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
|
||||
284
falyx/falyx.py
284
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:
|
||||
|
||||
@@ -16,5 +16,5 @@ __all__ = [
|
||||
"ArgumentAction",
|
||||
"CommandArgumentParser",
|
||||
"FalyxParser",
|
||||
"ParseResult",
|
||||
"RootParseResult",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user