|
| 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