55
66from robotcode .core .concurrent import check_current_task_canceled
77from 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
99from robotcode .core .text_document import TextDocument
1010from robotcode .core .utils .logging import LoggingDescriptor
1111from robotcode .robot .diagnostics .library_doc import (
1414 LibraryDoc ,
1515)
1616from 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+ )
1724from 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
0 commit comments