robotcode-robot 2.8.0.dev548__tar.gz → 2.8.0.dev670__tar.gz

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.
Files changed (50) hide show
  1. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/PKG-INFO +2 -2
  2. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/pyproject.toml +1 -1
  3. robotcode_robot-2.8.0.dev670/src/robotcode/robot/__version__.py +1 -0
  4. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/config/model.py +42 -35
  5. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/entities.py +20 -0
  6. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/library_doc.py +6 -3
  7. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/namespace.py +8 -20
  8. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/namespace_analyzer.py +31 -12
  9. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/analyzer.py +65 -12
  10. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/enums.py +1 -0
  11. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/model.py +41 -1
  12. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/nodes.py +49 -1
  13. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/serialization.py +25 -11
  14. robotcode_robot-2.8.0.dev548/src/robotcode/robot/__version__.py +0 -1
  15. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/.gitignore +0 -0
  16. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/README.md +0 -0
  17. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/__init__.py +0 -0
  18. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/config/__init__.py +0 -0
  19. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/config/loader.py +0 -0
  20. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/config/utils.py +0 -0
  21. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/__init__.py +0 -0
  22. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/analyzer_result.py +0 -0
  23. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/data_cache.py +0 -0
  24. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/diagnostic_rules.py +0 -0
  25. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/diagnostics_modifier.py +0 -0
  26. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/document_cache_helper.py +0 -0
  27. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/errors.py +0 -0
  28. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/import_resolver.py +0 -0
  29. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/imports_manager.py +0 -0
  30. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/keyword_finder.py +0 -0
  31. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/model_helper.py +0 -0
  32. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/project_index.py +0 -0
  33. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/scope_tree.py +0 -0
  34. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/__init__.py +0 -0
  35. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/json_dump.py +0 -0
  36. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/run_keyword.py +0 -0
  37. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/semantic_analyzer/variable_tokenizer.py +0 -0
  38. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/variable_scope.py +0 -0
  39. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/diagnostics/workspace_config.py +0 -0
  40. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/py.typed +0 -0
  41. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/__init__.py +0 -0
  42. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/ast.py +0 -0
  43. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/markdown_docs.py +0 -0
  44. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/markdownformatter.py +0 -0
  45. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/match.py +0 -0
  46. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/robot_patching.py +0 -0
  47. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/robot_path.py +0 -0
  48. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/stubs.py +0 -0
  49. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/variables.py +0 -0
  50. {robotcode_robot-2.8.0.dev548 → robotcode_robot-2.8.0.dev670}/src/robotcode/robot/utils/visitor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: robotcode-robot
3
- Version: 2.8.0-dev.548
3
+ Version: 2.8.0-dev.670
4
4
  Summary: Support classes for RobotCode for handling Robot Framework projects.
5
5
  Project-URL: Homepage, https://robotcode.io
6
6
  Project-URL: Donate, https://opencollective.com/robotcode
@@ -27,7 +27,7 @@ Classifier: Topic :: Utilities
27
27
  Classifier: Typing :: Typed
28
28
  Requires-Python: >=3.10
29
29
  Requires-Dist: platformdirs>=4.3
30
- Requires-Dist: robotcode-core==2.8.0-dev.548
30
+ Requires-Dist: robotcode-core==2.8.0-dev.670
31
31
  Requires-Dist: robotframework>=5.0.0
32
32
  Requires-Dist: tomli>=1.1.0; python_version < '3.11'
33
33
  Description-Content-Type: text/markdown
@@ -32,7 +32,7 @@ dependencies = [
32
32
  "robotframework>=5.0.0",
33
33
  "tomli>=1.1.0; python_version < '3.11'",
34
34
  "platformdirs>=4.3",
35
- "robotcode-core==2.8.0-dev.548",
35
+ "robotcode-core==2.8.0-dev.670",
36
36
  ]
37
37
  dynamic = ["version"]
38
38
 
@@ -0,0 +1 @@
1
+ __version__ = "2.8.0-dev.670"
@@ -3398,47 +3398,54 @@ class RobotConfig(RobotExtendBaseProfile):
3398
3398
  else:
3399
3399
  verbose_callback("No profiles selected.")
3400
3400
 
