Skip to content

Commit 6a3d5a3

Browse files
committed
feat(language-server): use the SemanticModel for the "Create Keyword" and "Assign Result to Variable" code actions
When the experimental SemanticAnalyzer is enabled, the "Create Keyword" quick fix (offered for unknown keywords) and the "Assign keyword result to variable" refactor read the keyword call's namespace, library entry and assignment state from the SemanticModel instead of walking the AST and re-resolving via ModelHelper. Output unchanged — covered by dedicated equivalence test files for both code actions. Side-fix: the analyzer now populates KeywordCallStatement.lib_entry and assign_variables. Both fields were declared on the dataclass from the start but never set by the visitors, so the model paths needed them filled in to match production behaviour.
1 parent 6d4fd6d commit 6a3d5a3

5 files changed

Lines changed: 1178 additions & 164 deletions

File tree

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

Lines changed: 171 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import (
55
TYPE_CHECKING,
66
Any,
7+
Iterable,
78
List,
89
Mapping,
910
Optional,
@@ -49,8 +50,13 @@
4950
from robotcode.core.utils.dataclasses import as_dict, from_dict
5051
from robotcode.core.utils.inspect import iter_methods
5152
from robotcode.core.utils.logging import LoggingDescriptor
53+
from robotcode.robot.diagnostics.entities import LibraryEntry
5254
from robotcode.robot.diagnostics.errors import DIAGNOSTICS_SOURCE_NAME, Error
5355
from robotcode.robot.diagnostics.model_helper import ModelHelper
56+
from robotcode.robot.diagnostics.namespace import Namespace
57+
from robotcode.robot.diagnostics.semantic_analyzer.enums import TokenKind
58+
from robotcode.robot.diagnostics.semantic_analyzer.model import SemanticModel
59+
from robotcode.robot.diagnostics.semantic_analyzer.nodes import KeywordCallStatement
5460
from robotcode.robot.utils.ast import (
5561
FirstAndLastRealStatementFinder,
5662
get_node_at_position,
@@ -97,6 +103,26 @@ class CodeActionData(CodeActionDataBase):
97103
)
98104

99105

106+
def _format_create_keyword_args(arg_values: Iterable[str]) -> List[str]:
107+
"""Build the placeholder list for the new keyword's `[Arguments]` line.
108+
109+
For each argument value:
110+
- `name=value` with a literal `name` (no variables in it) → `${name}`
111+
- everything else → `${argN}` (N counted across all arguments)
112+
113+
Pure function so both the AST and SemanticModel paths produce
114+
identical output for the same set of argument values.
115+
"""
116+
out: List[str] = []
117+
for value in arg_values:
118+
name, val = split_from_equals(value)
119+
if val is not None and not contains_variable(name, "$@&%"):
120+
out.append(f"${{{name}}}")
121+
else:
122+
out.append(f"${{arg{len(out) + 1}}}")
123+
return out
124+
125+
100126
class RobotCodeActionQuickFixesProtocolPart(RobotLanguageServerProtocolPart, ModelHelper, CodeActionHelperMixin):
101127
_logger = LoggingDescriptor()
102128

@@ -155,131 +181,183 @@ def code_action_create_keyword(
155181
CodeActionTriggerKind.INVOKED,
156182
CodeActionTriggerKind.AUTOMATIC,
157183
]:
158-
model = self.parent.documents_cache.get_model(document)
159184
namespace = self.parent.documents_cache.get_namespace(document)
160185

161186
for diagnostic in (
162187
d
163188
for d in context.diagnostics
164189
if d.source == DIAGNOSTICS_SOURCE_NAME and d.code == Error.KEYWORD_NOT_FOUND
165190
):
191+
resolved = self._resolve_create_keyword_target(document, diagnostic.range.start, namespace)
192+
if resolved is None:
193+
continue
194+
text, lib_entry = resolved
195+
166196
disabled = None
167-
node = get_node_at_position(model, diagnostic.range.start)
197+
if lib_entry is not None and lib_entry.library_doc.type == "LIBRARY":
198+
disabled = CodeActionDisabledType("Keyword is from a library")
168199

169-
if isinstance(node, (KeywordCall, Fixture, TestTemplate, Template)):
170-
tokens = get_tokens_at_position(node, diagnostic.range.start)
171-
if not tokens:
172-
continue
200+
result.append(
201+
CodeAction(
202+
f"Create Keyword `{text}`",
203+
kind=CodeActionKind.QUICK_FIX,
204+
data=as_dict(
205+
CodeActionData(
206+
"quickfix",
207+
"create_keyword",
208+
document.document_uri,
209+
diagnostic.range,
210+
)
211+
),
212+
diagnostics=[diagnostic],
213+
disabled=disabled,
214+
is_preferred=True,
215+
)
216+
)
173217

174-
keyword_token = tokens[-1]
218+
return result if result else None
175219

176-
bdd_token, token = self.split_bdd_prefix(namespace, keyword_token)
177-
if bdd_token is not None and token is not None:
178-
keyword_token = token
220+
# ------------------------------------------------------------------
221+
# Tier 2 model-based resolution for the "Create Keyword" quick fix
222+
# ------------------------------------------------------------------
179223

180-
(
181-
lib_entry,
182-
kw_namespace,
183-
) = self.get_namespace_info_from_keyword_token(namespace, keyword_token)
224+
def _resolve_create_keyword_target(
225+
self,
226+
document: TextDocument,
227+
position: Position,
228+
namespace: Namespace,
229+
) -> Optional[Tuple[str, Optional[LibraryEntry]]]:
230+
"""Resolve the bare keyword name + owning LibraryEntry for the
231+
"Create Keyword" quick fix at `position`.
232+
233+
Returns `(text, lib_entry)` or `None` if the position isn't a
234+
KeywordCall / Fixture / Template AST node, or the keyword name is
235+
empty after stripping the BDD / namespace prefix.
236+
237+
When the experimental SemanticAnalyzer is enabled the data comes
238+
directly from the SemanticStatement; otherwise we fall back to
239+
the legacy AST + `ModelHelper` path.
240+
"""
241+
semantic_model = namespace.semantic_model
242+
if semantic_model is not None:
243+
return self._resolve_create_keyword_target_from_model(position, semantic_model)
244+
return self._resolve_create_keyword_target_legacy(document, position, namespace)
245+
246+
def _resolve_create_keyword_target_from_model(
247+
self,
248+
position: Position,
249+
model: SemanticModel,
250+
) -> Optional[Tuple[str, Optional[LibraryEntry]]]:
251+
"""SemanticModel branch: KEYWORD SemanticToken already has the bare
252+
name (BDD prefix and namespace are split into their own tokens),
253+
and `KeywordCallStatement.lib_entry` is the pre-resolved owner."""
254+
stmt = model.statement_at(position.line + 1)
255+
if not isinstance(stmt, KeywordCallStatement):
256+
return None
257+
kw_tok = next((t for t in stmt.tokens if t.kind is TokenKind.KEYWORD), None)
258+
if kw_tok is None or not kw_tok.value:
259+
return None
260+
return kw_tok.value, stmt.lib_entry
184261

185-
if lib_entry is not None and lib_entry.library_doc.type == "LIBRARY":
186-
disabled = CodeActionDisabledType("Keyword is from a library")
262+
def _resolve_create_keyword_target_legacy(
263+
self,
264+
document: TextDocument,
265+
position: Position,
266+
namespace: Namespace,
267+
) -> Optional[Tuple[str, Optional[LibraryEntry]]]:
268+
"""AST + `ModelHelper` fallback (used when the experimental
269+
analyzer is off). Mirrors the original inline logic exactly so
270+
the model-path migration stays output-equivalent."""
271+
model = self.parent.documents_cache.get_model(document)
272+
node = get_node_at_position(model, position)
273+
if not isinstance(node, (KeywordCall, Fixture, TestTemplate, Template)):
274+
return None
187275

188-
text = keyword_token.value
276+
tokens = get_tokens_at_position(node, position)
277+
if not tokens:
278+
return None
279+
keyword_token = tokens[-1]
189280

190-
if lib_entry and kw_namespace:
191-
text = text[len(kw_namespace) + 1 :].strip()
281+
bdd_token, token = self.split_bdd_prefix(namespace, keyword_token)
282+
if bdd_token is not None and token is not None:
283+
keyword_token = token
192284

193-
if not text:
194-
continue
285+
lib_entry, kw_namespace = self.get_namespace_info_from_keyword_token(namespace, keyword_token)
195286

196-
result.append(
197-
CodeAction(
198-
f"Create Keyword `{text}`",
199-
kind=CodeActionKind.QUICK_FIX,
200-
data=as_dict(
201-
CodeActionData(
202-
"quickfix",
203-
"create_keyword",
204-
document.document_uri,
205-
diagnostic.range,
206-
)
207-
),
208-
diagnostics=[diagnostic],
209-
disabled=disabled,
210-
is_preferred=True,
211-
)
212-
)
287+
text = keyword_token.value
288+
if lib_entry and kw_namespace:
289+
text = text[len(kw_namespace) + 1 :].strip()
290+
if not text:
291+
return None
213292

214-
return result if result else None
293+
return text, lib_entry
215294

216295
def resolve_code_action_create_keyword(self, code_action: CodeAction, data: CodeActionData) -> Optional[CodeAction]:
217296
document = self.parent.documents.get(data.document_uri)
218297
if document is None:
219298
return None
220299

221-
model = self.parent.documents_cache.get_model(document)
222-
node = get_node_at_position(model, data.range.start)
223-
224-
if isinstance(node, (KeywordCall, Fixture, TestTemplate, Template)):
225-
tokens = get_tokens_at_position(node, data.range.start)
226-
if not tokens:
227-
return None
228-
229-
keyword_token = tokens[-1]
230-
231-
namespace = self.parent.documents_cache.get_namespace(document)
232-
233-
bdd_token, token = self.split_bdd_prefix(namespace, keyword_token)
234-
if bdd_token is not None and token is not None:
235-
keyword_token = token
236-
237-
(
238-
lib_entry,
239-
kw_namespace,
240-
) = self.get_namespace_info_from_keyword_token(namespace, keyword_token)
241-
242-
if lib_entry is not None and lib_entry.library_doc.type == "LIBRARY":
243-
return None
244-
245-
text = keyword_token.value
246-
247-
if lib_entry and kw_namespace:
248-
text = text[len(kw_namespace) + 1 :].strip()
300+
namespace = self.parent.documents_cache.get_namespace(document)
249301

250-
if not text:
251-
return None
302+
resolved = self._resolve_create_keyword_target(document, data.range.start, namespace)
303+
if resolved is None:
304+
return None
305+
text, lib_entry = resolved
252306

253-
arguments = []
307+
# Library keywords can't be auto-created; legacy returns None here.
308+
if lib_entry is not None and lib_entry.library_doc.type == "LIBRARY":
309+
return None
254310

255-
for t in node.get_tokens(Token.ARGUMENT):
256-
name, value = split_from_equals(cast(Token, t).value)
257-
if value is not None and not contains_variable(name, "$@&%"):
258-
arguments.append(f"${{{name}}}")
259-
else:
260-
arguments.append(f"${{arg{len(arguments) + 1}}}")
311+
arguments = self._collect_create_keyword_arguments(document, data.range.start, namespace)
261312

262-
insert_text = (
263-
KEYWORD_WITH_ARGS_TEMPLATE.substitute(name=text, args=" ".join(arguments))
264-
if arguments
265-
else KEYWORD_TEMPLATE.substitute(name=text)
266-
)
313+
insert_text = (
314+
KEYWORD_WITH_ARGS_TEMPLATE.substitute(name=text, args=" ".join(arguments))
315+
if arguments
316+
else KEYWORD_TEMPLATE.substitute(name=text)
317+
)
267318

268-
if lib_entry is not None and lib_entry.library_doc.type == "RESOURCE" and lib_entry.library_doc.source:
269-
dest_document = self.parent.documents.get_or_open_document(lib_entry.library_doc.source)
270-
else:
271-
dest_document = document
319+
if lib_entry is not None and lib_entry.library_doc.type == "RESOURCE" and lib_entry.library_doc.source:
320+
dest_document = self.parent.documents.get_or_open_document(lib_entry.library_doc.source)
321+
else:
322+
dest_document = document
272323

273-
code_action.edit, select_range = self._apply_create_keyword(dest_document, insert_text)
324+
code_action.edit, select_range = self._apply_create_keyword(dest_document, insert_text)
274325

275-
code_action.command = Command(
276-
SHOW_DOCUMENT_SELECT_AND_RENAME_COMMAND,
277-
SHOW_DOCUMENT_SELECT_AND_RENAME_COMMAND,
278-
[dest_document.document_uri, select_range, False],
279-
)
280-
return code_action
326+
code_action.command = Command(
327+
SHOW_DOCUMENT_SELECT_AND_RENAME_COMMAND,
328+
SHOW_DOCUMENT_SELECT_AND_RENAME_COMMAND,
329+
[dest_document.document_uri, select_range, False],
330+
)
331+
return code_action
281332

282-
return None
333+
def _collect_create_keyword_arguments(
334+
self,
335+
document: TextDocument,
336+
position: Position,
337+
namespace: Namespace,
338+
) -> List[str]:
339+
"""Build the `${name}` / `${argN}` placeholder list for the new
340+
keyword's `[Arguments]` line, derived from the offending call's
341+
ARGUMENT tokens.
342+
343+
SemanticModel-based when available (iterates `stmt.tokens`),
344+
falls back to the AST `node.get_tokens(ARGUMENT)` walk otherwise.
345+
Both paths apply the same `name=value` heuristic via
346+
`split_from_equals`.
347+
"""
348+
semantic_model = namespace.semantic_model
349+
if semantic_model is not None:
350+
stmt = semantic_model.statement_at(position.line + 1)
351+
if isinstance(stmt, KeywordCallStatement):
352+
arg_tokens = [t for t in stmt.tokens if t.kind is TokenKind.ARGUMENT]
353+
return _format_create_keyword_args(t.value for t in arg_tokens)
354+
return []
355+
356+
ast_model = self.parent.documents_cache.get_model(document)
357+
node = get_node_at_position(ast_model, position)
358+
if not isinstance(node, (KeywordCall, Fixture, TestTemplate, Template)):
359+
return []
360+
return _format_create_keyword_args(cast(Token, t).value for t in node.get_tokens(Token.ARGUMENT))
283361

284362
def _apply_create_keyword(self, document: TextDocument, insert_text: str) -> Tuple[WorkspaceEdit, Range]:
285363
model = self.parent.documents_cache.get_model(document)

0 commit comments

Comments
 (0)