Skip to content

Commit be36e61

Browse files
committed
feat(robot): pre-resolve more semantic data so LSP features can read it once
Inlay Hints now read from the SemanticModel instead of doing a second analysis pass. The model gains a real block hierarchy (FOR/WHILE/IF/TRY/ GROUP), pre-resolved init keyword docs on Library/Variables imports, and consolidated header-token construction so future LSP feature migrations stop re-walking the AST. The old AST-walk path stays as fallback while `robotcode.experimental.semanticModel` is off; both paths must produce identical hints (covered by parametrized equivalence tests across all supported RF versions).
1 parent 602a129 commit be36e61

18 files changed

Lines changed: 4187 additions & 792 deletions

File tree

packages/language_server/src/robotcode/language_server/robotframework/parts/inlay_hint.py

Lines changed: 230 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from robotcode.core.concurrent import check_current_task_canceled
77
from robotcode.core.language import language_id
8-
from robotcode.core.lsp.types import InlayHint, InlayHintKind, Range
8+
from robotcode.core.lsp.types import InlayHint, InlayHintKind, Position, Range
99
from robotcode.core.text_document import TextDocument
1010
from robotcode.core.utils.logging import LoggingDescriptor
1111
from robotcode.robot.diagnostics.library_doc import (
@@ -14,6 +14,13 @@
1414
LibraryDoc,
1515
)
1616
from robotcode.robot.diagnostics.namespace import Namespace
17+
from robotcode.robot.diagnostics.semantic_analyzer.enums import ImportType, TokenKind
18+
from robotcode.robot.diagnostics.semantic_analyzer.model import SemanticModel
19+
from robotcode.robot.diagnostics.semantic_analyzer.nodes import (
20+
ImportStatement,
21+
KeywordCallStatement,
22+
SemanticToken,
23+
)
1724
from robotcode.robot.utils.ast import (
1825
iter_nodes,
1926
range_from_node,
@@ -72,11 +79,30 @@ def collect(self, sender: Any, document: TextDocument, range: Range) -> Optional
7279
if config is None or (not config.parameter_names and not config.namespaces):
7380
return None
7481

75-
model = self.parent.documents_cache.get_model(document)
7682
namespace = self.parent.documents_cache.get_namespace(document)
7783

78-
result: List[InlayHint] = []
84+
# Tier 2 model-based path — used when the experimental SemanticAnalyzer
85+
# is enabled (semantic_model is populated). Falls back to the legacy
86+
# ModelHelper-based path otherwise.
87+
semantic_model = namespace.semantic_model
88+
if semantic_model is not None:
89+
return self._collect_from_model(document, range, namespace, semantic_model, config)
7990

91+
model = self.parent.documents_cache.get_model(document)
92+
return self._collect_legacy(document, range, model, namespace, config)
93+
94+
def _collect_legacy(
95+
self,
96+
document: TextDocument,
97+
range: Range,
98+
model: ast.AST,
99+
namespace: Namespace,
100+
config: InlayHintsConfig,
101+
) -> List[InlayHint]:
102+
"""Legacy AST-walk based collection. Kept callable directly so that
103+
tests can compare it against `_collect_from_model` for equivalence.
104+
"""
105+
result: List[InlayHint] = []
80106
for node in iter_nodes(model):
81107
check_current_task_canceled()
82108

@@ -92,6 +118,207 @@ def collect(self, sender: Any, document: TextDocument, range: Range) -> Optional
92118
r = method(document, range, node, model, namespace, config)
93119
if r is not None:
94120
result.extend(r)
121+
return result
122+
123+
# ------------------------------------------------------------------
124+
# Tier 2 model-based collection
125+
# ------------------------------------------------------------------
126+
127+
def _collect_from_model(
128+
self,
129+
document: TextDocument,
130+
range: Range,
131+
namespace: Namespace,
132+
model: SemanticModel,
133+
config: InlayHintsConfig,
134+
) -> List[InlayHint]:
135+
"""Iterate the SemanticModel statements and produce inlay hints
136+
directly from the pre-resolved data (no second find_keyword pass,
137+
no AST re-walk for imports).
138+
139+
Note: `RunKeywordCallStatement.inner_calls` are intentionally NOT
140+
traversed here. The legacy AST-walk path also doesn't reach
141+
Run-Keyword-If inner arguments (they live inside the parent's
142+
argument tokens, not as standalone AST nodes), so doing so in the
143+
model path would introduce a drift. If we want hints inside
144+
`Run Keyword If cond Log msg`, that's a future feature
145+
and needs a separate equivalence story.
146+
"""
147+
result: List[InlayHint] = []
148+
for stmt in model.statements:
149+
check_current_task_canceled()
150+
151+
# Range filter — `range` lines are 0-indexed, stmt.line_* are 1-indexed.
152+
if stmt.line_end - 1 < range.start.line:
153+
continue
154+
if stmt.line_start - 1 > range.end.line:
155+
break
156+
157+
if isinstance(stmt, KeywordCallStatement) and stmt.keyword_doc is not None:
158+
hints = self._inlay_hints_for_keyword_call(stmt, namespace, config)
159+
if hints:
160+
result.extend(hints)
161+
elif (
162+
config.parameter_names
163+
and isinstance(stmt, ImportStatement)
164+
and stmt.init_keyword_doc is not None
165+
and stmt.import_type in (ImportType.LIBRARY, ImportType.VARIABLES)
166+
):
167+
hints = self._inlay_hints_for_import(stmt, namespace, config)
168+
if hints:
169+
result.extend(hints)
170+
171+
return result
172+
173+
def _inlay_hints_for_import(
174+
self,
175+
stmt: ImportStatement,
176+
namespace: Namespace,
177+
config: InlayHintsConfig,
178+
) -> Optional[List[InlayHint]]:
179+
kw_doc = stmt.init_keyword_doc
180+
if kw_doc is None:
181+
return None
182+
183+
arg_tokens = [t for t in stmt.tokens if t.kind is TokenKind.ARGUMENT]
184+
# Imports never get a namespace prefix hint — pass keyword_token=None
185+
# so the namespace branch in the helper is skipped.
186+
return self._get_inlay_hint_from_semantic_tokens(
187+
keyword_token=None,
188+
kw_doc=kw_doc,
189+
arg_tokens=arg_tokens,
190+
arg_values=[t.value for t in arg_tokens],
191+
has_namespace_token=True,
192+
namespace=namespace,
193+
config=config,
194+
)
195+
196+
def _inlay_hints_for_keyword_call(
197+
self,
198+
stmt: KeywordCallStatement,
199+
namespace: Namespace,
200+
config: InlayHintsConfig,
201+
) -> Optional[List[InlayHint]]:
202+
kw_doc = stmt.keyword_doc
203+
if kw_doc is None:
204+
return None
205+
206+
# Top-level argument tokens carry positional/named args. NAMED_ARGUMENT_NAME
207+
# tokens already imply the user wrote the name, so a parameter-name hint
208+
# is redundant; we work from the parent ARGUMENT positions.
209+
arg_tokens = [t for t in stmt.tokens if t.kind is TokenKind.ARGUMENT]
210+
211+
keyword_token = next((t for t in stmt.tokens if t.kind is TokenKind.KEYWORD), None)
212+
has_namespace_token = any(t.kind is TokenKind.NAMESPACE for t in stmt.tokens)
213+
214+
return self._get_inlay_hint_from_semantic_tokens(
215+
keyword_token=keyword_token,
216+
kw_doc=kw_doc,
217+
arg_tokens=arg_tokens,
218+
arg_values=[t.value for t in arg_tokens],
219+
has_namespace_token=has_namespace_token,
220+
namespace=namespace,
221+
config=config,
222+
)
223+
224+
@staticmethod
225+
def _get_inlay_hint_from_semantic_tokens(
226+
keyword_token: Optional[SemanticToken],
227+
kw_doc: KeywordDoc,
228+
arg_tokens: List[SemanticToken],
229+
arg_values: List[str],
230+
has_namespace_token: bool,
231+
namespace: Namespace,
232+
config: InlayHintsConfig,
233+
) -> Optional[List[InlayHint]]:
234+
"""Model-based variant of `_get_inlay_hint`. Operates on SemanticTokens
235+
rather than RF tokens. Pure function so it can be unit-tested without
236+
the LSP protocol stack.
237+
"""
238+
from robot.errors import DataError
239+
240+
result: List[InlayHint] = []
241+
242+
if config.parameter_names:
243+
positional = None
244+
if kw_doc.arguments_spec is not None:
245+
try:
246+
positional, _ = kw_doc.arguments_spec.resolve(
247+
arg_values,
248+
None,
249+
resolve_variables_until=kw_doc.args_to_process,
250+
resolve_named=not kw_doc.is_any_run_keyword(),
251+
validate=False,
252+
)
253+
except DataError:
254+
pass
255+
256+
if positional is not None:
257+
kw_arguments = [
258+
a
259+
for a in kw_doc.arguments
260+
if a.kind
261+
not in [
262+
KeywordArgumentKind.NAMED_ONLY_MARKER,
263+
KeywordArgumentKind.POSITIONAL_ONLY_MARKER,
264+
KeywordArgumentKind.VAR_NAMED,
265+
KeywordArgumentKind.NAMED_ONLY,
266+
]
267+
]
268+
for i, _ in enumerate(positional):
269+
if i >= len(arg_tokens):
270+
break
271+
272+
index = i if i < len(kw_arguments) else len(kw_arguments) - 1
273+
if index < 0:
274+
continue
275+
276+
arg = kw_arguments[index]
277+
if i >= len(kw_arguments) and arg.kind != KeywordArgumentKind.VAR_POSITIONAL:
278+
break
279+
280+
prefix = ""
281+
if arg.kind == KeywordArgumentKind.VAR_POSITIONAL:
282+
prefix = "*"
283+
elif arg.kind == KeywordArgumentKind.VAR_NAMED:
284+
prefix = "**"
285+
286+
arg_token = arg_tokens[i]
287+
result.append(
288+
InlayHint(
289+
Position(line=arg_token.line - 1, character=arg_token.col_offset),
290+
f"{prefix}{arg.name}=",
291+
InlayHintKind.PARAMETER,
292+
)
293+
)
294+
295+
if keyword_token is not None and config.namespaces and not has_namespace_token:
296+
# Only suggest a namespace prefix if the user didn't already write one.
297+
if kw_doc.libtype == "LIBRARY":
298+
lib = next(
299+
(
300+
lib
301+
for lib in namespace.libraries.values()
302+
if lib.name == kw_doc.libname and kw_doc in lib.library_doc.keywords.keywords
303+
),
304+
None,
305+
)
306+
else:
307+
lib = next(
308+
(
309+
lib
310+
for lib in namespace.resources.values()
311+
if lib.name == kw_doc.libname and kw_doc in lib.library_doc.keywords.keywords
312+
),
313+
None,
314+
)
315+
if lib is not None:
316+
result.append(
317+
InlayHint(
318+
Position(line=keyword_token.line - 1, character=keyword_token.col_offset),
319+
f"{lib.alias or lib.name}.",
320+
)
321+
)
95322

96323
return result
97324

packages/robot/src/robotcode/robot/diagnostics/semantic_analyzer/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
ImportType,
66
NodeKind,
77
OnLimitAction,
8-
StatementKind,
98
TokenKind,
109
VarScope,
1110
)
@@ -14,9 +13,13 @@
1413
DefinitionBlock,
1514
DefinitionStatement,
1615
ExceptStatement,
16+
ForBlock,
1717
ForStatement,
18+
GroupBlock,
19+
IfBlock,
1820
IfStatement,
1921
ImportStatement,
22+
InlineIfStatement,
2023
KeywordCallStatement,
2124
ReturnStatement,
2225
RunKeywordCallStatement,
@@ -26,7 +29,9 @@
2629
SemanticToken,
2730
SettingStatement,
2831
TemplateDataStatement,
32+
TryBlock,
2933
VarStatement,
34+
WhileBlock,
3035
WhileStatement,
3136
)
3237
from .run_keyword import KeywordArgumentStrategy, get_keyword_argument_strategy
@@ -37,12 +42,16 @@
3742
"DefinitionBlock",
3843
"DefinitionStatement",
3944
"ExceptStatement",
45+
"ForBlock",
4046
"ForFlavor",
4147
"ForStatement",
4248
"ForZipMode",
49+
"GroupBlock",
50+
"IfBlock",
4351
"IfStatement",
4452
"ImportStatement",
4553
"ImportType",
54+
"InlineIfStatement",
4655
"KeywordArgumentStrategy",
4756
"KeywordCallStatement",
4857
"NodeKind",
@@ -56,11 +65,12 @@
5665
"SemanticStatement",
5766
"SemanticToken",
5867
"SettingStatement",
59-
"StatementKind",
6068
"TemplateDataStatement",
6169
"TokenKind",
70+
"TryBlock",
6271
"VarScope",
6372
"VarStatement",
73+
"WhileBlock",
6474
"WhileStatement",
6575
"build_variable_sub_tokens",
6676
"get_keyword_argument_strategy",

0 commit comments

Comments
 (0)