3401
+ # whether a selected profile is enabled; None when its condition has an error, which is reported only once
3402
+ enabled_states: Dict[str, Optional[bool]] = {}
3403
+
3404
+ def is_enabled(name: str) -> Optional[bool]:
3405
+ if name not in enabled_states:
3406
+ try:
3407
+ enabled_states[name] = selected_profiles[name][0].is_enabled()
3408
+ except EvaluationError as e:
3409
+ message = f"Error evaluating 'enabled' condition for profile '{name}': {e}"
3410
+
3411
+ if error_callback is None:
3412
+ raise ValueError(message) from e
3413
+
3414
+ error_callback(message)
3415
+ enabled_states[name] = None
3416
+ return enabled_states[name]
3417
+
3418
+ # a profile is used when it is enabled and selected itself, or when one of the profiles that inherit it is
3419
+ # used, so that a disabled profile brings none of the profiles it inherits from along, at any depth
3420
+ used_states: Dict[str, bool] = {}
3421
+
3422
+ def is_used(name: str) -> bool:
3423
+ if name not in used_states:
3424
+ parents = selected_profiles[name][1]
3425
+ used_states[name] = bool(is_enabled(name)) and (
3426
+ parents is None or any(is_used(parent_name) for parent_name, _ in parents)
3427
+ )
3428
+ return used_states[name]
3429
+
3401
3430
  for profile_name, (profile, parent_profiles) in sorted(
3402
3431
  selected_profiles.items(), key=lambda x: x[1][0].precedence or 0
3403
3432
  ):
3404
- try:
3405
- if not profile.is_enabled():
3406
- if verbose_callback:
3407
- verbose_callback(f'Skipping profile "{profile_name}" because it\'s disabled.')
3408
- continue
3409
- except EvaluationError as e:
3410
- message = f"Error evaluating 'enabled' condition for profile '{profile_name}': {e}"
3411
-
3412
- if error_callback is None:
3413
- raise ValueError(message) from e
3414
-
3415
- error_callback(message)
3433
+ profile_enabled = is_enabled(profile_name)
3434
+ if profile_enabled is None:
3416
3435
  continue
3417
3436
 
3418
- if parent_profiles is not None:
3419
- disabled_profiles = []
3420
- skip = False
3421
- for parent_profile in parent_profiles:
3422
- try:
3423
- if not parent_profile[1].is_enabled():
3424
- disabled_profiles.append(parent_profile[0])
3425
- except EvaluationError as e:
3426
- message = f"Error evaluating 'enabled' condition for profile '{parent_profile[0]}': {e}"
3427
-
3428
- if error_callback is None:
3429
- raise ValueError(message) from e
3430
-
3431
- error_callback(message)
3432
- skip = True
3433
- break
3437
+ if not profile_enabled:
3438
+ if verbose_callback:
3439
+ verbose_callback(f'Skipping profile "{profile_name}" because it\'s disabled.')
3440
+ continue
3434
3441
 
3435
- if skip or len(disabled_profiles) == len(parent_profiles):
3436
- if verbose_callback:
3437
- verbose_callback(
3438
- f"Skipping profile inherited '{profile_name}' because no parent is enabled."
3439
- f" Disabled profiles: {', '.join(disabled_profiles)}."
3440
- )
3441
- continue
3442
+ if not is_used(profile_name):
3443
+ if verbose_callback:
3444
+ verbose_callback(
3445
+ f"Skipping inherited profile '{profile_name}' because none of the profiles that inherit it"
3446
+ f" is used: {', '.join(sorted(name for name, _ in parent_profiles or ()))}."
3447
+ )
3448
+ continue
3442
3449
 
3443
3450
  if verbose_callback:
3444
3451
  verbose_callback(f'Using profile "{profile_name}".')
@@ -360,6 +360,26 @@ class VariablesEntry(LibraryEntry):
360
360
  )
361
361
 
362
362
 
