robotcode-language-server 2.6.2__py3-none-any.whl → 2.8.0.dev548__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.
Files changed (31) hide show
  1. robotcode/language_server/__version__.py +1 -1
  2. robotcode/language_server/common/parts/diagnostics.py +50 -17
  3. robotcode/language_server/common/parts/documents.py +34 -31
  4. robotcode/language_server/common/parts/workspace.py +57 -55
  5. robotcode/language_server/common/protocol.py +3 -1
  6. robotcode/language_server/robotframework/configuration.py +3 -0
  7. robotcode/language_server/robotframework/parts/code_action_documentation.py +440 -129
  8. robotcode/language_server/robotframework/parts/code_action_helper_mixin.py +17 -2
  9. robotcode/language_server/robotframework/parts/code_action_quick_fixes.py +6 -25
  10. robotcode/language_server/robotframework/parts/code_action_refactor.py +8 -6
  11. robotcode/language_server/robotframework/parts/code_lens.py +3 -4
  12. robotcode/language_server/robotframework/parts/completion.py +193 -21
  13. robotcode/language_server/robotframework/parts/debugging_utils.py +75 -6
  14. robotcode/language_server/robotframework/parts/diagnostics.py +15 -3
  15. robotcode/language_server/robotframework/parts/documents_cache.py +5 -1
  16. robotcode/language_server/robotframework/parts/hover.py +103 -21
  17. robotcode/language_server/robotframework/parts/http_server.py +17 -7
  18. robotcode/language_server/robotframework/parts/inline_value.py +69 -7
  19. robotcode/language_server/robotframework/parts/keywords_treeview.py +151 -53
  20. robotcode/language_server/robotframework/parts/model_variables.py +331 -0
  21. robotcode/language_server/robotframework/parts/references.py +47 -31
  22. robotcode/language_server/robotframework/parts/rename.py +1 -2
  23. robotcode/language_server/robotframework/parts/robot_workspace.py +229 -25
  24. robotcode/language_server/robotframework/parts/selection_range.py +26 -13
  25. robotcode/language_server/robotframework/parts/semantic_tokens.py +414 -113
  26. robotcode/language_server/robotframework/parts/signature_help.py +162 -70
  27. robotcode/language_server/robotframework/protocol.py +1 -0
  28. {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/METADATA +7 -6
  29. {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/RECORD +31 -30
  30. {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/WHEEL +1 -1
  31. {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,18 @@
1
1
  from dataclasses import dataclass
2
- from typing import TYPE_CHECKING, Any, List, Optional
2
+ from typing import TYPE_CHECKING, Any, Callable, List, Mapping, Optional, Tuple
3
3
 
4
4
  from robotcode.core.lsp.types import TextDocumentIdentifier
5
+ from robotcode.core.text_document import TextDocument
5
6
  from robotcode.core.uri import Uri
6
7
  from robotcode.core.utils.dataclasses import CamelSnakeMixin
7
8
  from robotcode.core.utils.logging import LoggingDescriptor
8
9
  from robotcode.jsonrpc2.protocol import rpc_method
9
- from robotcode.robot.diagnostics.model_helper import ModelHelper
10
+ from robotcode.robot.diagnostics.entities import LibraryEntry
11
+ from robotcode.robot.diagnostics.library_doc import KeywordDoc, LibraryDoc
12
+ from robotcode.robot.diagnostics.namespace import Namespace
13
+ from robotcode.robot.utils.markdown_docs import LinkResolver, ReferenceTarget
10
14
 
15
+ from .code_action_documentation import DocumentationTarget, keyword_reference_targets, link_documentation
11
16
  from .protocol_part import RobotLanguageServerProtocolPart
12
17
 
13
18
  if TYPE_CHECKING:
@@ -72,7 +77,7 @@ class GetDocumentationUrl(CamelSnakeMixin):
72
77
  keyword_id: Optional[str] = None
73
78
 
74
79
 
75
- class RobotKeywordsTreeViewPart(RobotLanguageServerProtocolPart, ModelHelper):
80
+ class RobotKeywordsTreeViewPart(RobotLanguageServerProtocolPart):
76
81
  _logger = LoggingDescriptor()
77
82
 
78
83
  def __init__(self, parent: "RobotLanguageServerProtocol") -> None:
@@ -96,50 +101,117 @@ class RobotKeywordsTreeViewPart(RobotLanguageServerProtocolPart, ModelHelper):
96
101
  result = []
97
102
 
98
103
  for _k, v in namespace.libraries.items():
104
+ documentation, keywords = (
105
+ self._import_documentation(v, True, document, namespace) if not no_documentation else (None, None)
106
+ )
99
107
  result.append(
100
108
  DocumentImport(
101
109
  name=v.name,
102
110
  alias=v.alias,
103
111
  id=str(hash(v)),
104
112
  type="library",
105
- documentation=v.library_doc.to_markdown(add_signature=False) if not no_documentation else None,
106
- keywords=[
107
- Keyword(
108
- l.name,
109
- str(hash(l)),
110
- l.parameter_signature(),
111
- l.to_markdown(add_signature=False) if not no_documentation else None,
112
- )
113
- for l in v.library_doc.keywords.values()
114
- ]
115
- if not no_documentation
116
- else None,
113
+ documentation=documentation,
114
+ keywords=keywords,
117
115
  )
118
116
  )
119
117
  for _k, v in namespace.resources.items():
118
+ documentation, keywords = (
119
+ self._import_documentation(v, False, document, namespace) if not no_documentation else (None, None)
120
+ )
120
121
  result.append(
121
122
  DocumentImport(
122
123
  name=v.name,
123
124
  alias=None,
124
125
  id=str(hash(v)),
125
126
  type="resource",
126
- documentation=v.library_doc.to_markdown(add_signature=False) if not no_documentation else None,
127
- keywords=[
128
- Keyword(
129
- l.name,
130
- str(hash(l)),
131
- l.parameter_signature(),
132
- l.to_markdown(add_signature=False) if not no_documentation else None,
133
- )
134
- for l in v.library_doc.keywords.values()
135
- ]
136
- if not no_documentation
137
- else None,
127
+ documentation=documentation,
128
+ keywords=keywords,
138
129
  )
139
130
  )
140
131
 
141
132
  return result
142
133
 
134
+ @property
135
+ def _links(self) -> bool:
136
+ return self.parent.robot_initialization_options.documentation_viewer_links
137
+
138
+ def _import_documentation(
139
+ self, entry: LibraryEntry, is_library: bool, document: TextDocument, namespace: Namespace
140
+ ) -> Tuple[str, List[Keyword]]:
141
+ """The tooltips of an import and of its keywords."""
142
+ library_doc = entry.library_doc
143
+ target = (
144
+ self.parent.robot_code_action_documentation.entry_target(entry, is_library, document, namespace)
145
+ if self._links
146
+ else None
147
+ )
148
+ link_resolver = self._link_resolver(document, namespace, target, library_doc)
149
+ return (
150
+ self._documentation(
151
+ lambda link_resolver: library_doc.to_markdown(add_signature=False, link_resolver=link_resolver),
152
+ target,
153
+ link_resolver,
154
+ ),
155
+ self._keywords(list(library_doc.keywords.values()), target, link_resolver),
156
+ )
157
+
158
+ def _link_resolver(
159
+ self,
160
+ document: TextDocument,
161
+ namespace: Namespace,
162
+ target: Optional[DocumentationTarget],
163
+ library_doc: LibraryDoc,
164
+ ) -> Optional[LinkResolver]:
165
+ # one for the tooltips of an import and all of its keywords
166
+ if target is None:
167
+ return None
168
+ return self.parent.robot_code_action_documentation.link_resolver(document, namespace, target, library_doc)
169
+
170
+ def _keywords(
171
+ self,
172
+ keywords: List[KeywordDoc],
173
+ target: Optional[DocumentationTarget],
174
+ link_resolver: Optional[LinkResolver],
175
+ ) -> List[Keyword]:
176
+ """The keywords of a library, a resource file or the document with their tooltips."""
177
+ # once for all keywords, they share their library and its documentation format
178
+ targets = keyword_reference_targets(keywords[0], self._links) if keywords else None
179
+ return [self._keyword(kw, target, link_resolver, targets) for kw in keywords]
180
+
181
+ def _keyword(
182
+ self,
183
+ kw: KeywordDoc,
184
+ target: Optional[DocumentationTarget],
185
+ link_resolver: Optional[LinkResolver],
186
+ targets: Optional[Mapping[str, ReferenceTarget]],
187
+ ) -> Keyword:
188
+ return Keyword(
189
+ kw.name,
190
+ str(hash(kw)),
191
+ kw.parameter_signature(),
192
+ self._documentation(
193
+ lambda link_resolver: kw.to_markdown(
194
+ add_signature=False, link_resolver=link_resolver, reference_targets=targets
195
+ ),
196
+ target,
197
+ link_resolver,
198
+ heading=False,
199
+ ),
200
+ )
201
+
202
+ def _documentation(
203
+ self,
204
+ render: Callable[[Optional[LinkResolver]], str],
205
+ target: Optional[DocumentationTarget],
206
+ link_resolver: Optional[LinkResolver],
207
+ *,
208
+ heading: bool = True,
209
+ ) -> str:
210
+ """A tooltip; with links, linked into the Documentation Viewer at the page of `target`."""
211
+ if not self._links:
212
+ return render(None)
213
+ return link_documentation(render, target, None, heading=heading, link_resolver=link_resolver)
214
+
143
215
  @rpc_method(name="robot/keywordsview/getDocumentKeywords", param_type=GetDocumentKeywordsParams, threaded=True)
144
216
  @_logger.call
145
217
  def _get_document_keywords(
@@ -155,31 +227,27 @@ class RobotKeywordsTreeViewPart(RobotLanguageServerProtocolPart, ModelHelper):
155
227
 
156
228
  namespace = self.parent.documents_cache.get_namespace(document)
157
229
 
158
- return [
159
- Keyword(
160
- l.name,
161
- str(hash(l)),
162
- l.parameter_signature(),
163
- l.to_markdown(add_signature=False) if not no_documentation else None,
164
- )
165
- for l in namespace.library_doc.keywords.values()
166
- ]
230
+ keywords = list(namespace.library_doc.keywords.values())
231
+ if no_documentation:
232
+ return [Keyword(l.name, str(hash(l)), l.parameter_signature(), None) for l in keywords]
167
233
 
168
- @rpc_method(name="robot/keywordsview/getDocumentationUrl", param_type=GetDocumentationUrl, threaded=True)
169
- @_logger.call
170
- def _get_documentation_url(
234
+ target = (
235
+ self.parent.robot_code_action_documentation.document_target(document, namespace) if self._links else None
236
+ )
237
+ return self._keywords(keywords, target, self._link_resolver(document, namespace, target, namespace.library_doc))
238
+
239
+ def _documentation_target(
171
240
  self,
172
241
  text_document: TextDocumentIdentifier,
173
- import_id: Optional[str] = None,
174
- keyword_id: Optional[str] = None,
175
- *args: Any,
176
- **kwargs: Any,
177
- ) -> Optional[str]:
242
+ import_id: Optional[str],
243
+ keyword_id: Optional[str],
244
+ ) -> Optional[DocumentationTarget]:
178
245
  document = self.parent.documents.get(text_document.uri)
179
246
  if document is None:
180
247
  return None
181
248
 
182
249
  namespace = self.parent.documents_cache.get_namespace(document)
250
+ documentation = self.parent.robot_code_action_documentation
183
251
 
184
252
  keyword_name = None
185
253
 
@@ -189,25 +257,55 @@ class RobotKeywordsTreeViewPart(RobotLanguageServerProtocolPart, ModelHelper):
189
257
  if keyword is not None:
190
258
  keyword_name = keyword.name
191
259
 
192
- return self.parent.robot_code_action_documentation.build_url(
193
- str(document.uri.to_path().name), (), document, namespace, keyword_name
194
- )
260
+ return documentation.document_target(document, namespace, keyword_name)
195
261
 
262
+ is_library = True
196
263
  entry = next((l for l in namespace.libraries.values() if str(hash(l)) == import_id), None)
197
264
  if entry is None:
265
+ is_library = False
198
266
  entry = next((l for l in namespace.resources.values() if str(hash(l)) == import_id), None)
199
267
 
200
- if keyword_id and entry is not None:
268
+ if entry is None:
269
+ return None
270
+
271
+ if keyword_id:
201
272
  keyword = next((l for l in entry.library_doc.keywords.values() if str(hash(l)) == keyword_id), None)
202
273
  if keyword is not None:
203
274
  keyword_name = keyword.name
204
275
 
205
- if entry is not None:
206
- return self.parent.robot_code_action_documentation.build_url(
207
- entry.import_name, entry.args, document, namespace, keyword_name
208
- )
276
+ return documentation.entry_target(entry, is_library, document, namespace, keyword_name)
277
+
278
+ @rpc_method(name="robot/keywordsview/getDocumentationTarget", param_type=GetDocumentationUrl, threaded=True)
279
+ @_logger.call
280
+ def _get_documentation_target(
281
+ self,
282
+ text_document: TextDocumentIdentifier,
283
+ import_id: Optional[str] = None,
284
+ keyword_id: Optional[str] = None,
285
+ *args: Any,
286
+ **kwargs: Any,
287
+ ) -> Optional[DocumentationTarget]:
288
+ return self._documentation_target(text_document, import_id, keyword_id)
289
+
290
+ @rpc_method(name="robot/keywordsview/getDocumentationUrl", param_type=GetDocumentationUrl, threaded=True)
291
+ @_logger.call
292
+ def _get_documentation_url(
293
+ self,
294
+ text_document: TextDocumentIdentifier,
295
+ import_id: Optional[str] = None,
296
+ keyword_id: Optional[str] = None,
297
+ *args: Any,
298
+ **kwargs: Any,
299
+ ) -> Optional[str]:
300
+ target = self._documentation_target(text_document, import_id, keyword_id)
301
+ if target is None:
302
+ return None
303
+
304
+ document = self.parent.documents.get(text_document.uri)
305
+ if document is None:
306
+ return None
209
307
 
210
- return None
308
+ return self.parent.robot_code_action_documentation.build_url(target, document)
211
309
 
212
310
  @rpc_method(
213
311
  name="robot/keywordsview/getLibraryDocumentation", param_type=GetLibraryDocumentationParams, threaded=True
@@ -0,0 +1,331 @@
1
+ """Model-path variable extraction shared by selection range, inline values,
2
+ and debug evaluatable expressions.
3
+
4
+ A faithful, self-contained port of the legacy
5
+ `ModelHelper.iter_variables_from_token` / `iter_expression_variables_from_token`
6
+ candidate semantics, operating on the `SemanticToken`s of a
7
+ `SemanticStatement`:
8
+
9
+ - variable occurrences are scanned from the token *values* of the cells the
10
+ legacy tokenizer type gate admits (arguments, names, keyword cells — not
11
+ comments or error tokens), in the same order legacy yields them: outer
12
+ variables before nested ones, the extended-syntax base name before the
13
+ full inner span;
14
+ - resolution goes through `SemanticModel.find_variable()` (no `ModelHelper`,
15
+ no `Namespace` re-resolution on the model path);
16
+ - bare-``$var`` expression references come from the pre-computed
17
+ `PYTHON_VARIABLE_REF` sub-tokens of `CONDITION` cells.
18
+
19
+ Consumers differ only in how they use the candidates: selection range takes
20
+ the first candidate whose range contains the cursor; inline values and debug
21
+ extraction re-resolve the primary candidate of each occurrence at the
22
+ debugger's stopped location and keep only found definitions.
23
+ """
24
+
25
+ from dataclasses import dataclass
26
+ from typing import Iterator, List, Optional
27
+
28
+ from robotcode.core.lsp.types import Position, Range
29
+ from robotcode.robot.diagnostics.semantic_analyzer.enums import NodeKind, TokenKind
30
+ from robotcode.robot.diagnostics.semantic_analyzer.model import SemanticModel
31
+ from robotcode.robot.diagnostics.semantic_analyzer.nodes import SemanticStatement, SemanticToken
32
+ from robotcode.robot.diagnostics.semantic_analyzer.variable_tokenizer import (
33
+ _MATCH_EXTENDED,
34
+ _build_python_expression_sub_tokens,
35
+ )
36
+ from robotcode.robot.utils.variables import contains_variable, is_number_literal, search_variable
37
+
38
+ # Statement cells the legacy `tokenize_variables` walk actually scans — its
39
+ # type gate allows `Token.ALLOW_VARIABLES` (arguments, names, test-case and
40
+ # keyword definition names) plus KEYWORD, ASSIGN, and OPTION cells;
41
+ # `Token.VARIABLE` cells pass through whole. Comments, error tokens, setting
42
+ # names, control-flow words, and structural tokens are never scanned.
43
+ _SCANNABLE_KINDS = frozenset(
44
+ {
45
+ TokenKind.ARGUMENT,
46
+ TokenKind.TAG,
47
+ TokenKind.CONDITION,
48
+ TokenKind.NAMED_ARGUMENT_NAME,
49
+ TokenKind.NAMED_ARGUMENT_VALUE,
50
+ TokenKind.PARAMETER,
51
+ TokenKind.OPTION,
52
+ TokenKind.OPTION_NAME,
53
+ TokenKind.OPTION_VALUE,
54
+ TokenKind.TEST_NAME,
55
+ TokenKind.KEYWORD_NAME,
56
+ TokenKind.KEYWORD,
57
+ TokenKind.KEYWORD_INNER,
58
+ TokenKind.BDD_PREFIX,
59
+ TokenKind.NAMESPACE,
60
+ TokenKind.IMPORT_NAME,
61
+ TokenKind.VARIABLE,
62
+ TokenKind.VARIABLE_NOT_FOUND,
63
+ TokenKind.VARIABLE_NAME,
64
+ }
65
+ )
66
+
67
+
68
+ def scannable_statement_tokens(stmt: SemanticStatement) -> List[SemanticToken]:
69
+ """The top-level tokens of a statement that the legacy variable iteration
70
+ would scan (see the `_SCANNABLE_KINDS` gate above)."""
71
+ return [token for token in stmt.tokens if token.kind in _SCANNABLE_KINDS]
72
+
73
+
74
+ @dataclass
75
+ class ModelVariableCandidate:
76
+ """One selectable/resolvable variable occurrence candidate."""
77
+
78
+ range: Range
79
+ lookup_name: str
80
+ primary: bool
81
+ """True for the first candidate of an occurrence — the one whose lookup
82
+ name mirrors the legacy resolution (extended syntax resolves the base)."""
83
+
84
+
85
+ # Statements whose variable-family cells come from `Token.VARIABLE` in the
86
+ # AST (definition names, FOR/EXCEPT/VAR targets) — outside the legacy
87
+ # tokenizer's type gate. Assign targets in keyword calls / inline IF are
88
+ # `Token.ASSIGN` and tokenize normally.
89
+ _UNGATED_STMT_KINDS = frozenset({NodeKind.VARIABLE_DEF, NodeKind.FOR_HEADER, NodeKind.EXCEPT_HEADER})
90
+
91
+
92
+ def iter_model_variable_candidates(
93
+ token: SemanticToken,
94
+ model: SemanticModel,
95
+ resolve_line: int,
96
+ resolve_col: Optional[int] = None,
97
+ stmt_kind: Optional[NodeKind] = None,
98
+ ) -> Iterator[ModelVariableCandidate]:
99
+ """Yield the regular variable candidates of one statement token in legacy
100
+ order (`iter_variables_from_token`).
101
+
102
+ ``resolve_line`` / ``resolve_col`` form the position used for
103
+ visibility-aware lookups (the cursor position for selection range, the
104
+ debugger's stopped location for inline values). ``stmt_kind`` is the
105
+ containing statement's kind — it decides whether a variable-family cell
106
+ is a `Token.VARIABLE` (processed whole, outside the legacy tokenizer's
107
+ type gate) or a tokenized `Token.ASSIGN`.
108
+ """
109
+ if token.value and token.col_offset is not None:
110
+ ungated = stmt_kind in _UNGATED_STMT_KINDS and token.kind in (
111
+ TokenKind.VARIABLE,
112
+ TokenKind.VARIABLE_NOT_FOUND,
113
+ TokenKind.VARIABLE_NAME,
114
+ )
115
+ yield from _scan_value(token.value, token.line, token.col_offset, ungated, model, resolve_line, resolve_col)
116
+
117
+
118
+ def iter_condition_ref_candidates(token: SemanticToken) -> Iterator[ModelVariableCandidate]:
119
+ """Yield the bare ``$var`` expression-reference candidates of a
120
+ `CONDITION` cell from its pre-computed `PYTHON_VARIABLE_REF` sub-tokens
121
+ (legacy `iter_expression_variables_from_token`). Used by inline values
122
+ and debug extraction, but not by selection range."""
123
+ if token.kind is not TokenKind.CONDITION:
124
+ return
125
+ for sub in token.sub_tokens or []:
126
+ if sub.kind is TokenKind.PYTHON_VARIABLE_REF:
127
+ yield from _expression_ref_candidate(sub)
128
+
129
+
130
+ def _scan_value(
131
+ value: str,
132
+ line: int,
133
+ col_offset: int,
134
+ ungated: bool,
135
+ model: SemanticModel,
136
+ resolve_line: int,
137
+ resolve_col: Optional[int],
138
+ ) -> Iterator[ModelVariableCandidate]:
139
+ """Scan a value fragment for variable occurrences, mirroring the legacy
140
+ `tokenize_variables` walk (outer occurrence first, then its index-access
141
+ content and the rest of the fragment)."""
142
+ pos = 0
143
+ while pos < len(value):
144
+ matcher = search_variable(value[pos:], "$@&%", ignore_errors=True)
145
+ base = matcher.base
146
+ if base is None:
147
+ if ungated and pos == 0 and len(value) >= 2 and value[0] in "$@&%" and value[1] == "{":
148
+ # Unclosed variable at the start of the cell (`${aaa`):
149
+ # legacy yields the whole raw token
150
+ # (`strip_variable_token` leaves unparseable values untouched).
151
+ yield ModelVariableCandidate(
152
+ range=_make_range(line, col_offset, len(value)),
153
+ lookup_name=value,
154
+ primary=True,
155
+ )
156
+ return
157
+
158
+ var_start = pos + matcher.start
159
+ name = matcher.name or ""
160
+ if not name:
161
+ # `${}` / `@{}`: legacy yields the whole unstripped remainder once
162
+ # and stops scanning the token.
163
+ span = value[var_start : pos + matcher.end] or value[var_start:]
164
+ yield ModelVariableCandidate(
165
+ range=_make_range(line, col_offset + var_start, len(span)),
166
+ lookup_name=span,
167
+ primary=True,
168
+ )
169
+ return
170
+
171
+ yield from _occurrence_candidates(
172
+ name, base, line, col_offset + var_start, ungated, model, resolve_line, resolve_col
173
+ )
174
+ if ungated:
175
+ # An ungated cell is processed as a single whole occurrence.
176
+ return
177
+
178
+ # Continue right after the variable's closing brace: index-access
179
+ # content (`${x}[${i}]`) and the remaining text are scanned like the
180
+ # legacy rest-recursion.
181
+ pos = var_start + len(name)
182
+
183
+
184
+ def _occurrence_candidates(
185
+ name: str,
186
+ base: str,
187
+ line: int,
188
+ col_offset: int,
189
+ ungated: bool,
190
+ model: SemanticModel,
191
+ resolve_line: int,
192
+ resolve_col: Optional[int],
193
+ ) -> Iterator[ModelVariableCandidate]:
194
+ """Candidates of one well-formed variable occurrence ``name`` (e.g.
195
+ ``${obj.attr}``) whose ``$`` sits at ``col_offset``.
196
+
197
+ Mirrors the legacy resolution order: raw lookup → number-literal skip →
198
+ extended-syntax base lookup → not-found candidates; nested variables
199
+ always follow their container.
200
+ """
201
+ if base.startswith("{") and base.endswith("}"):
202
+ # `${{expr}}`: only bare `$var` refs inside the expression count.
203
+ yield from _python_expression_candidates(base[1:-1], line, col_offset + 3)
204
+ return
205
+
206
+ nested = contains_variable(base, "$@&%")
207
+ stripped = base.strip()
208
+ inner_range = _make_range(
209
+ line,
210
+ col_offset + 2 + (len(base) - len(base.lstrip())),
211
+ len(stripped),
212
+ )
213
+
214
+ if model.find_variable(name, resolve_line, resolve_col, extended=False) is not None:
215
+ # Raw name resolves (including literal names with spaces or nested
216
+ # syntax, e.g. a definition row `${VALID VAR ${A}}`).
217
+ yield ModelVariableCandidate(range=inner_range, lookup_name=name, primary=True)
218
+ elif is_number_literal(name):
219
+ # Number literals (`${42}`, `${0x1F}`) are values, not variables.
220
+ pass
221
+ else:
222
+ extended = _MATCH_EXTENDED.match(base)
223
+ if extended is not None:
224
+ base_name = extended.group(1).strip()
225
+ base_lookup = f"{name[0]}{{{base_name}}}"
226
+ base_range = _make_range(line, col_offset + 2, len(base_name))
227
+ if base_name and not contains_variable(base_name, "$@&%"):
228
+ if model.find_variable(base_lookup, resolve_line, resolve_col, extended=False) is not None:
229
+ yield ModelVariableCandidate(range=base_range, lookup_name=base_lookup, primary=True)
230
+ elif not is_number_literal(base_lookup) and not contains_variable(base, "$@&"):
231
+ # Legacy quirk: the unresolved-container check runs with
232
+ # the default `$@&` identifiers, so `%{}`-only nesting
233
+ # still yields the not-found pair.
234
+ yield ModelVariableCandidate(range=base_range, lookup_name=base_lookup, primary=True)
235
+ yield ModelVariableCandidate(range=inner_range, lookup_name=name, primary=False)
236
+ elif not nested:
237
+ yield ModelVariableCandidate(range=inner_range, lookup_name=name, primary=True)
238
+
239
+ if nested:
240
+ if ungated:
241
+ # Legacy re-slices ungated containers raw (`value[2:-1]`) instead
242
+ # of tokenizing them: the whole inner span comes back once as an
243
+ # unresolvable pseudo-variable; the nested variables themselves
244
+ # are never reached.
245
+ yield ModelVariableCandidate(
246
+ range=_make_range(line, col_offset + 2, len(base)),
247
+ lookup_name=base,
248
+ primary=True,
249
+ )
250
+ return
251
+ # Nested occurrences inside the container's braces.
252
+ yield from _scan_value(base, line, col_offset + 2, False, model, resolve_line, resolve_col)
253
+
254
+
255
+ def _python_expression_candidates(expr: str, line: int, col_offset: int) -> Iterator[ModelVariableCandidate]:
256
+ """Name-only candidates for the ``$var`` refs of a `${{expr}}` body,
257
+ positions identical to the analyzer's `PYTHON_VARIABLE_REF` sub-tokens."""
258
+ for ref in _build_python_expression_sub_tokens(expr, line, col_offset) or []:
259
+ yield from _expression_ref_candidate(ref)
260
+
261
+
262
+ def _expression_ref_candidate(ref: SemanticToken) -> Iterator[ModelVariableCandidate]:
263
+ """Name-only candidate of a `PYTHON_VARIABLE_REF` (`$var` → `var`)."""
264
+ if ref.length <= 1:
265
+ return
266
+ name = ref.value[1:] if ref.value.startswith("$") else ref.value
267
+ yield ModelVariableCandidate(
268
+ range=_make_range(ref.line, ref.col_offset + 1, ref.length - 1),
269
+ lookup_name=f"${{{name}}}",
270
+ primary=True,
271
+ )
272
+
273
+
274
+ def _make_range(line: int, col_offset: int, length: int) -> Range:
275
+ return Range(
276
+ start=Position(line=line - 1, character=col_offset),
277
+ end=Position(line=line - 1, character=col_offset + length),
278
+ )
279
+
280
+
281
+ def iter_statement_tokens_at_line(model: SemanticModel, line: int) -> Iterator[SemanticToken]:
282
+ """Scannable top-level tokens of every statement covering a 1-indexed
283
+ line.
284
+
285
+ A line can hold several statements (inline `IF` header plus its branch
286
+ calls), so the single most-specific `statement_at()` result is not
287
+ enough for position-based candidate searches.
288
+ """
289
+ for stmt in model.statements:
290
+ if stmt.line_start <= line <= stmt.line_end:
291
+ yield from scannable_statement_tokens(stmt)
292
+
293
+
294
+ def iter_line_variable_candidates(
295
+ model: SemanticModel,
296
+ line: int,
297
+ resolve_col: Optional[int] = None,
298
+ ) -> Iterator[ModelVariableCandidate]:
299
+ """Regular variable candidates of every statement covering a 1-indexed
300
+ line."""
301
+ for stmt in model.statements:
302
+ if stmt.line_start <= line <= stmt.line_end:
303
+ for token in scannable_statement_tokens(stmt):
304
+ yield from iter_model_variable_candidates(token, model, line, resolve_col, stmt.kind)
305
+
306
+
307
+ def iter_line_condition_ref_candidates(model: SemanticModel, line: int) -> Iterator[ModelVariableCandidate]:
308
+ """Bare ``$var`` expression-reference candidates of every condition cell
309
+ on a 1-indexed line."""
310
+ for stmt in model.statements:
311
+ if stmt.line_start <= line <= stmt.line_end:
312
+ for token in stmt.tokens:
313
+ yield from iter_condition_ref_candidates(token)
314
+
315
+
316
+ def find_model_variable_range_at(
317
+ model: SemanticModel,
318
+ position: Position,
319
+ within: Range,
320
+ ) -> Optional[Range]:
321
+ """First candidate range containing the position — the model-path
322
+ replacement for the legacy selection-range variable step.
323
+
324
+ ``within`` is the range of the AST token under the cursor: legacy only
325
+ iterates the variables of that one token, so candidates outside it (a
326
+ neighbouring cell whose end touches the position) must not match.
327
+ """
328
+ for candidate in iter_line_variable_candidates(model, position.line + 1, position.character):
329
+ if position in candidate.range and candidate.range.start >= within.start and candidate.range.end <= within.end:
330
+ return candidate.range
331
+ return None