Skip to content

Commit 0768c84

Browse files
committed
feat(language-server): use the SemanticModel for the "Open Documentation" code action
When the experimental SemanticAnalyzer is enabled, the "Open Documentation" code action reads the pre-resolved keyword data and SemanticTokens from the model instead of looking the keyword up again on every request. Output is unchanged — verified by the existing E2E suite under both paths and a new equivalence test file.
1 parent 9415e86 commit 0768c84

5 files changed

Lines changed: 906 additions & 42 deletions

File tree

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

Lines changed: 206 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CodeActionContext,
1111
CodeActionKind,
1212
Command,
13+
Position,
1314
Range,
1415
)
1516
from robotcode.core.text_document import TextDocument
@@ -18,9 +19,17 @@
1819
from robotcode.core.utils.logging import LoggingDescriptor
1920
from robotcode.jsonrpc2.protocol import rpc_method
2021
from robotcode.robot.diagnostics.entities import LibraryEntry
21-
from robotcode.robot.diagnostics.library_doc import resolve_robot_variables
22+
from robotcode.robot.diagnostics.library_doc import KeywordDoc, resolve_robot_variables
2223
from robotcode.robot.diagnostics.model_helper import ModelHelper
2324
from robotcode.robot.diagnostics.namespace import Namespace
25+
from robotcode.robot.diagnostics.semantic_analyzer.enums import ImportType, NodeKind, TokenKind
26+
from robotcode.robot.diagnostics.semantic_analyzer.model import SemanticModel
27+
from robotcode.robot.diagnostics.semantic_analyzer.nodes import (
28+
DefinitionStatement,
29+
ImportStatement,
30+
KeywordCallStatement,
31+
SemanticToken,
32+
)
2433
from robotcode.robot.utils.ast import get_node_at_position, range_from_token
2534

2635
from ...common.decorators import code_action_kinds
@@ -35,6 +44,24 @@ class ConvertUriParams(CamelSnakeMixin):
3544
uri: str
3645

3746

47+
def _range_in_semantic_token(rng: Range, tok: SemanticToken) -> bool:
48+
"""Return True iff both endpoints of `rng` lie within `tok`'s span.
49+
50+
LSP `Range` is 0-indexed, `SemanticToken.line` is 1-indexed; both
51+
`col_offset` and `character` are 0-indexed. Mirrors the legacy
52+
`range in range_from_token(token)` semantics, including the inclusive
53+
end (`<=`) so a cursor exactly at the end of the token still matches.
54+
"""
55+
line0 = tok.line - 1
56+
end_col = tok.col_offset + tok.length
57+
return (
58+
rng.start.line == line0
59+
and tok.col_offset <= rng.start.character <= end_col
60+
and rng.end.line == line0
61+
and tok.col_offset <= rng.end.character <= end_col
62+
)
63+
64+
3865
class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart, ModelHelper):
3966
_logger = LoggingDescriptor()
4067

