|
4 | 4 | from typing import ( |
5 | 5 | TYPE_CHECKING, |
6 | 6 | Any, |
| 7 | + Iterable, |
7 | 8 | List, |
8 | 9 | Mapping, |
9 | 10 | Optional, |
|
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 @@ class CodeActionData(CodeActionDataBase): |
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,131 +181,183 @@ def code_action_create_keyword( |
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 ( |
162 | 187 | d |
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 | | - 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") |
168 | 199 |
|
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 | + ) |
173 | 217 |
|
174 | | - keyword_token = tokens[-1] |
| 218 | + return result if result else None |
175 | 219 |
|
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 | + # ------------------------------------------------------------------ |
179 | 223 |
|
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 |
184 | 261 |
|
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 |
187 | 275 |
|
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] |
189 | 280 |
|
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 |
192 | 284 |
|
193 | | - if not text: |
194 | | - continue |
| 285 | + lib_entry, kw_namespace = self.get_namespace_info_from_keyword_token(namespace, keyword_token) |
195 | 286 |
|
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 |
213 | 292 |
|
214 | | - return result if result else None |
| 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 | | - 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) |
249 | 301 |
|
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 |
252 | 306 |
|
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 |
254 | 310 |
|
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) |
261 | 312 |
|
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 | + ) |
267 | 318 |
|
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 |
272 | 323 |
|
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) |
274 | 325 |
|
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 |
281 | 332 |
|
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)) |
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) |
|
0 commit comments