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