@@ -53,6 +80,26 @@ def collect(
5380
document: TextDocument,
5481
range: Range,
5582
context: CodeActionContext,
83+
) -> Optional[List[Union[Command, CodeAction]]]:
84+
namespace = self.parent.documents_cache.get_namespace(document)
85+
86+
# Tier 2 model-based path — used when the experimental SemanticAnalyzer
87+
# is enabled. Reads everything off the SemanticModel: statement kind
88+
# via `model.statement_at()`, the pre-resolved `keyword_doc`, the
89+
# `import_name`, and SemanticTokens for cursor-position checks. No
90+
# `find_keyword`, no AST walk.
91+
semantic_model = namespace.semantic_model
92+
if semantic_model is not None:
93+
return self._collect_from_model(document, range, context, namespace, semantic_model)
94+
95+
return self._collect_legacy(document, range, context, namespace)
96+
97+
def _collect_legacy(
98+
self,
99+
document: TextDocument,
100+
range: Range,
101+
context: CodeActionContext,
102+
namespace: Namespace,
56103
) -> Optional[List[Union[Command, CodeAction]]]:
57104
from robot.parsing.lexer import Token as RobotToken
58105
from robot.parsing.model.statements import (
@@ -65,8 +112,6 @@ def collect(
65112
TestTemplate,
66113
)
67114

68-
namespace = self.parent.documents_cache.get_namespace(document)
69-
70115
model = self.parent.documents_cache.get_model(document)
71116
node = get_node_at_position(model, range.start)
72117

@@ -111,40 +156,7 @@ def collect(
111156

112157
if kw_doc is not None:
113158
if context.only and CodeActionKind.SOURCE.value in context.only:
114-
entry: Optional[LibraryEntry] = None
115-
116-
if kw_doc.libtype == "LIBRARY":
117-
entry = next(
118-
(v for v in namespace.libraries.values() if v.library_doc == kw_doc.parent),
119-
None,
120-
)
121-
122-
elif kw_doc.libtype == "RESOURCE":
123-
entry = next(
124-
(v for v in namespace.resources.values() if v.library_doc == kw_doc.parent),
125-
None,
126-
)
127-
128-
self_libdoc = namespace.library_doc
129-
if entry is None and self_libdoc == kw_doc.parent:
130-
entry = LibraryEntry(
131-
self_libdoc.name,
132-
str(document.uri.to_path().name),
133-
self_libdoc,
134-
)
135-
136-
if entry is None:
137-
return None
138-
139-
url = self.build_url(
140-
entry.import_name,
141-
entry.args,
142-
document,
143-
namespace,
144-
kw_doc.name,
145-
)
146-
147-
return [self.open_documentation_code_action(url)]
159+
return self._build_keyword_action(kw_doc, document, namespace)
148160

149161
if isinstance(node, KeywordName):
150162
name_token = node.get_token(RobotToken.KEYWORD_NAME)
@@ -161,6 +173,163 @@ def collect(
161173

162174
return None
163175

176+
# ------------------------------------------------------------------
177+
# Tier 2 model-based collection
178+
# ------------------------------------------------------------------
179+
180+
def _collect_from_model(
181+
self,
182+
document: TextDocument,
183+
range: Range,
184+
context: CodeActionContext,
185+
namespace: Namespace,
186+
model: SemanticModel,
187+
) -> Optional[List[Union[Command, CodeAction]]]:
188+
"""Mirror legacy three-branch logic (import / keyword-call / keyword-def)
189+
purely off the SemanticModel — no AST walks, no `find_keyword`.
190+
191+
Position checks use SemanticTokens; URL inputs read from pre-resolved
192+
statement fields (`import_name`, `keyword_doc`, `name`).
193+
"""
194+
# SemanticModel uses 1-indexed lines; LSP positions are 0-indexed.
195+
stmt = model.statement_at(range.start.line + 1)
196+
if stmt is None:
197+
return None
198+
199+
# Branch 1: Library / Resource import — gated on context.only at entry.
200+
if (
201+
context.only
202+
and isinstance(stmt, ImportStatement)
203+
and stmt.import_type in (ImportType.LIBRARY, ImportType.RESOURCE)
204+
and CodeActionKind.SOURCE.value in context.only
205+
):
206+
return self._import_action_from_model(stmt, document, range, namespace)
207+
208+
# Branch 2: keyword call / fixture / template.
209+
if isinstance(stmt, KeywordCallStatement):
210+
if range.start != range.end:
211+
return None
212+
kw_doc = stmt.keyword_doc
213+
if kw_doc is None:
214+
return None
215+
if not self._cursor_on_keyword_reference(range.start, stmt):
216+
return None
217+
if not (context.only and CodeActionKind.SOURCE.value in context.only):
218+
return None
219+
return self._build_keyword_action(kw_doc, document, namespace)
220+
221+
# Branch 3: keyword definition header — no context.only check
222+
# (legacy doesn't gate this branch either).
223+
if isinstance(stmt, DefinitionStatement) and stmt.kind is NodeKind.KEYWORD_DEF:
224+
name_tok = next((t for t in stmt.tokens if t.kind is TokenKind.KEYWORD_NAME), None)
225+
if name_tok is None or not _range_in_semantic_token(range, name_tok):
226+
return None
227+
url = self.build_url(
228+
str(document.uri.to_path().name),
229+
(),
230+
document,
231+
namespace,
232+
name_tok.value,
233+
)
234+
return [self.open_documentation_code_action(url)]
235+
236+
return None
237+
238+
def _import_action_from_model(
239+
self,
240+
stmt: ImportStatement,
241+
document: TextDocument,
242+
range: Range,
243+
namespace: Namespace,
244+
) -> Optional[List[Union[Command, CodeAction]]]:
245+
"""Library / Resource import branch built off SemanticTokens.
246+
247+
- The import path lives in the IMPORT_NAME token (cursor-position check).
248+
- Library `args` are the ARGUMENT tokens BEFORE the optional WITH NAME
249+
marker (CONTROL_FLOW); anything after is the alias and must be
250+
excluded — matches RF's `LibraryImport.args` semantics.
251+
- Resource imports never carry args (RF API returns ()).
252+
"""
253+
name_tok = next((t for t in stmt.tokens if t.kind is TokenKind.IMPORT_NAME), None)
254+
if name_tok is None or not _range_in_semantic_token(range, name_tok):
255+
return None
256+
257+
if stmt.import_type is ImportType.LIBRARY:
258+
arg_values: List[str] = []
259+
for tok in stmt.tokens:
260+
if tok.kind is TokenKind.CONTROL_FLOW:
261+
break # WITH NAME — everything after is the alias
262+
if tok.kind is TokenKind.ARGUMENT:
263+
arg_values.append(tok.value)
264+
args: Tuple[str, ...] = tuple(arg_values)
265+
else:
266+
args = ()
267+
268+
url = self.build_url(stmt.import_name or "", args, document, namespace)
269+
return [self.open_documentation_code_action(url)]
270+
271+
@staticmethod
272+
def _cursor_on_keyword_reference(pos: Position, stmt: KeywordCallStatement) -> bool:
273+
"""Cursor is within the union of NAMESPACE + SEPARATOR + KEYWORD
274+
SemanticTokens — i.e. inside the keyword reference excluding any
275+
BDD prefix. Mirrors the legacy
276+
`position.is_in_range(range_from_token(keyword_token))` after the
277+
BDD-prefix strip that `get_keyworddoc_and_token_from_position` does.
278+
"""
279+
relevant = [t for t in stmt.tokens if t.kind in (TokenKind.NAMESPACE, TokenKind.SEPARATOR, TokenKind.KEYWORD)]
280+
if not relevant:
281+
return False
282+
line0 = relevant[0].line - 1 # SemanticToken.line is 1-indexed
283+
if pos.line != line0:
284+
return False
285+
start_col = min(t.col_offset for t in relevant)
286+
end_col = max(t.col_offset + t.length for t in relevant)
287+
return start_col <= pos.character <= end_col
288+
289+
def _build_keyword_action(
290+
self,
291+
kw_doc: KeywordDoc,
292+
document: TextDocument,
293+
namespace: Namespace,
294+
) -> Optional[List[Union[Command, CodeAction]]]:
295+
"""Resolve the LibraryEntry that owns `kw_doc` and build the
296+
Open-Documentation action. Shared between legacy and model paths so
297+
the URL construction stays identical."""
298+
entry: Optional[LibraryEntry] = None
299+
300+
if kw_doc.libtype == "LIBRARY":
301+
entry = next(
302+
(v for v in namespace.libraries.values() if v.library_doc == kw_doc.parent),
303+
None,
304+
)
305+
306+
elif kw_doc.libtype == "RESOURCE":
307+
entry = next(
308+
(v for v in namespace.resources.values() if v.library_doc == kw_doc.parent),
309+
None,
310+
)
311+
312+
self_libdoc = namespace.library_doc
313+
if entry is None and self_libdoc == kw_doc.parent:
314+
entry = LibraryEntry(
315+
self_libdoc.name,
316+
str(document.uri.to_path().name),
317+
self_libdoc,
318+
)
319+
320+
if entry is None:
321+
return None
322+
323+
url = self.build_url(
324+
entry.import_name,
325+
entry.args,
326+
document,
327+
namespace,
328+
kw_doc.name,
329+
)
330+
331+
return [self.open_documentation_code_action(url)]
332+
164333
def open_documentation_code_action(self, url: str) -> CodeAction:
165334
return CodeAction(
166335
"Open Documentation",

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2365,12 +2365,15 @@ def visit_TestCase(self, node: TestCase) -> None: # noqa: N802
23652365
self._end_block_handlers = []
23662366
self._scope_builder.push_scope(node.name or "", range_from_node(node))
23672367

2368-
# Create definition statement (header) and definition block (tree container)
2368+
# Create definition statement (header) and definition block (tree container).
2369+
# Tokens come from the header AST node (TestCaseName) so consumers can
2370+
# find the TEST_NAME SemanticToken without reaching back into the AST.
23692371
defn = DefinitionStatement(
23702372
kind=NodeKind.TEST_CASE_DEF,
23712373
line_start=node.lineno,
23722374
line_end=node.end_lineno or node.lineno,
23732375
name=node.name,
2376+
tokens=self._build_tokens_from_node(node.header),
23742377
)
23752378
defn_block = DefinitionBlock(
23762379
kind=NodeKind.TESTCASE,
@@ -2466,14 +2469,17 @@ def visit_Keyword(self, node: Keyword) -> None: # noqa: N802
24662469
self._end_block_handlers = []
24672470
self._scope_builder.push_scope(node.name or "", range_from_node(node))
24682471

2469-
# Create definition statement (header) and definition block (tree container)
2472+
# Create definition statement (header) and definition block (tree container).
2473+
# Tokens come from the header AST node (KeywordName) so consumers can
2474+
# find the KEYWORD_NAME SemanticToken without reaching back into the AST.
24702475
arguments_spec = self._current_keyword_doc.arguments_spec if self._current_keyword_doc else None
24712476
defn = DefinitionStatement(
24722477
kind=NodeKind.KEYWORD_DEF,
24732478
line_start=node.lineno,
24742479
line_end=node.end_lineno or node.lineno,
24752480
name=node.name,
24762481
arguments_spec=arguments_spec,
2482+
tokens=self._build_tokens_from_node(node.header),
24772483
)
24782484
defn_block = DefinitionBlock(
24792485
kind=NodeKind.KEYWORD,

0 commit comments

Comments
 (0)