363
+ def library_entry_key(entry: LibraryEntry) -> str:
364
+ """The key of a library entry in the namespace disk cache: of a
365
+ `namespace_references` entry in `NamespaceData` and of a `lib_entry` in
366
+ a pickled semantic model.
367
+
368
+ Like `LibraryEntry.__hash__`, it includes the import position, so it tells
369
+ apart the entries of one library: the implicitly imported BuiltIn and an
370
+ explicit import of it, or a library imported by a resource file and the
371
+ same library imported directly.
372
+ """
373
+
374
+ def position(r: Range) -> str:
375
+ return f"{r.start.line}.{r.start.character}-{r.end.line}.{r.end.character}"
376
+
377
+ return (
378
+ f"{type(entry).__name__}:{entry.name}:{entry.import_name}:{entry.args!r}:{entry.alias or ''}"
379
+ f":{entry.import_source or ''}:{position(entry.import_range)}:{position(entry.alias_range)}"
380
+ )
381
+
382
+
363
383
  @dataclass(slots=True)
364
384
  class TestCaseDefinition(SourceEntity):
365
385
  name: str
@@ -1930,13 +1930,16 @@ class LibraryDoc:
1930
1930
  def _get_title_markdown(self, header_level: int) -> str:
1931
1931
  result = f"#{'#' * header_level} {(self.type.capitalize()) if self.type else 'Unknown'} *{self.name}*\n\n"
1932
1932
 
1933
- if self.version or self.scope:
1933
+ # resource and suite files show GLOBAL, as in Libdoc; variable files have no scope in Robot Framework
1934
+ scope = self.scope if self.type != "VARIABLES" else ""
1935
+
1936
+ if self.version or scope:
1934
1937
  result += "| | |\n| :--- | :--- |\n"
1935
1938
 
1936
1939
  if self.version:
1937
1940
  result += f"| **Library Version:** | {self.version} |\n"
1938
- if self.scope:
1939
- result += f"| **Library Scope:** | {self.scope} |\n"
1941
+ if scope:
1942
+ result += f"| **Library Scope:** | {scope} |\n"
1940
1943
 
1941
1944
  result += "\n\n"
1942
1945
 
@@ -44,6 +44,7 @@ from .entities import (
44
44
  VariableDefinition,
45
45
  VariablesEntry,
46
46
  VariablesImport,
47
+ library_entry_key,
47
48
  )
48
49
  from .errors import DIAGNOSTICS_SOURCE_NAME
49
50
  from .import_resolver import FailedImport, ImportResolver
@@ -147,24 +148,6 @@ class NamespaceData:
147
148
  variable_definitions: Dict[str, VariableDefinition] = field(default_factory=dict)
148
149
 
149
150
 
150
- def _namespace_reference_key(entry: LibraryEntry) -> str:
151
- """The key of a `namespace_references` entry in `NamespaceData`.
152
-
153
- Like `LibraryEntry.__hash__`, it includes the import position, so it tells
154
- apart the entries of one library: the implicitly imported BuiltIn and an
155
- explicit import of it, or a library imported by a resource file and the
156
- same library imported directly.
157
- """
158
-
159
- def position(r: Range) -> str:
160
- return f"{r.start.line}.{r.start.character}-{r.end.line}.{r.end.character}"
161
-
162
- return (
163
- f"{type(entry).__name__}:{entry.name}:{entry.import_name}:{entry.args!r}:{entry.alias or ''}"
164
- f":{entry.import_source or ''}:{position(entry.import_range)}:{position(entry.alias_range)}"
165
- )
166
-
167
-
168
151
  class Namespace:
169
152
  """Data container holding all results of a namespace build.
170
153
 
@@ -516,7 +499,7 @@ class Namespace:
516
499
  """
517
500
  # Build namespace_references key from LibraryEntry identity
518
501
  ns_refs: Dict[str, Set[Location]] = {
519
- _namespace_reference_key(entry): locs for entry, locs in self._namespace_references.items()
502
+ library_entry_key(entry): locs for entry, locs in self._namespace_references.items()
520
503
  }
521
504
 
522
505
  # Collect all referenced variable definitions for stable_id → object lookup
@@ -637,6 +620,11 @@ class Namespace:
637
620
  for kw in library_doc.keywords:
638
621
  kw_by_id[kw.stable_id] = kw
639
622
 
623
+ # the init of a variables file, which the import statement of the semantic model refers to
624
+ for variables_entry in resolved.variables_imports.values():
625
+ for kw in variables_entry.library_doc.inits:
626
+ kw_by_id[kw.stable_id] = kw
627
+
640
628
  # Variables from scope layers (command_line, own, imported, builtin)
641
629
  for var in scope.iter_all():
