1010 CodeActionContext ,
1111 CodeActionKind ,
1212 Command ,
13+ Position ,
1314 Range ,
1415)
1516from robotcode .core .text_document import TextDocument
1819from robotcode .core .utils .logging import LoggingDescriptor
1920from robotcode .jsonrpc2 .protocol import rpc_method
2021from 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
2223from robotcode .robot .diagnostics .model_helper import ModelHelper
2324from 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+ )
2433from robotcode .robot .utils .ast import get_node_at_position , range_from_token
2534
2635from ...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+
3865class 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" ,
0 commit comments