robotcode-language-server 2.5.0__py3-none-any.whl → 2.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- robotcode/language_server/__version__.py +1 -1
- robotcode/language_server/common/parts/diagnostics.py +1 -1
- robotcode/language_server/robotframework/parts/code_action_documentation.py +183 -37
- robotcode/language_server/robotframework/parts/code_action_quick_fixes.py +171 -93
- robotcode/language_server/robotframework/parts/code_action_refactor.py +107 -67
- robotcode/language_server/robotframework/parts/diagnostics.py +2 -1
- robotcode/language_server/robotframework/parts/hover.py +1 -1
- robotcode/language_server/robotframework/parts/inlay_hint.py +230 -3
- robotcode/language_server/robotframework/parts/semantic_tokens.py +139 -16
- robotcode/language_server/robotframework/parts/signature_help.py +397 -1
- {robotcode_language_server-2.5.0.dist-info → robotcode_language_server-2.6.0.dist-info}/METADATA +5 -5
- {robotcode_language_server-2.5.0.dist-info → robotcode_language_server-2.6.0.dist-info}/RECORD +14 -14
- {robotcode_language_server-2.5.0.dist-info → robotcode_language_server-2.6.0.dist-info}/WHEEL +1 -1
- {robotcode_language_server-2.5.0.dist-info → robotcode_language_server-2.6.0.dist-info}/entry_points.txt +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2.
|
|
1
|
+
__version__ = "2.6.0"
|
|
@@ -436,7 +436,7 @@ class DiagnosticsProtocolPart(LanguageServerProtocolPart):
|
|
|
436
436
|
documents_to_collect = [
|
|
437
437
|
doc
|
|
438
438
|
for doc in documents
|
|
439
|
-
if doc.opened_in_editor or self.get_diagnostics_mode(
|
|
439
|
+
if doc.opened_in_editor or self.get_diagnostics_mode(doc.uri) == DiagnosticsMode.WORKSPACE
|
|
440
440
|
]
|
|
441
441
|
|
|
442
442
|
with self._logger.measure_time(
|
|
@@ -10,6 +10,7 @@ from robotcode.core.lsp.types import (
|
|
|
10
10
|
CodeActionContext,
|
|
11
11
|
CodeActionKind,
|
|
12
12
|
Command,
|
|
13
|
+
Position,
|
|
13
14
|
Range,
|
|
14
15
|
)
|
|
15
16
|
from robotcode.core.text_document import TextDocument
|
|
@@ -18,9 +19,16 @@ from robotcode.core.utils.dataclasses import CamelSnakeMixin
|
|
|
18
19
|
from robotcode.core.utils.logging import LoggingDescriptor
|
|
19
20
|
from robotcode.jsonrpc2.protocol import rpc_method
|
|
20
21
|
from 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
|
|
22
23
|
from robotcode.robot.diagnostics.model_helper import ModelHelper
|
|
23
24
|
from 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
|
+
)
|
|
24
32
|
from robotcode.robot.utils.ast import get_node_at_position, range_from_token
|
|
25
33
|
|
|
26
34
|
from ...common.decorators import code_action_kinds
|
|
@@ -53,6 +61,26 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
53
61
|
document: TextDocument,
|
|
54
62
|
range: Range,
|
|
55
63
|
context: CodeActionContext,
|
|
64
|
+
) -> Optional[List[Union[Command, CodeAction]]]:
|
|
65
|
+
namespace = self.parent.documents_cache.get_namespace(document)
|
|
66
|
+
|
|
67
|
+
# Tier 2 model-based path — used when the experimental SemanticAnalyzer
|
|
68
|
+
# is enabled. Reads everything off the SemanticModel: statement kind
|
|
69
|
+
# via `model.statement_at()`, the pre-resolved `keyword_doc`, the
|
|
70
|
+
# `import_name`, and SemanticTokens for cursor-position checks. No
|
|
71
|
+
# `find_keyword`, no AST walk.
|
|
72
|
+
semantic_model = namespace.semantic_model
|
|
73
|
+
if semantic_model is not None:
|
|
74
|
+
return self._collect_from_model(document, range, context, namespace, semantic_model)
|
|
75
|
+
|
|
76
|
+
return self._collect_legacy(document, range, context, namespace)
|
|
77
|
+
|
|
78
|
+
def _collect_legacy(
|
|
79
|
+
self,
|
|
80
|
+
document: TextDocument,
|
|
81
|
+
range: Range,
|
|
82
|
+
context: CodeActionContext,
|
|
83
|
+
namespace: Namespace,
|
|
56
84
|
) -> Optional[List[Union[Command, CodeAction]]]:
|
|
57
85
|
from robot.parsing.lexer import Token as RobotToken
|
|
58
86
|
from robot.parsing.model.statements import (
|
|
@@ -65,8 +93,6 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
65
93
|
TestTemplate,
|
|
66
94
|
)
|
|
67
95
|
|
|
68
|
-
namespace = self.parent.documents_cache.get_namespace(document)
|
|
69
|
-
|
|
70
96
|
model = self.parent.documents_cache.get_model(document)
|
|
71
97
|
node = get_node_at_position(model, range.start)
|
|
72
98
|
|
|
@@ -111,40 +137,7 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
111
137
|
|
|
112
138
|
if kw_doc is not None:
|
|
113
139
|
if context.only and CodeActionKind.SOURCE.value in context.only:
|
|
114
|
-
|
|
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)]
|
|
140
|
+
return self._build_keyword_action(kw_doc, document, namespace)
|
|
148
141
|
|
|
149
142
|
if isinstance(node, KeywordName):
|
|
150
143
|
name_token = node.get_token(RobotToken.KEYWORD_NAME)
|
|
@@ -161,6 +154,159 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
161
154
|
|
|
162
155
|
return None
|
|
163
156
|
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
# Tier 2 model-based collection
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _collect_from_model(
|
|
162
|
+
self,
|
|
163
|
+
document: TextDocument,
|
|
164
|
+
range: Range,
|
|
165
|
+
context: CodeActionContext,
|
|
166
|
+
namespace: Namespace,
|
|
167
|
+
model: SemanticModel,
|
|
168
|
+
) -> Optional[List[Union[Command, CodeAction]]]:
|
|
169
|
+
"""Mirror legacy three-branch logic (import / keyword-call / keyword-def)
|
|
170
|
+
purely off the SemanticModel — no AST walks, no `find_keyword`.
|
|
171
|
+
|
|
172
|
+
Position checks use SemanticTokens; URL inputs read from pre-resolved
|
|
173
|
+
statement fields (`import_name`, `keyword_doc`, `name`).
|
|
174
|
+
"""
|
|
175
|
+
# SemanticModel uses 1-indexed lines; LSP positions are 0-indexed.
|
|
176
|
+
stmt = model.statement_at(range.start.line + 1)
|
|
177
|
+
if stmt is None:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
# Branch 1: Library / Resource import — gated on context.only at entry.
|
|
181
|
+
if (
|
|
182
|
+
context.only
|
|
183
|
+
and isinstance(stmt, ImportStatement)
|
|
184
|
+
and stmt.import_type in (ImportType.LIBRARY, ImportType.RESOURCE)
|
|
185
|
+
and CodeActionKind.SOURCE.value in context.only
|
|
186
|
+
):
|
|
187
|
+
return self._import_action_from_model(stmt, document, range, namespace)
|
|
188
|
+
|
|
189
|
+
# Branch 2: keyword call / fixture / template.
|
|
190
|
+
if isinstance(stmt, KeywordCallStatement):
|
|
191
|
+
if range.start != range.end:
|
|
192
|
+
return None
|
|
193
|
+
kw_doc = stmt.keyword_doc
|
|
194
|
+
if kw_doc is None:
|
|
195
|
+
return None
|
|
196
|
+
if not self._cursor_on_keyword_reference(range.start, stmt):
|
|
197
|
+
return None
|
|
198
|
+
if not (context.only and CodeActionKind.SOURCE.value in context.only):
|
|
199
|
+
return None
|
|
200
|
+
return self._build_keyword_action(kw_doc, document, namespace)
|
|
201
|
+
|
|
202
|
+
# Branch 3: keyword definition header — no context.only check
|
|
203
|
+
# (legacy doesn't gate this branch either).
|
|
204
|
+
if isinstance(stmt, DefinitionStatement) and stmt.kind is NodeKind.KEYWORD_DEF:
|
|
205
|
+
name_tok = next((t for t in stmt.tokens if t.kind is TokenKind.KEYWORD_NAME), None)
|
|
206
|
+
if name_tok is None or range not in name_tok.range:
|
|
207
|
+
return None
|
|
208
|
+
url = self.build_url(
|
|
209
|
+
str(document.uri.to_path().name),
|
|
210
|
+
(),
|
|
211
|
+
document,
|
|
212
|
+
namespace,
|
|
213
|
+
name_tok.value,
|
|
214
|
+
)
|
|
215
|
+
return [self.open_documentation_code_action(url)]
|
|
216
|
+
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
def _import_action_from_model(
|
|
220
|
+
self,
|
|
221
|
+
stmt: ImportStatement,
|
|
222
|
+
document: TextDocument,
|
|
223
|
+
range: Range,
|
|
224
|
+
namespace: Namespace,
|
|
225
|
+
) -> Optional[List[Union[Command, CodeAction]]]:
|
|
226
|
+
"""Library / Resource import branch built off SemanticTokens.
|
|
227
|
+
|
|
228
|
+
- The import path lives in the IMPORT_NAME token (cursor-position check).
|
|
229
|
+
- Library `args` are the ARGUMENT tokens BEFORE the optional WITH NAME
|
|
230
|
+
marker (CONTROL_FLOW); anything after is the alias and must be
|
|
231
|
+
excluded — matches RF's `LibraryImport.args` semantics.
|
|
232
|
+
- Resource imports never carry args (RF API returns ()).
|
|
233
|
+
"""
|
|
234
|
+
name_tok = next((t for t in stmt.tokens if t.kind is TokenKind.IMPORT_NAME), None)
|
|
235
|
+
if name_tok is None or range not in name_tok.range:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
if stmt.import_type is ImportType.LIBRARY:
|
|
239
|
+
arg_values: List[str] = []
|
|
240
|
+
for tok in stmt.tokens:
|
|
241
|
+
if tok.kind is TokenKind.CONTROL_FLOW:
|
|
242
|
+
break # WITH NAME — everything after is the alias
|
|
243
|
+
if tok.kind is TokenKind.ARGUMENT:
|
|
244
|
+
arg_values.append(tok.value)
|
|
245
|
+
args: Tuple[str, ...] = tuple(arg_values)
|
|
246
|
+
else:
|
|
247
|
+
args = ()
|
|
248
|
+
|
|
249
|
+
url = self.build_url(stmt.import_name or "", args, document, namespace)
|
|
250
|
+
return [self.open_documentation_code_action(url)]
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _cursor_on_keyword_reference(pos: Position, stmt: KeywordCallStatement) -> bool:
|
|
254
|
+
"""Cursor is within the NAMESPACE / SEPARATOR / KEYWORD SemanticTokens
|
|
255
|
+
that make up the keyword reference (BDD prefix excluded). Mirrors
|
|
256
|
+
the legacy `position.is_in_range(range_from_token(keyword_token))`
|
|
257
|
+
after the BDD-prefix strip that
|
|
258
|
+
`get_keyworddoc_and_token_from_position` does.
|
|
259
|
+
"""
|
|
260
|
+
return any(
|
|
261
|
+
pos in t.range
|
|
262
|
+
for t in stmt.tokens
|
|
263
|
+
if t.kind in (TokenKind.NAMESPACE, TokenKind.SEPARATOR, TokenKind.KEYWORD)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _build_keyword_action(
|
|
267
|
+
self,
|
|
268
|
+
kw_doc: KeywordDoc,
|
|
269
|
+
document: TextDocument,
|
|
270
|
+
namespace: Namespace,
|
|
271
|
+
) -> Optional[List[Union[Command, CodeAction]]]:
|
|
272
|
+
"""Resolve the LibraryEntry that owns `kw_doc` and build the
|
|
273
|
+
Open-Documentation action. Shared between legacy and model paths so
|
|
274
|
+
the URL construction stays identical."""
|
|
275
|
+
entry: Optional[LibraryEntry] = None
|
|
276
|
+
|
|
277
|
+
if kw_doc.libtype == "LIBRARY":
|
|
278
|
+
entry = next(
|
|
279
|
+
(v for v in namespace.libraries.values() if v.library_doc == kw_doc.parent),
|
|
280
|
+
None,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
elif kw_doc.libtype == "RESOURCE":
|
|
284
|
+
entry = next(
|
|
285
|
+
(v for v in namespace.resources.values() if v.library_doc == kw_doc.parent),
|
|
286
|
+
None,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
self_libdoc = namespace.library_doc
|
|
290
|
+
if entry is None and self_libdoc == kw_doc.parent:
|
|
291
|
+
entry = LibraryEntry(
|
|
292
|
+
self_libdoc.name,
|
|
293
|
+
str(document.uri.to_path().name),
|
|
294
|
+
self_libdoc,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if entry is None:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
url = self.build_url(
|
|
301
|
+
entry.import_name,
|
|
302
|
+
entry.args,
|
|
303
|
+
document,
|
|
304
|
+
namespace,
|
|
305
|
+
kw_doc.name,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return [self.open_documentation_code_action(url)]
|
|
309
|
+
|
|
164
310
|
def open_documentation_code_action(self, url: str) -> CodeAction:
|
|
165
311
|
return CodeAction(
|
|
166
312
|
"Open Documentation",
|
|
@@ -4,6 +4,7 @@ from string import Template as StringTemplate
|
|
|
4
4
|
from typing import (
|
|
5
5
|
TYPE_CHECKING,
|
|
6
6
|
Any,
|
|
7
|
+
Iterable,
|
|
7
8
|
List,
|
|
8
9
|
Mapping,
|
|
9
10
|
Optional,
|
|
@@ -49,8 +50,13 @@ from robotcode.core.text_document import TextDocument
|
|
|
49
50
|
from robotcode.core.utils.dataclasses import as_dict, from_dict
|
|
50
51
|
from robotcode.core.utils.inspect import iter_methods
|
|
51
52
|
from robotcode.core.utils.logging import LoggingDescriptor
|
|
53
|
+
from robotcode.robot.diagnostics.entities import LibraryEntry
|
|
52
54
|
from robotcode.robot.diagnostics.errors import DIAGNOSTICS_SOURCE_NAME, Error
|
|
53
55
|
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
|
|
54
60
|
from robotcode.robot.utils.ast import (
|
|
55
61
|
FirstAndLastRealStatementFinder,
|
|
56
62
|
get_node_at_position,
|
|
@@ -97,6 +103,26 @@ ${name}
|
|
|
97
103
|
)
|
|
98
104
|
|
|
99
105
|
|
|
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
|
+
|
|
100
126
|
class RobotCodeActionQuickFixesProtocolPart(RobotLanguageServerProtocolPart, ModelHelper, CodeActionHelperMixin):
|
|
101
127
|
_logger = LoggingDescriptor()
|
|
102
128
|
|
|
@@ -155,7 +181,6 @@ class RobotCodeActionQuickFixesProtocolPart(RobotLanguageServerProtocolPart, Mod
|
|
|
155
181
|
CodeActionTriggerKind.INVOKED,
|
|
156
182
|
CodeActionTriggerKind.AUTOMATIC,
|
|
157
183
|
]:
|
|
158
|
-
model = self.parent.documents_cache.get_model(document)
|
|
159
184
|
namespace = self.parent.documents_cache.get_namespace(document)
|
|
160
185
|
|
|
161
186
|
for diagnostic in (
|
|
@@ -163,123 +188,176 @@ class RobotCodeActionQuickFixesProtocolPart(RobotLanguageServerProtocolPart, Mod
|
|
|
163
188
|
for d in context.diagnostics
|
|
164
189
|
if d.source == DIAGNOSTICS_SOURCE_NAME and d.code == Error.KEYWORD_NOT_FOUND
|
|
165
190
|
):
|
|
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
|
+
|
|
166
196
|
disabled = None
|
|
167
|
-
|
|
197
|
+
if lib_entry is not None and lib_entry.library_doc.type == "LIBRARY":
|
|
198
|
+
disabled = CodeActionDisabledType("Keyword is from a library")
|
|
168
199
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
)
|
|
173
217
|
|
|
174
|
-
|
|
218
|
+
return result if result else None
|
|
175
219
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
# Tier 2 model-based resolution for the "Create Keyword" quick fix
|
|
222
|
+
# ------------------------------------------------------------------
|
|
179
223
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
184
261
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
187
275
|
|
|
188
|
-
|
|
276
|
+
tokens = get_tokens_at_position(node, position)
|
|
277
|
+
if not tokens:
|
|
278
|
+
return None
|
|
279
|
+
keyword_token = tokens[-1]
|
|
189
280
|
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
192
284
|
|
|
193
|
-
|
|
194
|
-
continue
|
|
285
|
+
lib_entry, kw_namespace = self.get_namespace_info_from_keyword_token(namespace, keyword_token)
|
|
195
286
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
213
292
|
|
|
214
|
-
return
|
|
293
|
+
return text, lib_entry
|
|
215
294
|
|
|
216
295
|
def resolve_code_action_create_keyword(self, code_action: CodeAction, data: CodeActionData) -> Optional[CodeAction]:
|
|
217
296
|
document = self.parent.documents.get(data.document_uri)
|
|
218
297
|
if document is None:
|
|
219
298
|
return None
|
|
220
299
|
|
|
221
|
-
|
|
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)
|
|
249
301
|
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
252
306
|
|
|
253
|
-
|
|
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
|
|
254
310
|
|
|
255
|
-
|
|
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)
|
|
261
312
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
)
|
|
267
318
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
272
323
|
|
|
273
|
-
|
|
324
|
+
code_action.edit, select_range = self._apply_create_keyword(dest_document, insert_text)
|
|
274
325
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
281
332
|
|
|
282
|
-
|
|
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))
|
|
283
361
|
|
|
284
362
|
def _apply_create_keyword(self, document: TextDocument, insert_text: str) -> Tuple[WorkspaceEdit, Range]:
|
|
285
363
|
model = self.parent.documents_cache.get_model(document)
|