642
630
  var_by_id[var.stable_id] = var
@@ -685,7 +673,7 @@ class Namespace:
685
673
  # the other maps add the implicit default libraries and the imports of
686
674
  # resource files.
687
675
  all_entries: Dict[str, LibraryEntry] = {
688
- _namespace_reference_key(entry): entry
676
+ library_entry_key(entry): entry
689
677
  for entry in itertools.chain(
690
678
  resolved.libraries.values(),
691
679
  resolved.resources.values(),
@@ -1458,7 +1458,10 @@ class NamespaceAnalyzer(Visitor):
1458
1458
  ignore_errors=True,
1459
1459
  )
1460
1460
 
1461
- if not matcher.is_assign(allow_assign_mark=True, allow_nested=True) or matcher.name is None:
1461
+ if (
1462
+ not matcher.is_assign(allow_assign_mark=True, allow_nested=True, allow_items=True)
1463
+ or matcher.name is None
1464
+ ):
1462
1465
  return
1463
1466
 
1464
1467
  if RF_VERSION >= (7, 0) and contains_variable(matcher.base, "$@&%"):
@@ -1496,6 +1499,19 @@ class NamespaceAnalyzer(Visitor):
1496
1499
  stripped_name_token = strip_variable_token(assign_token, matcher=matcher, parse_type=True)
1497
1500
 
1498
1501
  if matcher.items:
1502
+ # Robot Framework resolves the variables in the index; the search without types keeps a type
1503
+ # in the base, so the index starts after it
1504
+ raw = search_variable(matcher.string, ignore_errors=True)
1505
+ items_start = raw.start + len(raw.identifier) + len(raw.base) + 2
1506
+ items_token = Token(
1507
+ Token.ARGUMENT,
1508
+ matcher.string[items_start : raw.end],
1509
+ assign_token.lineno,
1510
+ assign_token.col_offset + items_start,
1511
+ )
1512
+ for var_token, var in self._iter_variables_from_token(items_token):
1513
+ self._handle_find_variable_result(var_token, var)
1514
+
1499
1515
  existing_var = self._find_variable(name)
1500
1516
  if existing_var is None:
1501
1517
  self._handle_find_variable_result(
@@ -1611,18 +1627,21 @@ class NamespaceAnalyzer(Visitor):
1611
1627
  if not matcher.is_scalar_assign():
1612
1628
  return
1613
1629
 
1614
- if (
1615
- next(
1616
- (
1617
- k
1618
- for k, v in self._variables.items()
1619
- if k == matcher
1620
- and v.type in [VariableDefinitionType.ARGUMENT, VariableDefinitionType.LOCAL_VARIABLE]
1621
- ),
1622
- None,
1630
+ existing_var = next(
1631
+ (
1632
+ v
1633
+ for k, v in self._variables.items()
1634
+ if k == matcher
1635
+ and v.type in [VariableDefinitionType.ARGUMENT, VariableDefinitionType.LOCAL_VARIABLE]
1636
+ ),
1637
+ None,
1638
+ )
1639
+ if existing_var is not None:
1640
+ # EXCEPT AS sets the existing variable again, like a FOR variable of that name
1641
+ self._variable_references[existing_var].add(
1642
+ Location(self._document_uri, range_from_token(strip_variable_token(variable_token)))
1623
1643
  )
1624
- is None
1625
- ):
1644
+ else:
1626
1645
  var_def = LocalVariableDefinition(
1627
1646
  name=variable_token.value,
1628
1647
  name_token=strip_variable_token(variable_token),
@@ -405,6 +405,8 @@ class SemanticAnalyzer(Visitor):
405
405
  )
406
406
  self._logger.exception(e)
407
407
 
408
+ self._mark_argument_uses()
409
+
408
410
  # Build indexes
409
411
  self._semantic_model.build_index()
410
412
 
@@ -423,6 +425,38 @@ class SemanticAnalyzer(Visitor):
423
425
  semantic_model=self._semantic_model,
424
426
  )
425
427
 
428
+ def _mark_argument_uses(self) -> None:
429
+ """Mark every variable token whose name the analysis resolved to an argument from `[Arguments]`, the same
430
+ resolution Find References and Rename use; embedded arguments stay unmarked."""
431
+ starts = {
432
+ (location.range.start.line, location.range.start.character)
433
+ for var, locations in self._variable_references.items()
434
+ if isinstance(var, ArgumentDefinition) and not isinstance(var, EmbeddedArgumentDefinition)
435
+ for location in locations
436
+ }
437
+ if starts:
438
+ for stmt in self._semantic_model.statements:
439
+ self._mark_argument_uses_in_statement(stmt, starts)
440
+
441
+ def _mark_argument_uses_in_statement(self, stmt: SemanticStatement, starts: Set[Tuple[int, int]]) -> None:
442
+ for token in stmt.tokens:
443
+ self._mark_argument_uses_in_token(token, starts)
444
+ # the inner calls of a Run Keyword variant have tokens of their own
445
+ if isinstance(stmt, RunKeywordCallStatement):
446
+ for inner in stmt.inner_calls:
447
+ self._mark_argument_uses_in_statement(inner, starts)
448
+
449
+ def _mark_argument_uses_in_token(self, token: SemanticToken, starts: Set[Tuple[int, int]]) -> None:
450
+ for sub in token.sub_tokens or ():
451
+ self._mark_argument_uses_in_token(sub, starts)
452
+ # a reference covers the name of the variable, its base
453
+ if (
454
+ token.kind is TokenKind.VARIABLE
455
+ and sub.kind is TokenKind.VARIABLE_BASE
456
+ and (sub.range.start.line, sub.range.start.character) in starts
457
+ ):
458
+ token.modifiers = (token.modifiers or frozenset()) | {TokenModifier.ARGUMENT}
459
+
426
460
  # --- Semantic Model helpers ---
427
461
 
428
462
  # Statement kinds that act as the block-header for control-flow blocks.
@@ -3176,7 +3210,10 @@ class SemanticAnalyzer(Visitor):
3176
3210
  ignore_errors=True,
3177
3211
  )
3178
3212
 
3179
- if not matcher.is_assign(allow_assign_mark=True, allow_nested=True) or matcher.name is None:
3213
+ if (
3214
+ not matcher.is_assign(allow_assign_mark=True, allow_nested=True, allow_items=True)
3215
+ or matcher.name is None
3216
+ ):
3180
3217
  return
3181
3218
 
3182
3219
  if RF_VERSION >= (7, 0) and contains_variable(matcher.base, "$@&%"):
@@ -3220,6 +3257,19 @@ class SemanticAnalyzer(Visitor):
3220
3257
  stripped_name_token = strip_variable_token(assign_token, matcher=matcher, parse_type=True)
3221
3258
 
3222
3259
  if matcher.items:
3260
+ # Robot Framework resolves the variables in the index; the search without types keeps a type
3261
+ # in the base, so the index starts after it
3262
+ raw = search_variable(matcher.string, ignore_errors=True)
3263
+ items_start = raw.start + len(raw.identifier) + len(raw.base) + 2
3264
+ items_token = Token(
3265
+ Token.ARGUMENT,
3266
+ matcher.string[items_start : raw.end],
3267
+ assign_token.lineno,
3268
+ assign_token.col_offset + items_start,
3269
+ )
3270
+ for var_token, var in self._iter_variables_from_token(items_token):
3271
+ self._handle_find_variable_result(var_token, var)
3272
+
3223
3273
  existing_var = self._find_variable(name)
3224
3274
  if existing_var is None:
3225
3275
  self._handle_find_variable_result(
@@ -3468,18 +3518,21 @@ class SemanticAnalyzer(Visitor):
3468
3518
  if not matcher.is_scalar_assign():
3469
3519
  return
3470
3520
 
3471
- if (
3472
- next(
3473
- (
3474
- k
3475
- for k, v in self._variables.items()
3476
- if k == matcher
3477
- and v.type in [VariableDefinitionType.ARGUMENT, VariableDefinitionType.LOCAL_VARIABLE]
3478
- ),
3479
- None,
3521
+ existing_var = next(
3522
+ (
3523
+ v
3524
+ for k, v in self._variables.items()
3525
+ if k == matcher
3526
+ and v.type in [VariableDefinitionType.ARGUMENT, VariableDefinitionType.LOCAL_VARIABLE]
3527
+ ),
3528
+ None,
3529
+ )
3530
+ if existing_var is not None:
3531
+ # EXCEPT AS sets the existing variable again, like a FOR variable of that name
3532
+ self._variable_references[existing_var].add(
3533
+ Location(self._document_uri, range_from_token(strip_variable_token(variable_token)))
3480
3534
  )
3481
- is None
3482
- ):
3535
+ else:
3483
3536
  var_def = LocalVariableDefinition(
3484
3537
  name=variable_token.value,
3485
3538
  name_token=strip_variable_token(variable_token),
@@ -194,6 +194,7 @@ class TokenModifier(Enum):
194
194
  EMBEDDED = "embedded"
195
195
  DECLARATION = "declaration"
196
196
  DOCUMENTATION = "documentation"
197
+ ARGUMENT = "argument" # a variable that names an argument from the keyword's [Arguments], not an embedded one
197
198
 
198
199
 
199
200
  class ForFlavor(Enum):
@@ -1,10 +1,12 @@
1
1
  from dataclasses import dataclass, field
2
2
  from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional
3
3
 
4
- from .enums import NodeKind
4
+ from .enums import NodeKind, TokenKind
5
5
  from .nodes import (
6
6
  DefinitionBlock,
7
7
  DefinitionStatement,
8
+ KeywordCallStatement,
9
+ RunKeywordCallStatement,
8
10
  SemanticBlock,
9
11
  SemanticNode,
10
12
  SemanticStatement,
@@ -23,6 +25,13 @@ _SECTION_KINDS: FrozenSet[NodeKind] = frozenset(
23
25
  }
24
26
  )
25
27
 
28
+ # The namespace and the name of a keyword reference; the span from the first to the
29
+ # last of them also covers the "." between. An inner call of a Run Keyword variant
30
+ # carries its name as KEYWORD_INNER.
31
+ _KEYWORD_REFERENCE_KINDS: FrozenSet[TokenKind] = frozenset(
32
+ {TokenKind.NAMESPACE, TokenKind.KEYWORD, TokenKind.KEYWORD_INNER}
33
+ )
34
+
26
35
  if TYPE_CHECKING:
27
36
  from ...utils.variables import VariableMatcher
28
37
  from ..entities import VariableDefinition
@@ -193,6 +202,37 @@ class SemanticModel:
193
202
  SemanticModel._collect_token_path(sub, line, col, path)
194
203
  return
195
204
 
205
+ def keyword_call_at(self, line: int, col: int) -> Optional[KeywordCallStatement]:
206
+ """Get the keyword call whose keyword reference covers a position.
207
+
208
+ The keyword reference is the namespace and the keyword name with the
209
+ "." between them, without a BDD prefix; its end counts as covered, as
210
+ in the legacy check of a keyword token. Besides the call that
211
+ `statement_at()` returns, this finds the calls in the body of an
212
+ inline IF, which are statements of their own on the line, and the
213
+ inner calls of a Run Keyword variant, searched recursively.
214
+ O(n) in statements.
215
+ """
216
+ for stmt in self.statements:
217
+ if isinstance(stmt, KeywordCallStatement) and stmt.line_start <= line <= stmt.line_end:
218
+ call = self._keyword_call_covering(stmt, line, col)
219
+ if call is not None:
220
+ return call
221
+ return None
222
+
223
+ @staticmethod
224
+ def _keyword_call_covering(stmt: KeywordCallStatement, line: int, col: int) -> Optional[KeywordCallStatement]:
225
+ reference = [token for token in stmt.tokens if token.kind in _KEYWORD_REFERENCE_KINDS]
226
+ if reference and reference[0].line == line:
227
+ if reference[0].col_offset <= col <= reference[-1].col_offset + reference[-1].length:
228
+ return stmt
229
+ if isinstance(stmt, RunKeywordCallStatement):
230
+ for inner in stmt.inner_calls:
231
+ call = SemanticModel._keyword_call_covering(inner, line, col)
232
+ if call is not None:
233
+ return call
234
+ return None
235
+
196
236
  def enclosing_definition(self, line: int) -> Optional["DefinitionBlock | DefinitionStatement"]:
197
237
  """Find the enclosing definition (Keyword/TestCase) containing the given line.
198
238
 
@@ -1,8 +1,9 @@
1
1
  from dataclasses import dataclass, field
2
- from typing import TYPE_CHECKING, FrozenSet, List, Optional, Tuple
2
+ from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional, Tuple
3
3
 
4
4
  from robotcode.core.lsp.types import Position, Range
5
5
 
6
+ from ..entities import library_entry_key
6
7
  from .enums import (
7
8
  ForFlavor,
8
9
  ForZipMode,
@@ -19,6 +20,16 @@ if TYPE_CHECKING:
19
20
  from ..library_doc import ArgumentSpec, KeywordDoc
20
21
 
21
22
 
23
+ def _slot_values(node: object) -> Dict[str, Any]:
24
+ """The values of all slots of a node, the base of a statement's pickled state."""
25
+ return {slot: getattr(node, slot) for cls in type(node).__mro__ for slot in getattr(cls, "__slots__", ())}
26
+
27
+
28
+ def _set_slot_values(node: object, state: Dict[str, Any]) -> None:
29
+ for slot, value in state.items():
30
+ setattr(node, slot, value)
31
+
32
+
22
33
  @dataclass(slots=True)
23
34
  class SemanticToken:
24
35
  """A single resolved token in the Semantic Model.
@@ -136,6 +147,21 @@ class KeywordCallStatement(SemanticStatement):
136
147
  # Variables that receive the return value: ${result}= My Keyword
137
148
  assign_variables: List[SemanticToken] = field(default_factory=list)
138
149
 
150
+ def __getstate__(self) -> Dict[str, Any]:
151
+ # The namespace disk cache stores ids for the keyword and the library
152
+ # entry, which belong to the analysis of the imports;
153
+ # `resolve_references()` maps them back to the restored namespace's
154
+ # objects. Inner calls replace their own references.
155
+ state = _slot_values(self)
156
+ if self.keyword_doc is not None:
157
+ state["keyword_doc"] = self.keyword_doc.stable_id
158
+ if self.lib_entry is not None:
159
+ state["lib_entry"] = library_entry_key(self.lib_entry)
160
+ return state
161
+
162
+ def __setstate__(self, state: Dict[str, Any]) -> None:
163
+ _set_slot_values(self, state)
164
+
139
165
 
140
166
  @dataclass(slots=True)
141
167
  class RunKeywordCallStatement(KeywordCallStatement):
@@ -274,6 +300,18 @@ class ImportStatement(SemanticStatement):
274
300
  # second AST walk + libdoc lookup.
275
301
  init_keyword_doc: Optional["KeywordDoc"] = None
276
302
 
303
+ def __getstate__(self) -> Dict[str, Any]:
304
+ # ids in the namespace disk cache, see `KeywordCallStatement.__getstate__`
305
+ state = _slot_values(self)
306
+ if self.lib_entry is not None:
307
+ state["lib_entry"] = library_entry_key(self.lib_entry)
308
+ if self.init_keyword_doc is not None:
309
+ state["init_keyword_doc"] = self.init_keyword_doc.stable_id
310
+ return state
311
+
312
+ def __setstate__(self, state: Dict[str, Any]) -> None:
313
+ _set_slot_values(self, state)
314
+
277
315
 
278
316
  # --- Settings ---
279
317
 
@@ -328,6 +366,16 @@ class TemplateDataStatement(SemanticStatement):
328
366
 
329
367
  template_keyword_doc: Optional["KeywordDoc"] = None
330
368
 
369
+ def __getstate__(self) -> Dict[str, Any]:
370
+ # an id in the namespace disk cache, see `KeywordCallStatement.__getstate__`
371
+ state = _slot_values(self)
372
+ if self.template_keyword_doc is not None:
373
+ state["template_keyword_doc"] = self.template_keyword_doc.stable_id
374
+ return state
375
+
376
+ def __setstate__(self, state: Dict[str, Any]) -> None:
377
+ _set_slot_values(self, state)
378
+
331
379
 
332
380
  # --- Blocks (structural containers) ---
333
381
 
@@ -5,13 +5,14 @@ stable_id strings back to live objects, and pickle helper utilities.
5
5
 
6
6
  The SemanticModel is designed to be pickle-friendly:
7
7
  - SemanticToken is a pure value object (no live references) - pickles natively
8
- - SemanticStatement subclasses hold keyword_doc/lib_entry references which
9
- are live objects that cannot survive pickle/unpickle across sessions
8
+ - SemanticStatement subclasses hold keyword_doc/lib_entry/init_keyword_doc/
9
+ template_keyword_doc references which are live objects that cannot survive
10
+ pickle/unpickle across sessions
10
11
 
11
12
  For serialization:
12
- 1. Before pickling: replace live object references with stable_id strings
13
- 2. Pickle normally
14
- 3. After unpickling: call resolve_references() to restore live objects
13
+ 1. When pickled, the statements store stable_id strings and library entry
14
+ keys instead of these references (`__getstate__` in nodes.py)
15
+ 2. After unpickling: call resolve_references() to restore live objects
15
16
  """
16
17
 
17
18
  from typing import TYPE_CHECKING, Dict, Optional
@@ -24,6 +25,7 @@ from .nodes import (
24
25
  RunKeywordCallStatement,
25
26
  SemanticBlock,
26
27
  SemanticStatement,
28
+ TemplateDataStatement,
27
29
  )
28
30
 
29
31
  if TYPE_CHECKING:
@@ -39,10 +41,11 @@ def resolve_references(
39
41
  ) -> None:
40
42
  """Resolve stable_id strings back to live objects after unpickling.
41
43
 
42
- After unpickling a SemanticModel, keyword_doc and lib_entry fields
43
- contain stable_id strings instead of live objects. This function
44
+ After unpickling a SemanticModel, the keyword_doc, lib_entry,
45
+ init_keyword_doc and template_keyword_doc fields contain stable_id
46
+ strings and library entry keys instead of live objects. This function
44
47
  walks the model and replaces them with the actual objects from the
45
- provided lookup dicts.
48
+ provided lookup dicts; an id that is not in them resolves to None.
46
49
 
47
50
  Args:
48
51
  model: The unpickled SemanticModel to resolve.
@@ -85,6 +88,9 @@ def _resolve_statement(
85
88
  _resolve_keyword_call(stmt, kw_by_id, entry_by_key)
86
89
  elif isinstance(stmt, ImportStatement):
87
90
  stmt.lib_entry = _resolve_entry(stmt.lib_entry, entry_by_key)
91
+ stmt.init_keyword_doc = _resolve_keyword(stmt.init_keyword_doc, kw_by_id)
92
+ elif isinstance(stmt, TemplateDataStatement):
93
+ stmt.template_keyword_doc = _resolve_keyword(stmt.template_keyword_doc, kw_by_id)
88
94
  elif isinstance(stmt, DefinitionStatement):
89
95
  if isinstance(stmt.arguments_spec, str):
90
96
  # arguments_spec should not be serialized as string,
@@ -98,9 +104,7 @@ def _resolve_keyword_call(
98
104
  entry_by_key: Dict[str, "LibraryEntry"],
99
105
  ) -> None:
100
106
  """Resolve references in a keyword call statement."""
101
- if isinstance(stmt.keyword_doc, str):
102
- stmt.keyword_doc = kw_by_id.get(stmt.keyword_doc) # type: ignore[unreachable]
103
-
107
+ stmt.keyword_doc = _resolve_keyword(stmt.keyword_doc, kw_by_id)
104
108
  stmt.lib_entry = _resolve_entry(stmt.lib_entry, entry_by_key)
105
109
 
106
110
  if isinstance(stmt, RunKeywordCallStatement):
@@ -108,6 +112,16 @@ def _resolve_keyword_call(
108
112
  _resolve_keyword_call(inner, kw_by_id, entry_by_key)
109
113
 
110
114
 
115
+ def _resolve_keyword(
116
+ keyword_doc: Optional["KeywordDoc"],
117
+ kw_by_id: Dict[str, "KeywordDoc"],
118
+ ) -> Optional["KeywordDoc"]:
119
+ """Resolve a single keyword reference."""
120
+ if isinstance(keyword_doc, str):
121
+ return kw_by_id.get(keyword_doc) # type: ignore[unreachable]
122
+ return keyword_doc
123
+
124
+
111
125
  def _resolve_entry(
112
126
  entry: Optional["LibraryEntry"],
113
127
  entry_by_key: Dict[str, "LibraryEntry"],
@@ -1 +0,0 @@
1
- __version__ = "2.8.0-dev.548"