@@ -50,15 +50,17 @@ class VariableOccurrence:
5050 strip_for_reference : bool = True
5151
5252
53- def build_variable_occurrence (value : str , line : int , col_offset : int ) -> VariableOccurrence :
53+ def build_variable_occurrence (
54+ value : str , line : int , col_offset : int , * , parse_type : bool = False
55+ ) -> VariableOccurrence :
5456 """Parse a single variable expression once and return shared occurrence data."""
5557 sub_tokens = build_variable_sub_tokens (value , line , col_offset )
5658 return VariableOccurrence (
5759 value = value ,
5860 line = line ,
5961 col_offset = col_offset ,
6062 length = len (value ),
61- lookup_name = normalize_variable_lookup_name (value ),
63+ lookup_name = normalize_variable_lookup_name (value , parse_type = parse_type ),
6264 semantic_sub_tokens = sub_tokens if sub_tokens else None ,
6365 )
6466
@@ -157,6 +159,7 @@ def iter_variable_occurrences_from_token(
157159 token : Token ,
158160 identifiers : str = "$@&%" ,
159161 * ,
162+ parse_type : bool = False ,
160163 ignore_errors : bool = False ,
161164 extra_types : Optional [Set [str ]] = None ,
162165 exception_handler : Optional [Callable [[Exception , Token ], None ]] = None ,
@@ -168,7 +171,7 @@ def iter_variable_occurrences_from_token(
168171 """
169172 parsed_token = token
170173 if token .type == Token .VARIABLE and token .value .endswith ("=" ):
171- match = search_variable (token .value , ignore_errors = True )
174+ match = search_variable (token .value , ignore_errors = True , parse_type = parse_type )
172175 if not match .is_assign (allow_assign_mark = True ):
173176 return
174177
@@ -190,18 +193,24 @@ def iter_variable_occurrences_from_token(
190193 if sub_token .type != Token .VARIABLE :
191194 continue
192195
193- occurrence = build_variable_occurrence (sub_token .value , sub_token .lineno , sub_token .col_offset )
196+ occurrence = build_variable_occurrence (
197+ sub_token .value ,
198+ sub_token .lineno ,
199+ sub_token .col_offset ,
200+ parse_type = parse_type ,
201+ )
194202 yield from iter_related_occurrences (occurrence )
195203
196204
197- def normalize_variable_lookup_name (value : str ) -> Optional [str ]:
205+ def normalize_variable_lookup_name (value : str , * , parse_type : bool = False ) -> Optional [str ]:
198206 """Normalize a variable expression to a lookup name for static resolution.
199207
200208 Examples:
201209 - `${obj.attr}` -> `${obj}`
202210 - `${var}[0][x]` -> `${var}`
203211 - `%{HOME=default}` -> `%{HOME}`
204212 - `${{expr}}` -> None
213+ - `${age: int}` -> `${age}` only when ``parse_type=True`` (declaration context)
205214 """
206215 if not value or len (value ) < 3 :
207216 return None
@@ -238,28 +247,41 @@ def normalize_variable_lookup_name(value: str) -> Optional[str]:
238247 if not inner :
239248 return None
240249
241- # Try extended syntax first: extract the base variable name before any
242- # operator/expression. This must happen *before* the nested-variable
243- # guard because the tail may contain nested variables (e.g.
244- # ``${A + '${B}'}``) while the base name ``A`` is perfectly resolvable.
245- # Skip when the extension starts with a variable identifier (``${``,
246- # ``@{`` etc.) — that indicates a **nested variable name** like
247- # ``${cfg_${env}}``, not an expression.
248- extended_match = _MATCH_EXTENDED .match (inner )
249- if extended_match :
250- ext_part = extended_match .group (2 )
251- if not ext_part .startswith (("${" , "@{" , "&{" , "%{" )):
252- inner = extended_match .group (1 )
250+ # Type hint check must precede extended-syntax matching: `: ` in `${age: int}` would
251+ # otherwise be consumed by _MATCH_EXTENDED (which accepts any [^\s\w] operator) and
252+ # silently strip the type even in reference contexts. RF itself only strips the type
253+ # hint when search_variable() is called with parse_type=True — i.e. in declaration
254+ # contexts (Variables section, [Arguments], VAR, FOR, Assignment).
255+ if prefix == "$" and ": " in inner :
256+ if parse_type :
257+ inner = inner .split (": " , 1 )[0 ]
258+ # else: keep inner with type hint intact; the full `${age: int}` is the lookup name.
259+ else :
260+ # Try extended syntax first: extract the base variable name before any
261+ # operator/expression. This must happen *before* the nested-variable
262+ # guard because the tail may contain nested variables (e.g.
263+ # ``${A + '${B}'}``) while the base name ``A`` is perfectly resolvable.
264+ # Skip when the extension starts with a variable identifier (``${``,
265+ # ``@{`` etc.) — that indicates a **nested variable name** like
266+ # ``${cfg_${env}}``, not an expression.
267+ extended_match = _MATCH_EXTENDED .match (inner )
268+ if extended_match :
269+ ext_part = extended_match .group (2 )
270+ if not ext_part .startswith (("${" , "@{" , "&{" , "%{" )):
271+ inner = extended_match .group (1 )
253272
254273 if "${" in inner or "@{" in inner or "&{" in inner or "%{" in inner :
255274 return None
256275
257276 if prefix == "%" and "=" in inner :
258277 inner = inner .split ("=" , 1 )[0 ]
259- elif prefix == "$" and ": " in inner :
260- inner = inner .split (": " , 1 )[0 ]
261- elif prefix == "$" and ":" in inner :
262- inner = inner .split (":" , 1 )[0 ]
278+ elif prefix == "$" and ":" in inner and ": " not in inner :
279+ # Bare colon: embedded argument pattern ${arg:\d+}.
280+ # Type hints (`: ` with space) are handled above, gated by parse_type.
281+ # Preserve builtin ${:}; only treat ':' as pattern separator when both sides exist.
282+ head , _ , tail = inner .partition (":" )
283+ if head and tail :
284+ inner = head
263285
264286 inner = inner .strip ()
265287 if not inner :
@@ -491,7 +513,9 @@ def _decompose_variable_inner(
491513 if "${" in inner or "@{" in inner or "&{" in inner or "%{" in inner :
492514 return _decompose_nested_variable (inner , line , col_offset )
493515
494- # Check for type hint: ${age: int} or ${name: str:\w+}
516+ # Check for type hint: ${age: int}
517+ # RF uses ': ' (colon + space) as the type separator.
518+ # Everything after ': ' is the type hint — no further splitting.
495519 if ": " in inner and prefix_char == "$" :
496520 colon_pos = inner .index (": " )
497521 base = inner [:colon_pos ]
@@ -516,48 +540,15 @@ def _decompose_variable_inner(
516540 )
517541 )
518542
519- # Check for pattern after type: ${name: str:\w+}
520- if ":" in rest :
521- pattern_sep = rest .index (":" )
522- type_hint = rest [:pattern_sep ]
523- pattern = rest [pattern_sep + 1 :]
524- tokens .append (
525- SemanticToken (
526- kind = TokenKind .VARIABLE_TYPE_HINT ,
527- value = type_hint ,
528- line = line ,
529- col_offset = col_offset + colon_pos + 2 ,
530- length = len (type_hint ),
531- )
532- )
533- tokens .append (
534- SemanticToken (
535- kind = TokenKind .VARIABLE_PATTERN_SEPARATOR ,
536- value = ":" ,
537- line = line ,
538- col_offset = col_offset + colon_pos + 2 + len (type_hint ),
539- length = 1 ,
540- )
541- )
542- tokens .append (
543- SemanticToken (
544- kind = TokenKind .VARIABLE_PATTERN ,
545- value = pattern ,
546- line = line ,
547- col_offset = col_offset + colon_pos + 2 + len (type_hint ) + 1 ,
548- length = len (pattern ),
549- )
550- )
551- else :
552- tokens .append (
553- SemanticToken (
554- kind = TokenKind .VARIABLE_TYPE_HINT ,
555- value = rest ,
556- line = line ,
557- col_offset = col_offset + colon_pos + 2 ,
558- length = len (rest ),
559- )
543+ tokens .append (
544+ SemanticToken (
545+ kind = TokenKind .VARIABLE_TYPE_HINT ,
546+ value = rest ,
547+ line = line ,
548+ col_offset = col_offset + colon_pos + 2 ,
549+ length = len (rest ),
560550 )
551+ )
561552 return tokens
562553
563554 # Check for embedded pattern without type: ${arg:\d+}
@@ -855,7 +846,7 @@ def _iter_related_occurrences_from_token(
855846 line = token .line ,
856847 col_offset = token .col_offset ,
857848 length = token .length ,
858- lookup_name = normalize_variable_lookup_name (token .value ),
849+ lookup_name = normalize_variable_lookup_name (token .value , parse_type = False ),
859850 semantic_sub_tokens = token .sub_tokens if token .sub_tokens else None ,
860851 )
861852 yield nested
0 commit comments