Skip to content

Commit 9415e86

Browse files
committed
feat(language-server): use the SemanticModel for signature help
When the experimental SemanticAnalyzer is enabled, signature help reads the pre-resolved keyword data from the SemanticModel instead of looking the keyword up again on every request. Output is unchanged — verified by running the existing regression suite under both paths and a dedicated equivalence test file.
1 parent a87fc79 commit 9415e86

2 files changed

Lines changed: 975 additions & 0 deletions

File tree

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

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
from robotcode.core.utils.logging import LoggingDescriptor
2929
from robotcode.robot.diagnostics.library_doc import KeywordDoc, LibraryDoc
3030
from robotcode.robot.diagnostics.model_helper import ModelHelper
31+
from robotcode.robot.diagnostics.namespace import Namespace
32+
from robotcode.robot.diagnostics.semantic_analyzer.enums import ImportType, NodeKind
33+
from robotcode.robot.diagnostics.semantic_analyzer.model import SemanticModel
34+
from robotcode.robot.diagnostics.semantic_analyzer.nodes import (
35+
ImportStatement,
36+
KeywordCallStatement,
37+
)
3138
from robotcode.robot.utils.ast import (
3239
get_node_at_position,
3340
get_tokens_at_position,
@@ -48,6 +55,15 @@
4855
Optional[SignatureHelp],
4956
]
5057

58+
# AST node types the model-based path handles. KeywordCall and Fixture
59+
# (Setup / Teardown) collapse to `KeywordCallStatement` in the model;
60+
# LibraryImport / VariablesImport collapse to `ImportStatement`. Other
61+
# node types — including TestTemplate / Template — fall through to the
62+
# legacy `_find_method` lookup so we don't accidentally introduce
63+
# signature help where the legacy path produces None (legacy has no
64+
# `signature_help_TestTemplate` / `signature_help_Template` handler).
65+
_MODEL_HANDLED_AST_NAMES: frozenset[str] = frozenset({"KeywordCall", "Fixture", "LibraryImport", "VariablesImport"})
66+
5167

