Coverage for / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / cli / repl / completion.py: 100%
83 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright © 2025 Bijan Mousavi
4"""Completion helpers for the REPL."""
6from __future__ import annotations
8from collections.abc import Iterator
9import shlex
10from typing import Any
12from prompt_toolkit.completion import CompleteEvent, Completer, Completion
13from prompt_toolkit.document import Document
14import typer
16from bijux_cli.cli.core.constants import (
17 OPT_FORMAT,
18 OPT_HELP,
19 OPT_LOG_LEVEL,
20 OPT_QUIET,
21 PRETTY_FLAGS,
22)
24GLOBAL_OPTS = [
25 *OPT_QUIET,
26 *OPT_FORMAT,
27 *OPT_LOG_LEVEL,
28 *PRETTY_FLAGS,
29 *OPT_HELP,
30]
32_BUILTINS = ("exit", "quit")
35def _split_words(text: str) -> list[str]:
36 """Split input text into shell-like words for completion."""
37 try:
38 words: list[str] = shlex.split(text)
39 except ValueError:
40 return []
41 if text.endswith(" ") or not text:
42 words.append("")
43 return words
46def _collect_completions(
47 words: list[str],
48 cmd_map: dict[tuple[str, ...], Any],
49 builtins: tuple[str, ...],
50) -> list[tuple[str, int, str | None]]:
51 """Compute completion tuples for the current buffer state."""
52 if not words:
53 return []
55 current = words[-1]
56 completions: list[tuple[str, int, str | None]] = []
58 if current.startswith("-"):
59 completions.extend(
60 (opt, -len(current), None) for opt in GLOBAL_OPTS if opt.startswith(current)
61 )
63 cmd_obj = None
64 for i in range(len(words) - 1, 0, -1):
65 key = tuple(words[:i])
66 if key in cmd_map:
67 cmd_obj = cmd_map[key]
68 break
70 if cmd_obj is None:
71 completions.extend(
72 (b, -len(current), None) for b in builtins if b.startswith(current)
73 )
75 completions.extend(
76 (key[0], -len(current), None)
77 for key in cmd_map
78 if len(key) == 1 and key[0].startswith(current)
79 )
80 if words[:3] == ["config", "set", ""]:
81 completions.append(("KEY=VALUE", 0, "KEY=VALUE"))
82 elif not completions and current == "":
83 completions.append(("DUMMY", 0, "DUMMY"))
84 return completions
86 is_group = hasattr(cmd_obj, "registered_commands") or hasattr(
87 cmd_obj, "registered_groups"
88 )
89 if is_group:
90 names = [c.name for c in getattr(cmd_obj, "registered_commands", [])]
91 names += [g.name for g in getattr(cmd_obj, "registered_groups", [])]
92 completions.extend(
93 (name, -len(current), None) for name in names if name.startswith(current)
94 )
95 elif hasattr(cmd_obj, "params"):
96 for param in cmd_obj.params:
97 completions.extend(
98 (opt, -len(current), None)
99 for opt in (*param.opts, *(getattr(param, "secondary_opts", []) or []))
100 if opt.startswith(current)
101 )
103 if current and "--help".startswith(current):
104 completions.append(("--help", -len(current), None))
106 if not completions and words[:3] == ["config", "set", ""]:
107 completions.append(("KEY=VALUE", 0, "KEY=VALUE"))
108 elif not completions and current == "":
109 completions.append(("DUMMY", 0, "DUMMY"))
110 return completions
113class CommandCompleter(Completer):
114 """Provides context-aware tab-completion for the REPL."""
116 def __init__(self, main_app: typer.Typer) -> None:
117 """Initializes the completer."""
118 self.main_app = main_app
119 self._cmd_map = self._collect(main_app)
120 self._BUILTINS = _BUILTINS
122 def _collect(
123 self,
124 app: typer.Typer,
125 path: list[str] | None = None,
126 ) -> dict[tuple[str, ...], Any]:
127 """Build a command lookup table keyed by command path."""
128 path = path or []
129 out: dict[tuple[str, ...], Any] = {}
130 for cmd in getattr(app, "registered_commands", []):
131 out[tuple(path + [cmd.name])] = cmd
132 for grp in getattr(app, "registered_groups", []):
133 out[tuple(path + [grp.name])] = grp.typer_instance
134 out.update(self._collect(grp.typer_instance, path + [grp.name]))
135 return out
137 def _find(
138 self,
139 words: list[str],
140 ) -> tuple[Any | None, list[str]]:
141 """Locate the deepest command match and return remaining tokens."""
142 for i in range(len(words), 0, -1):
143 key = tuple(words[:i])
144 if key in self._cmd_map:
145 return self._cmd_map[key], words[i:]
146 return None, words
148 def get_completions(
149 self,
150 document: Document,
151 _complete_event: CompleteEvent,
152 ) -> Iterator[Completion]:
153 """Yield completions for the current prompt buffer."""
154 text = document.text_before_cursor
155 words = _split_words(text)
156 if not words:
157 return
158 for value, start, display in _collect_completions(
159 words, self._cmd_map, self._BUILTINS
160 ):
161 if display is None:
162 yield Completion(value, start_position=start)
163 else:
164 yield Completion(value, display=display, start_position=start)