Skip to content

Commit 72cdbc2

Browse files
committed
feat(repl): tab completion for keywords, variables, imports
Tab now completes Robot-aware: - keyword names at the start of a cell, sourced from every loaded library and imported resource (case + whitespace + underscore insensitive — matches Robot's own resolution rules) - `${...}` / `@{...}` / `&{...}` variables from the live suite scope, and `%{...}` environment variables from the process environment - `Import Library` arguments — plain identifiers (`Coll<Tab>` → `Collections`), dotted module paths (`robot.libraries.Coll<Tab>` → `robot.libraries.Collections`), and filesystem paths (`./libs/My<Tab>` → `./libs/MyLib.py`) - `Import Resource` arguments — `.robot` / `.resource` files only - `Import Variables` arguments — `.py` / `.yaml` / `.yml` / `.json` variable files plus discoverable variables modules Works across Robot Framework 5, 6, and 7+. On Pythons whose stdlib `readline` is backed by libedit (macOS system Python, some uv / python-build-standalone builds), install the `gnureadline` extra for full GNU readline behaviour: `pip install robotcode-repl[gnureadline]`.
1 parent 1c28b57 commit 72cdbc2

4 files changed

Lines changed: 995 additions & 5 deletions

File tree

‎packages/repl/pyproject.toml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ dependencies = [
3131
"robotcode-runner"
3232
]
3333

34+
[project.optional-dependencies]
35+
# `gnureadline` ships a real GNU libreadline with the Python bindings —
36+
# install it when your interpreter's stdlib `readline` is backed by
37+
# libedit (macOS system Python, some Linux Pythons built via
38+
# python-build-standalone / uv). libedit silently ignores the
39+
# GNU-specific completion bindings the REPL relies on
40+
# (`set completion-query-items`, `tab: complete`, …), so without
41+
# `gnureadline` Tab-completion falls back to the verbose default
42+
# layout with the "Display all NNN possibilities?" prompt.
43+
gnureadline = ["gnureadline; sys_platform != 'win32'"]
44+
3445
[project.entry-points.robotcode]
3546
repl = "robotcode.repl.hooks"
3647

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
"""Robot-aware completion candidates for the REPL.
2+
3+
This module is backend-agnostic: it understands Robot's syntax (cells
4+
separated by 2+ spaces or tab, `${...}` / `@{...}` / `&{...}` / `%{...}`
5+
variable wrappers, special leading-cell forms like `Import Library`,
6+
`Import Resource`, `Import Variables`) and answers "what should the
7+
user be offered as a completion here?". Both `ReadlineBackend`
8+
(Stage 2) and `PromptToolkitBackend` (Stage 3) consume the same
9+
`tokenize()` and `candidates_for()` API.
10+
11+
We pull live data straight from `EXECUTION_CONTEXTS.current` — the
12+
REPL runs synchronously inside `suite.run()`, so the active namespace
13+
and variable scope are populated by the time the user hits Tab.
14+
15+
Library / Resource / Variables imports each have **their own** Robot
16+
discovery function (`complete_library_import`, `complete_resource_import`,
17+
`complete_variables_import`) — they live in different namespaces and
18+
recognise different file extensions, so we never share their result
19+
sets. The dispatch logic mirrors the Language Server's per-form
20+
completion handlers in
21+
`packages/language_server/.../robotframework/parts/completion.py`.
22+
"""
23+
24+
import os
25+
import re
26+
from dataclasses import dataclass
27+
from typing import Any, Callable, Iterable, Iterator, List, Optional, Set
28+
29+
from robot.running.context import EXECUTION_CONTEXTS
30+
from robot.utils import normalize
31+
32+
from robotcode.robot.diagnostics.library_doc import (
33+
complete_library_import,
34+
complete_resource_import,
35+
complete_variables_import,
36+
)
37+
from robotcode.robot.utils import RF_VERSION
38+
39+
# Robot Framework 7.0 renamed `TestLibrary.handlers` → `TestLibrary.keywords`
40+
# (and the same on `UserLibrary` / `ResourceFile`). We pick the attribute
41+
# name once at import time so the iteration loops below stay readable.
42+
_LIB_KEYWORDS_ATTR = "keywords" if RF_VERSION >= (7, 0) else "handlers"
43+
44+
# Robot's cell separator: 2+ consecutive spaces or a tab.
45+
_CELL_SEP = re.compile(r" +|\t")
46+
# Variable opener anywhere in the current cell: `[$@&%]{<partial>` with an
47+
# unclosed brace. `(.*)` is the partial variable name typed so far.
48+
_VAR_OPEN = re.compile(r"([\$@&%])\{([^{}]*)$")
49+
50+
_VALID_SIGILS = "$@&%"
51+
52+
53+
def _norm(s: Optional[str]) -> str:
54+
"""Robot's case/whitespace/underscore-insensitive folding."""
55+
return normalize(s, ignore="_") if s else ""
56+
57+
58+
@dataclass(frozen=True)
59+
class CompletionContext:
60+
"""Where the cursor is and what kind of completion fits.
61+
62+
`kind` is one of "keyword", "variable", "library", "resource",
63+
"argument". `replace_start` is the buffer offset where the
64+
*replaceable token* begins — the caller substitutes
65+
`buffer[replace_start:cursor]` with the chosen candidate. `prefix`
66+
is the partial token the user has typed (for variable-context, the
67+
name without sigil/brace).
68+
"""
69+
70+
kind: str
71+
prefix: str
72+
replace_start: int
73+
sigil: str = "" # only set for `kind == "variable"`
74+
75+
76+
def tokenize(buffer: str, cursor: int) -> CompletionContext:
77+
"""Inspect the buffer up to `cursor` and classify the typing context."""
78+
text = buffer[:cursor]
79+
80+
# Locate the current cell start by walking back past the last cell
81+
# separator. Default: cursor is in the first cell, cell starts at 0.
82+
cell_start = 0
83+
prior_cells: List[str] = []
84+
matches = list(_CELL_SEP.finditer(text))
85+
if matches:
86+
last_sep = matches[-1]
87+
cell_start = last_sep.end()
88+
prior_cells = _CELL_SEP.split(text[: last_sep.start()])
89+
90+
current_cell = text[cell_start:]
91+
92+
# Variable-completion: unclosed `[$@&%]{...` anywhere in the current cell.
93+
var_m = _VAR_OPEN.search(current_cell)
94+
if var_m:
95+
sigil = var_m.group(1)
96+
name_partial = var_m.group(2)
97+
# `replace_start` points at the sigil so the substitution wipes
98+
# the entire `${partial` and writes `${NAME}` back in.
99+
replace_start = cell_start + var_m.start()
100+
return CompletionContext("variable", name_partial, replace_start, sigil=sigil)
101+
102+
# Cell-level dispatch.
103+
if not prior_cells:
104+
return CompletionContext("keyword", current_cell, cell_start)
105+
106+
first_cell = prior_cells[0].strip()
107+
folded = first_cell.casefold()
108+
if folded == "import library":
109+
return CompletionContext("library", current_cell, cell_start)
110+
if folded == "import resource":
111+
return CompletionContext("resource", current_cell, cell_start)
112+
if folded == "import variables":
113+
return CompletionContext("variables", current_cell, cell_start)
114+
115+
return CompletionContext("argument", current_cell, cell_start)
116+
117+
118+
_LIBRARY_KINDS = {"MODULE_INTERNAL", "MODULE", "FILE", "FOLDER"}
119+
_RESOURCE_KINDS = {"RESOURCE", "FILE", "FOLDER"}
120+
_VARIABLES_KINDS = {"MODULE_INTERNAL", "MODULE", "VARIABLES_MODULE", "VARIABLES", "FILE", "FOLDER"}
121+
122+
123+
def candidates_for(ctx: CompletionContext, *, working_dir: str = ".") -> List[str]:
124+
"""Return completion strings appropriate for `ctx`.
125+
126+
Variable references (`kind == "variable"`) come back fully wrapped
127+
(e.g. ``${TEST_NAME}``) so they're drop-in replacements for the
128+
partial ``${TEST`` the user is editing. Keyword candidates are
129+
raw labels.
130+
131+
Import-form candidates (``library`` / ``resource`` / ``variables``)
132+
carry their *full* user-typed prefix back: ``robot.libraries.Coll``
133+
returns ``robot.libraries.Collections``; ``./libs/My`` returns
134+
``./libs/MyLib.py``. The user's whole cell is replaced verbatim
135+
regardless of which Robot-supported addressing scheme they're
136+
typing.
137+
"""
138+
if ctx.kind == "keyword":
139+
return _filter_robot_normalised(_iter_keywords(), ctx.prefix)
140+
if ctx.kind == "variable":
141+
# `%{X}` is a *Robot environment variable* — resolved against
142+
# `os.environ` at runtime, NOT against the suite's variable
143+
# scope. The other sigils (`$`/`@`/`&`) share Robot's namespace.
144+
source: Iterable[str] = os.environ.keys() if ctx.sigil == "%" else _iter_variables()
145+
names = _filter_robot_normalised(source, ctx.prefix)
146+
return [f"{ctx.sigil}{{{n}}}" for n in names]
147+
148+
# Library / Resource / Variables each map to the matching `Settings`
149+
# entry in a `.robot` file: `Library`, `Resource`, `Variables`. The
150+
# language server's `complete_LibraryImport` / `complete_ResourceImport`
151+
# / `complete_VariablesImport` decide on separator semantics the same
152+
# way (see packages/language_server/.../parts/completion.py):
153+
#
154+
# - Library → dotted module paths supported (no `/` in input).
155+
# - Resource → file-system paths only; `.` is treated as part of the
156+
# filename (`common.resource`), not a module separator.
157+
# - Variables → dotted module paths supported (parallel to Library).
158+
if ctx.kind == "library":
159+
return _import_completions(
160+
ctx.prefix, working_dir, api=complete_library_import, allow_kinds=_LIBRARY_KINDS, support_dotted=True
161+
)
162+
if ctx.kind == "resource":
163+
return _import_completions(
164+
ctx.prefix, working_dir, api=complete_resource_import, allow_kinds=_RESOURCE_KINDS, support_dotted=False
165+
)
166+
if ctx.kind == "variables":
167+
return _import_completions(
168+
ctx.prefix, working_dir, api=complete_variables_import, allow_kinds=_VARIABLES_KINDS, support_dotted=True
169+
)
170+
return []
171+
172+
173+
def _import_completions(
174+
prefix: str,
175+
working_dir: str,
176+
*,
177+
api: Callable[..., Optional[List[Any]]],
178+
allow_kinds: Set[str],
179+
support_dotted: bool,
180+
) -> List[str]:
181+
"""Generic three-mode dispatch for library / resource / variables imports.
182+
183+
Each Robot discovery API behaves as a *directory listing* — pass
184+
it a head (or `None` for "the global namespace"), get back the
185+
entries reachable from that head. The candidates we return are
186+
the **full reconstructed cell**: head plus matching child, ready
187+
to drop into the line.
188+
189+
Modes, picked from the prefix syntax:
190+
191+
1. **Plain identifier or empty prefix** (no `.` `/` `\\`): call
192+
``api(None)``, filter by prefix. Matches the language server's
193+
behaviour — whatever sys.path / project / built-ins Robot
194+
discovers, we surface.
195+
2. **Filesystem path** (contains `/` or `\\`): split at the last
196+
separator, ``api(dir_part)``, filter by partial, prepend
197+
``dir_part`` back.
198+
3. **Dotted module path** (contains `.`, only with
199+
``support_dotted=True``): split at the last `.`, ask Robot for
200+
submodules of the head, stitch the head back. Resource imports
201+
skip this mode because Robot itself doesn't translate dots for
202+
them — `common.resource` is a filename, not `common/resource`.
203+
"""
204+
205+
def _fetch(name: Optional[str]) -> List[Any]:
206+
try:
207+
return list(api(name, working_dir=working_dir, base_dir=working_dir) or [])
208+
except Exception:
209+
return []
210+
211+
# Empty prefix — full discovery, no filter.
212+
if not prefix:
213+
results = _fetch(None)
214+
return sorted({r.label for r in results if r.kind.name in allow_kinds})
215+
216+
# Filesystem path (`/` or `\`) takes precedence — `./libs/foo.bar`
217+
# is a path, not a dotted module.
218+
if "/" in prefix or "\\" in prefix:
219+
sep_idx = max(prefix.rfind("/"), prefix.rfind("\\"))
220+
dir_part = prefix[: sep_idx + 1]
221+
partial = prefix[sep_idx + 1 :]
222+
results = _fetch(dir_part)
223+
partial_cf = partial.casefold()
224+
return sorted(
225+
{
226+
dir_part + r.label
227+
for r in results
228+
if r.kind.name in allow_kinds and r.label.casefold().startswith(partial_cf)
229+
}
230+
)
231+
232+
# Dotted module path — library + variables only.
233+
if support_dotted and "." in prefix:
234+
base, _, partial = prefix.rpartition(".")
235+
results = _fetch(base + ".")
236+
partial_cf = partial.casefold()
237+
return sorted(
238+
{
239+
f"{base}.{r.label}"
240+
for r in results
241+
if r.kind.name in allow_kinds and r.label.casefold().startswith(partial_cf)
242+
}
243+
)
244+
245+
# Plain identifier — full discovery, filter by prefix locally.
246+
results = _fetch(None)
247+
prefix_cf = prefix.casefold()
248+
return sorted({r.label for r in results if r.kind.name in allow_kinds and r.label.casefold().startswith(prefix_cf)})
249+
250+
251+
# ---------------------------------------------------------------------------
252+
# Robot runtime introspection — private helpers, isolated so a future
253+
# RF-API change only touches this section.
254+
# ---------------------------------------------------------------------------
255+
256+
257+
def _iter_keywords() -> Iterator[str]:
258+
"""Yield every keyword name reachable from the live execution context.
259+
260+
The keyword container moved between Robot versions:
261+
262+
- RF 7.0+: ``library.keywords`` → ``list[LibraryKeyword]`` (each
263+
with ``.name``).
264+
- RF 5/6: ``library.handlers`` → ``KeywordStore`` of ``Handler``
265+
instances (each also with ``.name``).
266+
267+
``_LIB_KEYWORDS_ATTR`` (selected once at module import) tells us
268+
which attribute to read; resources expose the same name.
269+
"""
270+
context = EXECUTION_CONTEXTS.current
271+
if context is None:
272+
return
273+
store = getattr(context.namespace, "_kw_store", None)
274+
if store is None:
275+
return
276+
seen: Set[str] = set()
277+
for lib in store.libraries.values():
278+
for kw in getattr(lib, _LIB_KEYWORDS_ATTR, ()) or ():
279+
name = getattr(kw, "name", None)
280+
if name and name not in seen:
281+
seen.add(name)
282+
yield name
283+
for resource in store.resources.values():
284+
for kw in getattr(resource, _LIB_KEYWORDS_ATTR, ()) or ():
285+
name = getattr(kw, "name", None)
286+
if name and name not in seen:
287+
seen.add(name)
288+
yield name
289+
290+
291+
def _iter_variables() -> Iterator[str]:
292+
"""Yield variable names (without the `${}` wrapping) from current scope."""
293+
context = EXECUTION_CONTEXTS.current
294+
if context is None:
295+
return
296+
for decorated in context.variables.as_dict().keys():
297+
name = str(decorated)
298+
# `as_dict(decoration=True)` returns names like `${VAR}` — strip
299+
# the wrapping so the partial-typed name matches what's inside `${`.
300+
if len(name) >= 3 and name[0] in _VALID_SIGILS and name[1] == "{" and name[-1] == "}":
301+
yield name[2:-1]
302+
else:
303+
yield name
304+
305+
306+
# ---------------------------------------------------------------------------
307+
# Filtering — Robot's case/whitespace/underscore-insensitive prefix match.
308+
# ---------------------------------------------------------------------------
309+
310+
311+
def _filter_robot_normalised(labels: Iterable[str], prefix: str) -> List[str]:
312+
"""Return unique labels whose normalised form starts with `prefix` normalised.
313+
314+
Sorted alphabetically (canonical case) — completion menus look more
315+
predictable that way than insertion-order.
316+
"""
317+
if not prefix:
318+
return sorted(set(labels))
319+
target = _norm(prefix)
320+
return sorted({label for label in labels if _norm(label).startswith(target)})

0 commit comments

Comments
 (0)