5268
class RobotSignatureHelpProtocolPart(RobotLanguageServerProtocolPart, ModelHelper):
5369
_logger = LoggingDescriptor()
@@ -81,6 +97,26 @@ def collect(
8197
document: TextDocument,
8298
position: Position,
8399
context: Optional[SignatureHelpContext] = None,
100+
) -> Optional[SignatureHelp]:
101+
namespace = self.parent.documents_cache.get_namespace(document)
102+
103+
# Tier 2 model-based path — used when the experimental SemanticAnalyzer
104+
# is enabled (semantic_model is populated). The arg-index math still
105+
# uses the RF token list (battle-tested via `get_argument_info_at_position`);
106+
# the model's contribution is skipping the second `find_keyword` /
107+
# libdoc lookup and reading the pre-resolved `keyword_doc` /
108+
# `init_keyword_doc` directly off the statement.
109+
semantic_model = namespace.semantic_model
110+
if semantic_model is not None:
111+
return self._collect_from_model(document, position, namespace, semantic_model)
112+
113+
return self._collect_legacy(document, position, context)
114+
115+
def _collect_legacy(
116+
self,
117+
document: TextDocument,
118+
position: Position,
119+
context: Optional[SignatureHelpContext],
84120
) -> Optional[SignatureHelp]:
85121
result_node = get_node_at_position(
86122
self.parent.documents_cache.get_model(document),
@@ -96,6 +132,149 @@ def collect(
96132

97133
return method(result_node, document, position, context)
98134

135+
# ------------------------------------------------------------------
136+
# Tier 2 model-based collection
137+
# ------------------------------------------------------------------
138+
139+
def _collect_from_model(
140+
self,
141+
document: TextDocument,
142+
position: Position,
143+
namespace: Namespace,
144+
model: SemanticModel,
145+
) -> Optional[SignatureHelp]:
146+
"""Resolve the SemanticStatement at the cursor and reuse the legacy
147+
`_get_signature_help` arg-index math with the pre-resolved
148+
`keyword_doc` / `init_keyword_doc`.
149+
150+
The cursor-on-keyword-name early-return and the WITH-NAME alias
151+
guard mirror the legacy path exactly so output is byte-equivalent.
152+
"""
153+
# SemanticModel uses 1-indexed lines; LSP positions are 0-indexed.
154+
stmt = model.statement_at(position.line + 1)
155+
if stmt is None:
156+
return None
157+
158+
# We still need the RF AST node for its raw token list — that's what
159+
# the existing `get_argument_info_at_position` walks. The SemanticModel
160+
# filters SEPARATOR / CONTINUATION tokens out, so we can't reuse its
161+
# tokens for the position math without re-implementing the whitespace /
162+
# multi-line edge cases. Hybrid-by-design: model for resolution, RF
163+
# tokens for position math.
164+
ast_model = self.parent.documents_cache.get_model(document)
165+
ast_node = get_node_at_position(ast_model, position, include_end=True)
166+
if ast_node is None:
167+
return None
168+
169+
ast_node_name = type(ast_node).__name__
170+
if ast_node_name not in _MODEL_HANDLED_AST_NAMES:
171+
# Statement type the model doesn't pre-resolve (yet) — fall back
172+
# so we don't silently drop signature help that legacy supported.
173+
method = self._find_method(type(ast_node))
174+
if method is None:
175+
return None
176+
return method(ast_node, document, position, None)
177+
178+
if isinstance(stmt, KeywordCallStatement):
179+
return self._signature_help_for_keyword_call_model(stmt, ast_node, document, position)
180+
181+
if isinstance(stmt, ImportStatement) and stmt.import_type in (ImportType.LIBRARY, ImportType.VARIABLES):
182+
return self._signature_help_for_import_model(stmt, ast_node, document, position, namespace)
183+
184+
return None
185+
186+
def _signature_help_for_keyword_call_model(
187+
self,
188+
stmt: KeywordCallStatement,
189+
ast_node: ast.AST,
190+
document: TextDocument,
191+
position: Position,
192+
) -> Optional[SignatureHelp]:
193+
from robot.parsing.lexer.tokens import Token as RobotToken
194+
195+
kw_doc = stmt.keyword_doc
196+
if kw_doc is None:
197+
return None
198+
199+
kw_node = cast(Statement, ast_node)
200+
201+
tokens_at_position = get_tokens_at_position(kw_node, position, include_end=True)
202+
if not tokens_at_position:
203+
return None
204+
205+
token_at_position = tokens_at_position[-1]
206+
if token_at_position.type not in (RobotToken.ARGUMENT, RobotToken.EOL, RobotToken.SEPARATOR):
207+
return None
208+
209+
# Cursor must be past the keyword name (with the same +2 grace the
210+
# legacy path uses, so " " right after the name still counts as "on
211+
# the name" — no signature popup until you've typed past the gap).
212+
keyword_token_type = (
213+
RobotToken.NAME
214+
if stmt.kind in (NodeKind.SETUP, NodeKind.TEARDOWN, NodeKind.TEMPLATE_KEYWORD)
215+
else RobotToken.KEYWORD
216+
)
217+
keyword_token = kw_node.get_token(keyword_token_type)
218+
if keyword_token is None:
219+
return None
220+
if position < range_from_token(keyword_token).extend(end_character=2).end:
221+
return None
222+
223+
return self._get_signature_help(kw_doc, kw_node.tokens, token_at_position, position)
224+
225+
def _signature_help_for_import_model(
226+
self,
227+
stmt: ImportStatement,
228+
ast_node: ast.AST,
229+
document: TextDocument,
230+
position: Position,
231+
namespace: Namespace,
232+
) -> Optional[SignatureHelp]:
233+
from robot.parsing.lexer.tokens import Token as RobotToken
234+
from robot.parsing.model.statements import LibraryImport, VariablesImport
235+
236+
kw_doc = stmt.init_keyword_doc
237+
if kw_doc is None:
238+
return None
239+
240+
import_node = cast(Statement, ast_node)
241+
242+
# Library imports have a NAME token + arguments + optional WITH NAME alias.
243+
# Variables imports have a NAME + arguments. Cursor on/before the NAME
244+
# token, on the alias, or in the alias keyword should not produce a
245+
# signature.
246+
name_token = import_node.get_token(RobotToken.NAME)
247+
if name_token is None or not name_token.value:
248+
return None
249+
if position <= range_from_token(name_token).extend(end_character=1).end:
250+
return None
251+
252+
with_name_token: Optional[Token] = None
253+
if isinstance(import_node, LibraryImport):
254+
with_name_token = next((t for t in import_node.tokens if t.value == "WITH NAME"), None)
255+
if with_name_token is not None and position >= range_from_token(with_name_token).start:
256+
return None
257+
258+
tokens_at_position = get_tokens_at_position(import_node, position)
259+
if not tokens_at_position:
260+
return None
261+
token_at_position = tokens_at_position[-1]
262+
if token_at_position.type not in (RobotToken.ARGUMENT, RobotToken.EOL, RobotToken.SEPARATOR):
263+
return None
264+
265+
# For library imports, anything past WITH NAME belongs to the alias and
266+
# is not part of the init signature. Trim the token list before passing
267+
# to the arg-index math, mirroring the legacy `signature_help_LibraryImport`.
268+
tokens: Sequence[Token]
269+
if isinstance(import_node, LibraryImport) and with_name_token is not None:
270+
tokens = import_node.tokens[: import_node.tokens.index(with_name_token)]
271+
elif isinstance(import_node, VariablesImport):
272+
tokens = import_node.tokens
273+
else:
274+
tokens = import_node.tokens
275+
276+
return self._get_signature_help(kw_doc, tokens, token_at_position, position)
277+
99278
def _signature_help_KeywordCall_or_Fixture( # noqa: N802
100279
self,
101280
keyword_name_token_type: str,

0 commit comments

Comments
 (0)