Files
falyx/falyx/completer.py
Roland Thomas dcec792d32 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
2026-04-12 14:04:06 -04:00

259 lines
9.3 KiB
Python

# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""Prompt Toolkit completion support for routed Falyx command input.
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:
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.
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
import os
import shlex
from typing import TYPE_CHECKING, Iterable
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document
if TYPE_CHECKING:
from falyx import Falyx
class FalyxCompleter(Completer):
"""Prompt Toolkit completer for routed Falyx input.
`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:
- 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): Active Falyx application instance used to resolve routes
and retrieve completion candidates.
"""
def __init__(self, falyx: Falyx):
"""Initialize the completer with a bound Falyx instance.
Args:
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: 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 = text.endswith((" ", "\t"))
except ValueError:
return
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
# Leaf command: CAP owns the rest
if not route.command or not route.command.arg_parser:
return
leaf_tokens = list(route.leaf_argv)
if route.stub:
leaf_tokens.append(route.stub)
try:
suggestions = route.command.arg_parser.suggest_next(
leaf_tokens,
route.cursor_at_end_of_token,
)
except Exception:
return
yield from self._yield_lcp_completions(suggestions, route.stub)
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:
namespace (Falyx): Namespace whose entries should be searched for
completion candidates.
prefix (str): Current partially typed entry name.
Returns:
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) -> Iterable[Completion]:
"""Yield completions for the current stub using longest-common-prefix logic.
Behavior:
- If only one match → yield it fully.
- If multiple matches share a longer prefix → insert the prefix, but also
display all matches in the menu.
- If no shared prefix → list all matches individually.
Args:
suggestions (list[str]): The raw suggestions to consider.
stub (str): The currently typed prefix (used to offset insertion).
Yields:
Completion: Completion objects for the Prompt Toolkit menu.
"""
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(match),
start_position=-len(stub),
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,
)