optics-framework-lsp 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- optics_framework_lsp/__init__.py +2 -0
- optics_framework_lsp/cli.py +83 -0
- optics_framework_lsp/completion.py +467 -0
- optics_framework_lsp/keyword_catalog.py +47 -0
- optics_framework_lsp/keywords.py +353 -0
- optics_framework_lsp/lint.py +94 -0
- optics_framework_lsp/parser/__init__.py +0 -0
- optics_framework_lsp/parser/ast.py +76 -0
- optics_framework_lsp/parser/csv_parser.py +183 -0
- optics_framework_lsp/py.typed +0 -0
- optics_framework_lsp/rename.py +170 -0
- optics_framework_lsp/server.py +353 -0
- optics_framework_lsp/symbols.py +99 -0
- optics_framework_lsp/tokens.py +129 -0
- optics_framework_lsp/validation.py +387 -0
- optics_framework_lsp-1.0.0.dist-info/METADATA +347 -0
- optics_framework_lsp-1.0.0.dist-info/RECORD +20 -0
- optics_framework_lsp-1.0.0.dist-info/WHEEL +4 -0
- optics_framework_lsp-1.0.0.dist-info/entry_points.txt +3 -0
- optics_framework_lsp-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""optics-lsp — a language server for optics-framework csv suites.
|
|
2
|
+
|
|
3
|
+
With no arguments it speaks LSP over stdio, which is how an editor starts it. `lint` instead
|
|
4
|
+
validates a whole suite in one shot and reports every finding:
|
|
5
|
+
|
|
6
|
+
optics-lsp lint PATH # walk a project directory; readable output
|
|
7
|
+
optics-lsp lint # read {"files":[{"name","content"}]} as JSON on stdin
|
|
8
|
+
optics-lsp lint PATH --json # the machine-readable report either way
|
|
9
|
+
|
|
10
|
+
stdin is for a caller holding uploaded files in memory with nothing on disk, which is why it
|
|
11
|
+
always answers JSON. A path is for a person, so it prints lines.
|
|
12
|
+
|
|
13
|
+
Both branches import lazily: `lint` must not pay for pygls, and serving must not pay for
|
|
14
|
+
anything the linter needs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _lint(path: str | None, as_json: bool) -> int:
|
|
26
|
+
"""Exit 0 whenever the suite was validated — findings are data, not process failure — and
|
|
27
|
+
1 if the input could not be read at all."""
|
|
28
|
+
from .lint import as_text, report, walk
|
|
29
|
+
|
|
30
|
+
if path is None:
|
|
31
|
+
try:
|
|
32
|
+
body = json.load(sys.stdin)
|
|
33
|
+
files = [(entry["name"], entry["content"]) for entry in body["files"]]
|
|
34
|
+
except (json.JSONDecodeError, KeyError, TypeError, UnicodeDecodeError) as error:
|
|
35
|
+
print(
|
|
36
|
+
'optics-lsp lint: expected {"files": [{"name": ..., "content": ...}]} on stdin '
|
|
37
|
+
f"({type(error).__name__}: {error})",
|
|
38
|
+
file=sys.stderr,
|
|
39
|
+
)
|
|
40
|
+
return 1
|
|
41
|
+
as_json = True
|
|
42
|
+
else:
|
|
43
|
+
root = Path(path).expanduser()
|
|
44
|
+
if not root.is_dir():
|
|
45
|
+
print(f"optics-lsp lint: not a directory: {root}", file=sys.stderr)
|
|
46
|
+
return 1
|
|
47
|
+
files = walk(root)
|
|
48
|
+
|
|
49
|
+
found = report(files)
|
|
50
|
+
if as_json:
|
|
51
|
+
json.dump(found, sys.stdout)
|
|
52
|
+
sys.stdout.write("\n")
|
|
53
|
+
else:
|
|
54
|
+
print(as_text(found))
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> None:
|
|
59
|
+
parser = argparse.ArgumentParser(prog="optics-lsp", description=__doc__)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"command",
|
|
62
|
+
nargs="?",
|
|
63
|
+
choices=["lint"],
|
|
64
|
+
help="validate a suite instead of serving over stdio",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"path", nargs="?", help="project directory to walk; omitted, the suite is read on stdin"
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument("--json", action="store_true", help="report as JSON rather than lines")
|
|
70
|
+
# Unknown flags are ignored rather than rejected: editors pass their own (`--stdio`), and
|
|
71
|
+
# refusing them would break a client for no gain.
|
|
72
|
+
args, _ = parser.parse_known_args()
|
|
73
|
+
|
|
74
|
+
if args.command == "lint":
|
|
75
|
+
raise SystemExit(_lint(args.path, args.json))
|
|
76
|
+
|
|
77
|
+
from .server import server
|
|
78
|
+
|
|
79
|
+
server.start_io()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
main()
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# Completion, signature help, goto-definition and hover. The column a cursor sits in
|
|
2
|
+
# decides what belongs there.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
import io
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from lsprotocol.types import (
|
|
12
|
+
CompletionItem,
|
|
13
|
+
CompletionItemKind,
|
|
14
|
+
Hover,
|
|
15
|
+
Location,
|
|
16
|
+
MarkupContent,
|
|
17
|
+
MarkupKind,
|
|
18
|
+
ParameterInformation,
|
|
19
|
+
Position,
|
|
20
|
+
Range,
|
|
21
|
+
SignatureHelp,
|
|
22
|
+
SignatureInformation,
|
|
23
|
+
TextEdit,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .keyword_catalog import Catalog, Keyword, slug
|
|
27
|
+
from .parser.ast import AST
|
|
28
|
+
from .validation import (
|
|
29
|
+
VAR,
|
|
30
|
+
declarations,
|
|
31
|
+
declared,
|
|
32
|
+
element_refs,
|
|
33
|
+
module_conditions,
|
|
34
|
+
module_refs,
|
|
35
|
+
undefined,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ParamKind = Literal["module", "file", "api"]
|
|
40
|
+
|
|
41
|
+
# What a param holds, by keyword and position after `module_step`. Anything unlisted
|
|
42
|
+
# holds an element or variable.
|
|
43
|
+
PARAM_KINDS: dict[str, dict[int, ParamKind]] = {
|
|
44
|
+
"run loop": {0: "module"},
|
|
45
|
+
"execute module": {0: "module"},
|
|
46
|
+
"read data": {1: "file"},
|
|
47
|
+
"invoke api": {0: "api"},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Values a param accepts, by param name. Documented in docstrings only, so the catalog
|
|
51
|
+
# cannot supply them: `direction` is checked as `in ("up", "down")` by the appium driver,
|
|
52
|
+
# and `rule` as `any(...) if rule == 'any' else all(...)`.
|
|
53
|
+
PARAM_VALUES = {
|
|
54
|
+
"direction": ["up", "down", "left", "right"],
|
|
55
|
+
"rule": ["any", "all"],
|
|
56
|
+
"element_state": ["visible", "invisible", "enabled", "disabled"],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Test cases the runner lifts out of the normal order. `categorize_test_cases` matches
|
|
61
|
+
# the words anywhere in the name, so these are canonical spellings, not reserved words.
|
|
62
|
+
# No frequency is claimed here: `get_execution_queue` keys its plan by name, so the
|
|
63
|
+
# test-level pair lands once around the first test rather than around every one.
|
|
64
|
+
_LIFECYCLE = {
|
|
65
|
+
"Suite Setup": "suite setup, before the tests",
|
|
66
|
+
"Suite Teardown": "suite teardown, after the tests",
|
|
67
|
+
"Setup": "test-level setup",
|
|
68
|
+
"Teardown": "test-level teardown",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# The header decides what a csv is. These are the four `_identify_csv_content` accepts,
|
|
73
|
+
# written as the framework reads them back: `read_elements` looks up `Element_Name` with
|
|
74
|
+
# the case intact, while the other three readers lowercase theirs. Five params is what
|
|
75
|
+
# 5 of 7 real projects write.
|
|
76
|
+
_HEADERS = {
|
|
77
|
+
"test_case,test_step": "test cases",
|
|
78
|
+
"module_name,module_step,param_1,param_2,param_3,param_4,param_5": "modules",
|
|
79
|
+
"Element_Name,Element_ID": "elements",
|
|
80
|
+
"error_code,match_string,description,severity": "error definitions",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Cursor:
|
|
85
|
+
"""Where a position falls in a csv: its header row, column, and partial field."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, text: str, position: Position) -> None:
|
|
88
|
+
lines = text.splitlines()
|
|
89
|
+
line = lines[position.line] if position.line < len(lines) else ""
|
|
90
|
+
prefix = line[: position.character]
|
|
91
|
+
self.paired = line[position.character :].startswith("}")
|
|
92
|
+
|
|
93
|
+
self.header_line, header = next(
|
|
94
|
+
((i, row) for i, row in enumerate(lines) if row.strip()), (0, "")
|
|
95
|
+
)
|
|
96
|
+
self.header = header
|
|
97
|
+
self.headers = [h.strip().lower() for h in next(csv.reader(io.StringIO(header)), [])]
|
|
98
|
+
self.fields = [f.strip() for f in next(csv.reader(io.StringIO(line)), [])]
|
|
99
|
+
|
|
100
|
+
# csv, not prefix.count(","), so a quoted comma in an XPath does not shift us.
|
|
101
|
+
fields = next(csv.reader(io.StringIO(prefix)), [""]) or [""]
|
|
102
|
+
self.column = len(fields) - 1
|
|
103
|
+
self.partial = fields[-1]
|
|
104
|
+
self.line = position.line
|
|
105
|
+
self.start = position.character - len(self.partial)
|
|
106
|
+
|
|
107
|
+
def column_of(self, header: str) -> int | None:
|
|
108
|
+
return self.headers.index(header) if header in self.headers else None
|
|
109
|
+
|
|
110
|
+
def field(self, column: int) -> str:
|
|
111
|
+
return self.fields[column] if column < len(self.fields) else ""
|
|
112
|
+
|
|
113
|
+
def header_at(self, column: int) -> str:
|
|
114
|
+
return self.headers[column] if column < len(self.headers) else ""
|
|
115
|
+
|
|
116
|
+
def step_name(self, step: int) -> str:
|
|
117
|
+
return slug(self.field(step))
|
|
118
|
+
|
|
119
|
+
def replacement(self, text: str) -> TextEdit:
|
|
120
|
+
"""Replace the whole field, so ${b} completes without nesting into ${${b}}."""
|
|
121
|
+
# An editor that auto-pairs braces leaves `${|}`, and the item brings its own.
|
|
122
|
+
end = self.start + len(self.partial) + (self.paired and text.endswith("}"))
|
|
123
|
+
return TextEdit(
|
|
124
|
+
range=Range(
|
|
125
|
+
start=Position(line=self.line, character=self.start),
|
|
126
|
+
end=Position(line=self.line, character=end),
|
|
127
|
+
),
|
|
128
|
+
new_text=text,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _widen_header(cursor: Cursor, step: int) -> list[TextEdit] | None:
|
|
133
|
+
"""`csv.DictReader` drops cells the header does not name, so declare the columns."""
|
|
134
|
+
if cursor.line == cursor.header_line or cursor.column < len(cursor.headers):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
added = "".join(f",param_{i - step}" for i in range(len(cursor.headers), cursor.column + 1))
|
|
138
|
+
at = Position(line=cursor.header_line, character=len(cursor.header))
|
|
139
|
+
return [TextEdit(range=Range(start=at, end=at), new_text=added)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _item(cursor: Cursor, label: str, kind: CompletionItemKind, detail: str, text: str):
|
|
143
|
+
return CompletionItem(
|
|
144
|
+
label=label,
|
|
145
|
+
kind=kind,
|
|
146
|
+
detail=detail,
|
|
147
|
+
text_edit=cursor.replacement(text),
|
|
148
|
+
filter_text=text,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _modules(cursor: Cursor, ast: AST, prefix: str = "") -> list[CompletionItem]:
|
|
153
|
+
return [
|
|
154
|
+
_item(cursor, name, CompletionItemKind.Module, "module", prefix + name)
|
|
155
|
+
for name in sorted({m.name for m in ast.modules})
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _listing(
|
|
160
|
+
cursor: Cursor, names: Iterable[str], kind: CompletionItemKind, detail: str
|
|
161
|
+
) -> list[CompletionItem]:
|
|
162
|
+
"""Names offered as they are written."""
|
|
163
|
+
return [_item(cursor, name, kind, detail, name) for name in names]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _variables(cursor: Cursor, ast: AST) -> list[CompletionItem]:
|
|
167
|
+
names = {e.name for e in ast.elements} | declared(ast)
|
|
168
|
+
return [
|
|
169
|
+
_item(cursor, name, CompletionItemKind.Variable, "element", f"${{{name}}}")
|
|
170
|
+
for name in sorted(names)
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _params(
|
|
175
|
+
cursor: Cursor,
|
|
176
|
+
ast: AST,
|
|
177
|
+
catalog: Catalog | None,
|
|
178
|
+
step: int,
|
|
179
|
+
*,
|
|
180
|
+
data_files: Sequence[str],
|
|
181
|
+
apis: Sequence[str],
|
|
182
|
+
) -> list[CompletionItem]:
|
|
183
|
+
"""What belongs in a param column, by the keyword its row names."""
|
|
184
|
+
name = cursor.step_name(step)
|
|
185
|
+
param = cursor.column - step - 1
|
|
186
|
+
|
|
187
|
+
# `Condition` alternates condition, target. A target is always a module, while a
|
|
188
|
+
# condition is either a module, optionally !-inverted, or an expression.
|
|
189
|
+
if name == "condition":
|
|
190
|
+
modules = _modules(cursor, ast, "!" if cursor.partial.startswith("!") else "")
|
|
191
|
+
return modules if param % 2 else modules + _variables(cursor, ast)
|
|
192
|
+
|
|
193
|
+
kind = PARAM_KINDS.get(name, {}).get(param)
|
|
194
|
+
if kind == "module":
|
|
195
|
+
# A module to run, not an element to find, and written bare.
|
|
196
|
+
return _modules(cursor, ast)
|
|
197
|
+
if kind == "file":
|
|
198
|
+
# Resolved against the project root, so a relative path is what belongs here.
|
|
199
|
+
return _listing(cursor, data_files, CompletionItemKind.File, "data file")
|
|
200
|
+
if kind == "api":
|
|
201
|
+
return _listing(cursor, apis, CompletionItemKind.Value, "api")
|
|
202
|
+
|
|
203
|
+
# The catalog names the params, so a fixed-value one is found by name rather
|
|
204
|
+
# than by listing every keyword that happens to take a `direction`.
|
|
205
|
+
keyword = (catalog or {}).get(name)
|
|
206
|
+
names = keyword.params if keyword else []
|
|
207
|
+
if values := PARAM_VALUES.get(names[param] if param < len(names) else ""):
|
|
208
|
+
return _listing(cursor, values, CompletionItemKind.EnumMember, "value")
|
|
209
|
+
|
|
210
|
+
return _variables(cursor, ast)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def complete(
|
|
214
|
+
text: str,
|
|
215
|
+
position: Position,
|
|
216
|
+
ast: AST,
|
|
217
|
+
catalog: Catalog | None,
|
|
218
|
+
*,
|
|
219
|
+
images: Sequence[str] = (),
|
|
220
|
+
data_files: Sequence[str] = (),
|
|
221
|
+
apis: Sequence[str] = (),
|
|
222
|
+
) -> list[CompletionItem]:
|
|
223
|
+
cursor = Cursor(text, position)
|
|
224
|
+
|
|
225
|
+
# Nothing is defined yet, so the row being typed is the header that decides the kind.
|
|
226
|
+
if cursor.column == 0 and len([row for row in text.splitlines() if row.strip()]) <= 1:
|
|
227
|
+
kind = CompletionItemKind.Struct
|
|
228
|
+
return [_item(cursor, h, kind, detail, h) for h, detail in _HEADERS.items()]
|
|
229
|
+
|
|
230
|
+
step = cursor.column_of("module_step")
|
|
231
|
+
|
|
232
|
+
if step is not None and cursor.column == step:
|
|
233
|
+
# A step names a keyword or, for nested modules, another module.
|
|
234
|
+
items = _modules(cursor, ast)
|
|
235
|
+
for name, keyword in sorted((catalog or {}).items()):
|
|
236
|
+
label = name.title()
|
|
237
|
+
items.append(
|
|
238
|
+
_item(
|
|
239
|
+
cursor,
|
|
240
|
+
label,
|
|
241
|
+
CompletionItemKind.Keyword,
|
|
242
|
+
", ".join(keyword.params) or "no params",
|
|
243
|
+
label,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
return items
|
|
247
|
+
|
|
248
|
+
if step is not None and cursor.column > step:
|
|
249
|
+
items = _params(cursor, ast, catalog, step, data_files=data_files, apis=apis)
|
|
250
|
+
|
|
251
|
+
# Accepting a param the header does not cover declares it in the same edit.
|
|
252
|
+
for item in items:
|
|
253
|
+
item.additional_text_edits = _widen_header(cursor, step)
|
|
254
|
+
return items
|
|
255
|
+
|
|
256
|
+
# Both name columns continue an existing block, so they offer what already exists.
|
|
257
|
+
if cursor.column in (cursor.column_of("test_step"), cursor.column_of("module_name")):
|
|
258
|
+
return _modules(cursor, ast)
|
|
259
|
+
|
|
260
|
+
# Defining an element is how an element-not-found gets fixed, so offer those names.
|
|
261
|
+
if cursor.column == cursor.column_of("element_name"):
|
|
262
|
+
kind = CompletionItemKind.Variable
|
|
263
|
+
return _listing(cursor, sorted(undefined(ast)), kind, "used, not defined")
|
|
264
|
+
|
|
265
|
+
# An id is usually an xpath or literal text, which we cannot guess, but an image
|
|
266
|
+
# locator is the bare filename of a template somewhere in the project. Any
|
|
267
|
+
# `element_id*` column holds one, as `read_elements` reads them all.
|
|
268
|
+
if cursor.header_at(cursor.column).startswith("element_id"):
|
|
269
|
+
return _listing(cursor, images, CompletionItemKind.File, "template image")
|
|
270
|
+
|
|
271
|
+
if cursor.column == cursor.column_of("test_case"):
|
|
272
|
+
names = sorted({t.name for t in ast.test_cases})
|
|
273
|
+
return _listing(cursor, names, CompletionItemKind.Value, "test case") + [
|
|
274
|
+
_item(cursor, name, CompletionItemKind.Event, detail, name)
|
|
275
|
+
for name, detail in _LIFECYCLE.items()
|
|
276
|
+
if name not in names
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
return []
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _rendered(keyword: Keyword) -> list[str]:
|
|
283
|
+
"""Params as `name='default'`, so what an omitted cell falls back to is visible."""
|
|
284
|
+
return [
|
|
285
|
+
f"{name}={keyword.defaults[name]}" if name in keyword.defaults else name
|
|
286
|
+
for name in keyword.params
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def signature(text: str, position: Position, catalog: Catalog | None) -> SignatureHelp | None:
|
|
291
|
+
"""The keyword's params, with the column the cursor is in marked active."""
|
|
292
|
+
cursor = Cursor(text, position)
|
|
293
|
+
step = cursor.column_of("module_step")
|
|
294
|
+
if not catalog or step is None or cursor.column <= step:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
name = cursor.step_name(step)
|
|
298
|
+
keyword = catalog.get(name)
|
|
299
|
+
if keyword is None:
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
# A parameter label must be a substring of the signature for a client to highlight
|
|
303
|
+
# it, so both are built from the same rendering.
|
|
304
|
+
params = _rendered(keyword)
|
|
305
|
+
return SignatureHelp(
|
|
306
|
+
signatures=[
|
|
307
|
+
SignatureInformation(
|
|
308
|
+
label=f"{name.title()}({', '.join(params)})",
|
|
309
|
+
parameters=[ParameterInformation(label=p) for p in params],
|
|
310
|
+
)
|
|
311
|
+
],
|
|
312
|
+
active_signature=0,
|
|
313
|
+
active_parameter=min(cursor.column - step - 1, max(len(keyword.params) - 1, 0)),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _at(uri: str, row: int) -> Location:
|
|
318
|
+
"""A definition points at the start of its row, which is 1-based in the ast."""
|
|
319
|
+
at = Position(line=max(row - 1, 0), character=0)
|
|
320
|
+
return Location(uri=uri, range=Range(start=at, end=at))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def definition(
|
|
324
|
+
text: str, position: Position, ast: AST, catalog: Catalog | None
|
|
325
|
+
) -> list[Location]:
|
|
326
|
+
"""Where the module a step runs, or the elements a param reads, are defined."""
|
|
327
|
+
cursor = Cursor(text, position)
|
|
328
|
+
step = cursor.column_of("module_step")
|
|
329
|
+
if cursor.column != cursor.column_of("test_step") and (
|
|
330
|
+
step is None or cursor.column < step
|
|
331
|
+
):
|
|
332
|
+
return []
|
|
333
|
+
|
|
334
|
+
field = cursor.field(cursor.column)
|
|
335
|
+
|
|
336
|
+
# A step column resolves the keyword first, so a same-named module is not what runs.
|
|
337
|
+
if cursor.column == step and slug(field) in (catalog or {}):
|
|
338
|
+
return []
|
|
339
|
+
|
|
340
|
+
# A cell holds either ${names} to read or a bare name to run. Every ${name} in the
|
|
341
|
+
# cell is offered: a fallback element is several rows, and so is `${a} == ${b}`.
|
|
342
|
+
if names := set(VAR.findall(field)):
|
|
343
|
+
return [_at(e.uri, e.row) for e in ast.elements if e.name in names]
|
|
344
|
+
|
|
345
|
+
# Condition writes an inverted module as `!Name`; the runner strips the same way.
|
|
346
|
+
wanted = field.removeprefix("!")
|
|
347
|
+
return [_at(m.uri, m.start_row) for m in ast.modules if m.name == wanted]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# Only these classify as Image in `determine_element_type` (.tiff discovers but never
|
|
351
|
+
# matches), so an id ending in one is a template filename, not an xpath or literal text.
|
|
352
|
+
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _symbol_at(cursor: Cursor, catalog: Catalog | None) -> tuple[str, str] | None:
|
|
356
|
+
"""What the cursor is on, as a kind and the name to match against."""
|
|
357
|
+
field = cursor.field(cursor.column)
|
|
358
|
+
if not field:
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
step = cursor.column_of("module_step")
|
|
362
|
+
if cursor.column in (cursor.column_of("module_name"), cursor.column_of("test_step")):
|
|
363
|
+
return "module", field
|
|
364
|
+
if cursor.column == cursor.column_of("element_name"):
|
|
365
|
+
return "element", field
|
|
366
|
+
if cursor.header_at(cursor.column).startswith("element_id"):
|
|
367
|
+
# Only an image is shared by name; an xpath is written per row.
|
|
368
|
+
return ("image", field) if field.lower().endswith(IMAGE_SUFFIXES) else None
|
|
369
|
+
|
|
370
|
+
if step is None or cursor.column < step:
|
|
371
|
+
return None
|
|
372
|
+
if cursor.column == step:
|
|
373
|
+
# A keyword beats a same-named module here, as `_execute_single_keyword` resolves.
|
|
374
|
+
return ("keyword" if slug(field) in (catalog or {}) else "module"), field
|
|
375
|
+
|
|
376
|
+
if names := VAR.findall(field):
|
|
377
|
+
return "element", names[0]
|
|
378
|
+
|
|
379
|
+
kind = PARAM_KINDS.get(cursor.step_name(step), {}).get(cursor.column - step - 1)
|
|
380
|
+
if kind in ("file", "api"):
|
|
381
|
+
return kind, field
|
|
382
|
+
return "module", field.removeprefix("!")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def references(
|
|
386
|
+
text: str,
|
|
387
|
+
position: Position,
|
|
388
|
+
ast: AST,
|
|
389
|
+
catalog: Catalog | None,
|
|
390
|
+
*,
|
|
391
|
+
include_declaration: bool = False,
|
|
392
|
+
) -> list[Location]:
|
|
393
|
+
"""Every place the name under the cursor is used, and optionally where it is bound."""
|
|
394
|
+
cursor = Cursor(text, position)
|
|
395
|
+
found = _symbol_at(cursor, catalog)
|
|
396
|
+
if found is None:
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
kind, name = found
|
|
400
|
+
declared_at: list[Location] = []
|
|
401
|
+
|
|
402
|
+
if kind == "module":
|
|
403
|
+
# A Condition names a module to run, which validation cannot assume, and so does
|
|
404
|
+
# a step cell — but only when no keyword claims the name first.
|
|
405
|
+
seen = list(module_refs(ast)) + list(module_conditions(ast)) + [
|
|
406
|
+
(m.uri, step.row, step.step_name)
|
|
407
|
+
for m in ast.modules
|
|
408
|
+
for step in m.steps
|
|
409
|
+
if step.step_name and slug(step.step_name) not in (catalog or {})
|
|
410
|
+
]
|
|
411
|
+
uses = [_at(uri, row) for uri, row, n in seen if n == name]
|
|
412
|
+
declared_at = [_at(m.uri, m.start_row) for m in ast.modules if m.name == name]
|
|
413
|
+
elif kind == "element":
|
|
414
|
+
uses = [_at(uri, row) for uri, row, n in element_refs(ast) if n == name]
|
|
415
|
+
declared_at = [_at(e.uri, e.row) for e in ast.elements if e.name == name] + [
|
|
416
|
+
_at(uri, row) for uri, row, n in declarations(ast) if n == name
|
|
417
|
+
]
|
|
418
|
+
elif kind == "keyword":
|
|
419
|
+
# The framework defines it, so there is nothing here to declare.
|
|
420
|
+
wanted = slug(name)
|
|
421
|
+
uses = [
|
|
422
|
+
_at(module.uri, step.row)
|
|
423
|
+
for module in ast.modules
|
|
424
|
+
for step in module.steps
|
|
425
|
+
if slug(step.step_name) == wanted
|
|
426
|
+
]
|
|
427
|
+
elif kind == "image":
|
|
428
|
+
uses = [
|
|
429
|
+
_at(e.uri, e.row)
|
|
430
|
+
for e in ast.elements
|
|
431
|
+
if any(locator.text == name for locator in e.locators)
|
|
432
|
+
]
|
|
433
|
+
else:
|
|
434
|
+
# A file or an api, by the same table completion offers.
|
|
435
|
+
uses = [
|
|
436
|
+
_at(m.uri, step.row)
|
|
437
|
+
for m in ast.modules
|
|
438
|
+
for step in m.steps
|
|
439
|
+
for i, param in enumerate(step.params)
|
|
440
|
+
if PARAM_KINDS.get(slug(step.step_name), {}).get(i) == kind and param == name
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
# Sorted, so a result reads top to bottom per file rather than by rule order.
|
|
444
|
+
uses.sort(key=lambda at: (at.uri, at.range.start.line))
|
|
445
|
+
return uses + declared_at if include_declaration else uses
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def hover(text: str, position: Position, catalog: Catalog | None) -> Hover | None:
|
|
449
|
+
"""A keyword's signature and the framework's own docstring for it."""
|
|
450
|
+
cursor = Cursor(text, position)
|
|
451
|
+
step = cursor.column_of("module_step")
|
|
452
|
+
if step is None or cursor.column != step:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
keyword = (catalog or {}).get(cursor.step_name(step))
|
|
456
|
+
if keyword is None:
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
# Plain text, because the docstrings are reST: markdown would fold the `:param x:`
|
|
460
|
+
# lines into one paragraph.
|
|
461
|
+
label = f"{cursor.field(step)}({', '.join(_rendered(keyword))})"
|
|
462
|
+
return Hover(
|
|
463
|
+
contents=MarkupContent(
|
|
464
|
+
kind=MarkupKind.PlainText,
|
|
465
|
+
value=f"{label}\n\n{keyword.doc}" if keyword.doc else label,
|
|
466
|
+
)
|
|
467
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# The framework's keyword signatures, from the generated table in `keywords.py`.
|
|
2
|
+
#
|
|
3
|
+
# Hardcoded rather than read from the user's install: importing `optics_framework.api.*`
|
|
4
|
+
# pulls numpy, cv2, pandas and skimage, so probing needs a ~342 MB install and fails outright
|
|
5
|
+
# when one of those is broken — leaving a project with no keyword diagnostics at all. Run
|
|
6
|
+
# `scripts/update_catalog.py` to refresh the table against a new optics release.
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from .keywords import KEYWORDS, OPTICS_VERSION
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class Keyword:
|
|
17
|
+
required: int
|
|
18
|
+
variadic: bool
|
|
19
|
+
# Positional names in order. Python forbids a defaulted param before a plain one, so
|
|
20
|
+
# the first `required` of these are the mandatory ones.
|
|
21
|
+
params: list[str]
|
|
22
|
+
# The framework's own docstring, which is where the accepted values are written.
|
|
23
|
+
doc: str = ""
|
|
24
|
+
# What an omitted param falls back to, already repr'd, by param name.
|
|
25
|
+
defaults: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Catalog = dict[str, Keyword]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def slug(name: str | None) -> str:
|
|
32
|
+
"""A step name as `_execute_single_keyword` resolves it: whitespace collapsed and
|
|
33
|
+
lowercased. Module names are not — those it looks up raw."""
|
|
34
|
+
return " ".join((name or "").split()).lower()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Copied out of the table so a caller mutating a Keyword cannot corrupt it.
|
|
38
|
+
CATALOG: Catalog = {
|
|
39
|
+
name: Keyword(
|
|
40
|
+
required=signature["required"],
|
|
41
|
+
variadic=signature["variadic"],
|
|
42
|
+
params=list(signature["params"]),
|
|
43
|
+
doc=signature["doc"],
|
|
44
|
+
defaults=dict(signature["defaults"]),
|
|
45
|
+
)
|
|
46
|
+
for name, signature in KEYWORDS.items()
|
|
47
|
+
}
|