2828from robotcode .core .utils .logging import LoggingDescriptor
2929from robotcode .robot .diagnostics .library_doc import KeywordDoc , LibraryDoc
3030from 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+ )
3138from robotcode .robot .utils .ast import (
3239 get_node_at_position ,
3340 get_tokens_at_position ,
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
5268class 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