pine2pyne 0.2.4__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 (75) hide show
  1. pine2pyne/__init__.py +84 -0
  2. pine2pyne/__main__.py +12 -0
  3. pine2pyne/ast_nodes.py +405 -0
  4. pine2pyne/ast_walk.py +93 -0
  5. pine2pyne/block_value_lowering.py +694 -0
  6. pine2pyne/bool_builtins.py +42 -0
  7. pine2pyne/bool_value_typing.py +580 -0
  8. pine2pyne/cli.py +247 -0
  9. pine2pyne/codegen.py +886 -0
  10. pine2pyne/compat/__init__.py +19 -0
  11. pine2pyne/compat/__main__.py +6 -0
  12. pine2pyne/compat/cli.py +114 -0
  13. pine2pyne/compat/comparison.py +105 -0
  14. pine2pyne/compat/corpus.py +73 -0
  15. pine2pyne/compat/report.py +95 -0
  16. pine2pyne/compat/runner.py +63 -0
  17. pine2pyne/condition_typing.py +260 -0
  18. pine2pyne/conditional_history_calls.py +96 -0
  19. pine2pyne/core/__init__.py +26 -0
  20. pine2pyne/core/contracts.py +125 -0
  21. pine2pyne/di.py +173 -0
  22. pine2pyne/emit/__init__.py +5 -0
  23. pine2pyne/emit/legacy.py +14 -0
  24. pine2pyne/emit/pynecomp/__init__.py +20 -0
  25. pine2pyne/emit/pynecomp/banner.py +30 -0
  26. pine2pyne/emit/pynecomp/blank_lines.py +69 -0
  27. pine2pyne/emit/pynecomp/emitter.py +61 -0
  28. pine2pyne/emit/pynecomp/imports.py +65 -0
  29. pine2pyne/emit/pynecomp/passes/__init__.py +84 -0
  30. pine2pyne/emit/pynecomp/passes/annotations.py +422 -0
  31. pine2pyne/emit/pynecomp/passes/base.py +69 -0
  32. pine2pyne/emit/pynecomp/passes/builtins.py +169 -0
  33. pine2pyne/emit/pynecomp/passes/hoisting.py +205 -0
  34. pine2pyne/emit/pynecomp/passes/imports.py +112 -0
  35. pine2pyne/emit/pynecomp/passes/legacy_colors.py +101 -0
  36. pine2pyne/emit/pynecomp/passes/library.py +205 -0
  37. pine2pyne/emit/pynecomp/passes/rewrites.py +215 -0
  38. pine2pyne/emit/pynecomp/printer.py +288 -0
  39. pine2pyne/emit/pynecomp/unparser.py +186 -0
  40. pine2pyne/emitted_names.py +152 -0
  41. pine2pyne/errors.py +69 -0
  42. pine2pyne/for_loop_end_bound.py +172 -0
  43. pine2pyne/grouped_history_lowering.py +310 -0
  44. pine2pyne/import_resolver.py +469 -0
  45. pine2pyne/input_hoisting.py +195 -0
  46. pine2pyne/legacy_function_names.py +74 -0
  47. pine2pyne/legacy_sessions.py +68 -0
  48. pine2pyne/lexer.py +630 -0
  49. pine2pyne/library_collision.py +434 -0
  50. pine2pyne/namespace_members.py +247 -0
  51. pine2pyne/node_ids.py +235 -0
  52. pine2pyne/once_lowering.py +75 -0
  53. pine2pyne/overload_resolution.py +475 -0
  54. pine2pyne/parse_repair.py +398 -0
  55. pine2pyne/parser.py +1873 -0
  56. pine2pyne/pine_builtins.py +394 -0
  57. pine2pyne/pine_semantics.py +604 -0
  58. pine2pyne/pipeline/__init__.py +18 -0
  59. pine2pyne/pipeline/service.py +70 -0
  60. pine2pyne/pipeline/stages.py +73 -0
  61. pine2pyne/scope_renamer.py +965 -0
  62. pine2pyne/strategy_when_lowering.py +101 -0
  63. pine2pyne/strict_boolean.py +101 -0
  64. pine2pyne/symbol_table.py +166 -0
  65. pine2pyne/tokens.py +143 -0
  66. pine2pyne/transformer.py +2510 -0
  67. pine2pyne/type_inference.py +422 -0
  68. pine2pyne/udt_field_defaults.py +149 -0
  69. pine2pyne/value_blocks.py +357 -0
  70. pine2pyne-0.2.4.dist-info/METADATA +99 -0
  71. pine2pyne-0.2.4.dist-info/RECORD +75 -0
  72. pine2pyne-0.2.4.dist-info/WHEEL +5 -0
  73. pine2pyne-0.2.4.dist-info/entry_points.txt +3 -0
  74. pine2pyne-0.2.4.dist-info/licenses/LICENSE +201 -0
  75. pine2pyne-0.2.4.dist-info/top_level.txt +1 -0
pine2pyne/parser.py ADDED
@@ -0,0 +1,1873 @@
1
+ """
2
+ Recursive descent parser for Pine Script v6.
3
+
4
+ Converts token stream from lexer into an Abstract Syntax Tree (AST).
5
+ """
6
+ import dataclasses
7
+ import warnings
8
+ from typing import List, Optional, Union
9
+ from .tokens import Token, TokenType
10
+ from .ast_nodes import *
11
+ from .errors import ParserError, SemanticError, UnsupportedFeatureError
12
+ from .once_lowering import lower_once_structures
13
+ from .strategy_when_lowering import lower_strategy_when_arguments
14
+ from .scope_renamer import fresh_name
15
+
16
+
17
+ def _pine_version(version: Optional[VersionAnnotation]) -> int:
18
+ """The //@version number, or 0 when absent or not a number."""
19
+ text = version.version if version else ''
20
+ return int(text) if str(text).isdigit() else 0
21
+
22
+
23
+ # `x += 5` is read as `x := x + 5`: the operator each compound assignment token stands for
24
+ COMPOUND_ASSIGNMENT_OPERATORS = {
25
+ TokenType.PLUS_ASSIGN: '+',
26
+ TokenType.MINUS_ASSIGN: '-',
27
+ TokenType.MULT_ASSIGN: '*',
28
+ TokenType.DIV_ASSIGN: '/',
29
+ TokenType.MOD_ASSIGN: '%',
30
+ }
31
+ # The variable that holds the receiver of `arr.get(0).v += 1` (card #98)
32
+ RECEIVER_NAME_STEM = 'assigned_field_receiver'
33
+
34
+
35
+ def _as_statement_list(statement) -> list:
36
+ """What parse_statement returned as a list: one statement, several (a source statement
37
+ lowered to more than one, card #98), or none."""
38
+ if isinstance(statement, list):
39
+ return statement
40
+ return [statement] if statement else []
41
+
42
+
43
+ class Parser:
44
+ """Recursive descent parser for Pine Script v6."""
45
+
46
+ def __init__(self, tokens: List[Token]):
47
+ # COMPILER_ANNOTATION tokens (//@variable, //@param, //@function, ...) are
48
+ # documentation only and carry no syntax. They can appear anywhere a
49
+ # comment can — including inside blocks and expressions — so handling them
50
+ # at each parse site would mean a case in every rule. Drop them up front
51
+ # instead; the meaningful VERSION_ANNOTATION is preserved.
52
+ self.tokens = [t for t in tokens if t.type is not TokenType.COMPILER_ANNOTATION]
53
+ self.pos = 0
54
+ self._token_names: Optional[set] = None # every name the tokens hold, read on first use (card #98)
55
+
56
+ def current_token(self) -> Token:
57
+ """Get current token without advancing."""
58
+ if self.pos >= len(self.tokens):
59
+ return self.tokens[-1] # Return EOF
60
+ return self.tokens[self.pos]
61
+
62
+ def peek_token(self, offset: int = 1) -> Token:
63
+ """Look ahead at token at pos + offset."""
64
+ pos = self.pos + offset
65
+ if pos >= len(self.tokens):
66
+ return self.tokens[-1] # Return EOF
67
+ return self.tokens[pos]
68
+
69
+ def advance(self) -> Token:
70
+ """Consume and return current token."""
71
+ token = self.current_token()
72
+ if token.type != TokenType.EOF:
73
+ self.pos += 1
74
+ return token
75
+
76
+ def expect(self, token_type: TokenType) -> Token:
77
+ """Consume token of expected type or raise error."""
78
+ token = self.current_token()
79
+ if token.type != token_type:
80
+ error = ParserError(
81
+ f"Expected {token_type.name}, got {token.type.name}",
82
+ token.line,
83
+ token.column
84
+ )
85
+ error.token_index = self._current_index() # read by parse_repair (card #55)
86
+ raise error
87
+ return self.advance()
88
+
89
+ def match(self, *token_types: TokenType) -> bool:
90
+ """Check if current token matches any of the given types."""
91
+ return self.current_token().type in token_types
92
+
93
+ def skip_newlines(self) -> None:
94
+ """Skip any NEWLINE tokens."""
95
+ while self.match(TokenType.NEWLINE):
96
+ self.advance()
97
+
98
+ def _looks_like_generic_call(self) -> bool:
99
+ """
100
+ Check if the current position looks like a generic function call.
101
+ Pattern: <type1, type2, ...>(args)
102
+ This prevents `close < ema` from being parsed as generics.
103
+ """
104
+ saved_pos = self.pos
105
+ try:
106
+ # We're currently at <, skip it
107
+ self.pos += 1
108
+
109
+ # Skip through the generic parameters
110
+ depth = 1
111
+ while depth > 0 and self.pos < len(self.tokens):
112
+ token = self.tokens[self.pos]
113
+ if token.type == TokenType.LT:
114
+ depth += 1
115
+ elif token.type == TokenType.GT:
116
+ depth -= 1
117
+ if depth == 0:
118
+ # Found closing >, check next token
119
+ self.pos += 1
120
+ if self.pos < len(self.tokens):
121
+ next_token = self.tokens[self.pos]
122
+ # Generic calls MUST be followed by ( for function call
123
+ self.pos = saved_pos
124
+ return next_token.type == TokenType.LPAREN
125
+ self.pos = saved_pos
126
+ return False
127
+ elif token.type not in (TokenType.TYPE_IDENTIFIER, TokenType.IDENTIFIER,
128
+ TokenType.COMMA, TokenType.LT, TokenType.GT):
129
+ # Only type names and commas are valid inside <...> generics.
130
+ # Any operator (and, or, /, +, etc.) means this is a comparison, not generics.
131
+ self.pos = saved_pos
132
+ return False
133
+ self.pos += 1
134
+
135
+ self.pos = saved_pos
136
+ return False
137
+ except Exception:
138
+ self.pos = saved_pos
139
+ return False
140
+
141
+ def error(self, message: str) -> ParserError:
142
+ """Create parser error at current position."""
143
+ token = self.current_token()
144
+ error = ParserError(message, token.line, token.column)
145
+ error.token_index = self._current_index() # read by parse_repair (card #55)
146
+ return error
147
+
148
+ def _current_index(self) -> int:
149
+ """Index in self.tokens of current_token()."""
150
+ return min(self.pos, len(self.tokens) - 1)
151
+
152
+ # ========================================================================
153
+ # Top-level parsing
154
+ # ========================================================================
155
+
156
+ def parse(self) -> Script:
157
+ """Parse entire Pine Script source."""
158
+ script = Script()
159
+
160
+ self.skip_newlines()
161
+
162
+ # Parse version annotation
163
+ if self.match(TokenType.VERSION_ANNOTATION):
164
+ version_token = self.advance()
165
+ script.version = VersionAnnotation(version=version_token.value)
166
+ self.skip_newlines()
167
+
168
+ # Parse script declaration (indicator/strategy/library)
169
+ if self._at_script_declaration(script.version):
170
+ script.script_decl = self.parse_script_declaration(script.version)
171
+ self.skip_newlines()
172
+
173
+ # Parse imports
174
+ while self.match(TokenType.IMPORT):
175
+ script.imports.append(self.parse_import())
176
+ self.skip_newlines()
177
+
178
+ # Parse global declarations and body
179
+ # Track actual source line numbers for ordering and blank line detection
180
+ while not self.match(TokenType.EOF):
181
+ self.skip_newlines()
182
+
183
+ if self.match(TokenType.EOF):
184
+ break
185
+
186
+ # Handle VERSION_ANNOTATION tokens that appear in the body
187
+ # (e.g., when pre-version declarations like DEFAULT_PYRAMIDING = 6
188
+ # appear before //@version=6 in the source)
189
+ if self.match(TokenType.VERSION_ANNOTATION):
190
+ if not script.version:
191
+ version_token = self.advance()
192
+ script.version = VersionAnnotation(version=version_token.value)
193
+ else:
194
+ self.advance() # Skip duplicate version annotations
195
+ continue
196
+
197
+ # Handle COMPILER_ANNOTATION tokens in the body
198
+ if self.match(TokenType.COMPILER_ANNOTATION):
199
+ self.advance() # Skip compiler annotations
200
+ continue
201
+
202
+ # Handle strategy/indicator/library declarations that appear after
203
+ # pre-declaration code (when the initial top-level check missed them)
204
+ if not script.script_decl and self._at_script_declaration(script.version):
205
+ script.script_decl = self.parse_script_declaration(script.version)
206
+ self.skip_newlines()
207
+ continue
208
+
209
+ # Record the starting position of each declaration/statement
210
+ start_token = self.current_token()
211
+
212
+ # `export type T` / `export enum E` (a library's API): parsed as the type or enum
213
+ if self.match(TokenType.EXPORT) and self.peek_token().type in (TokenType.TYPE, TokenType.ENUM):
214
+ self.advance()
215
+
216
+ # Check for function declarations
217
+ if self.is_function_declaration():
218
+ node = self.parse_function_declaration()
219
+ self._stamp_top_level(node, start_token)
220
+ script.declarations.append(node)
221
+ # Check for var/varip declarations
222
+ elif self.match(TokenType.VAR, TokenType.VARIP):
223
+ node = self.parse_var_declaration()
224
+ self._stamp_top_level(node, start_token)
225
+ script.declarations.append(node)
226
+ # Check for type declarations
227
+ elif self.match(TokenType.TYPE):
228
+ node = self.parse_type_declaration()
229
+ self._stamp_top_level(node, start_token)
230
+ script.declarations.append(node)
231
+ # Check for enum declarations
232
+ elif self.match(TokenType.ENUM):
233
+ node = self.parse_enum_declaration()
234
+ self._stamp_top_level(node, start_token)
235
+ script.declarations.append(node)
236
+ # Handle import statements that appear in the body (not just at top)
237
+ elif self.match(TokenType.IMPORT):
238
+ import_node = self.parse_import()
239
+ script.imports.append(import_node)
240
+ # Check for input declarations
241
+ elif self.is_input_declaration():
242
+ saved_pos = self.pos
243
+ try:
244
+ node = self.parse_input_declaration()
245
+ self._stamp_top_level(node, start_token)
246
+ script.declarations.append(node)
247
+ except Exception:
248
+ # Not a simple input declaration (e.g., input.string(...) == "On")
249
+ # Fall back to parsing as a regular statement
250
+ self.pos = saved_pos
251
+ for stmt in _as_statement_list(self.parse_statement()):
252
+ self._stamp_top_level(stmt, start_token)
253
+ script.body.append(stmt)
254
+ # Otherwise, parse as statement
255
+ else:
256
+ for stmt in _as_statement_list(self.parse_statement()):
257
+ self._stamp_top_level(stmt, start_token)
258
+ script.body.append(stmt)
259
+
260
+ self.skip_newlines()
261
+
262
+ script = lower_once_structures(script)
263
+ return lower_strategy_when_arguments(script, _pine_version(script.version))
264
+
265
+ def _at_script_declaration(self, version: Optional[VersionAnnotation]) -> bool:
266
+ """At an indicator/strategy/library call; before v5 a study() call is the indicator (card #55)."""
267
+ if not self.match(TokenType.IDENTIFIER):
268
+ return False
269
+ name = self.current_token().value
270
+ if name == 'study':
271
+ return _pine_version(version) < 5 and self.peek_token().type == TokenType.LPAREN
272
+ return name in ('indicator', 'strategy', 'library')
273
+
274
+ def parse_script_declaration(self, version: Optional[VersionAnnotation] = None
275
+ ) -> Union[IndicatorDecl, StrategyDecl, LibraryDecl]:
276
+ """Parse indicator/strategy/library declaration, binding positionals to Pine's names.
277
+
278
+ `title` holds a string-literal title; any other title expression stays in kwargs
279
+ under 'title' and `title` is None (card #55).
280
+ """
281
+ name_token = self.advance() # indicator, strategy, library, or v4 study
282
+ kind = 'indicator' if name_token.value == 'study' else name_token.value
283
+ self.expect(TokenType.LPAREN)
284
+ positionals, keywords = self._parse_declaration_arguments(name_token.value)
285
+ self.expect(TokenType.RPAREN)
286
+ kwargs = self._bind_declaration_positionals(kind, name_token, positionals, keywords, version)
287
+
288
+ title_value = kwargs.pop('title', None)
289
+ title_double_quoted = isinstance(title_value, Literal) and title_value.is_double_quoted
290
+ if title_value is None:
291
+ title = f"Untitled {name_token.value}"
292
+ elif isinstance(title_value, Literal) and title_value.literal_type == 'string':
293
+ title = title_value.value
294
+ else:
295
+ title = None
296
+ kwargs = {'title': title_value, **kwargs}
297
+
298
+ declaration_class = {'indicator': IndicatorDecl, 'strategy': StrategyDecl}.get(kind, LibraryDecl)
299
+ # a codegen refusal reports the declaration's line (card #40)
300
+ return declaration_class(title=title, kwargs=kwargs, line=name_token.line,
301
+ positional_count=len(positionals), title_double_quoted=title_double_quoted,
302
+ keyword_order=tuple(keywords))
303
+
304
+ def _parse_declaration_arguments(self, function_name: str) -> tuple:
305
+ """Return (positionals, keywords) up to the closing paren.
306
+
307
+ A positional after a keyword is parsed and dropped with a warning, as before
308
+ card #55: its Pine parameter is not known (TradingView's reading is unmeasured).
309
+ """
310
+ positionals, keywords = [], {}
311
+ while not self.match(TokenType.RPAREN):
312
+ self.skip_newlines() # Handle newlines before parameters
313
+ if self.match(TokenType.RPAREN): # Check again after skipping newlines
314
+ break
315
+ if self.match(TokenType.COMMA):
316
+ self.advance()
317
+ self.skip_newlines() # Handle newlines after comma
318
+ continue
319
+ if (self.match(TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER) and
320
+ self.peek_token().type == TokenType.ASSIGN):
321
+ key_token = self.advance()
322
+ if key_token.value in keywords: # published scripts do this; the last value wins, as before
323
+ warnings.warn(f"{key_token.line}:{key_token.column}: {function_name}() argument "
324
+ f"'{key_token.value}' is given twice; the last value is kept")
325
+ self.expect(TokenType.ASSIGN)
326
+ keywords[key_token.value] = self.parse_expression()
327
+ elif keywords:
328
+ token = self.current_token()
329
+ self.parse_expression()
330
+ warnings.warn(f"{token.line}:{token.column}: {function_name}() positional argument after "
331
+ f"a keyword argument is dropped")
332
+ else:
333
+ positionals.append(self.parse_expression())
334
+ return positionals, keywords
335
+
336
+ def _bind_declaration_positionals(self, kind: str, name_token: Token, positionals: list,
337
+ keywords: dict, version: Optional[VersionAnnotation]) -> dict:
338
+ """Name each positional by Pine's parameter order; return them, then the keywords."""
339
+ from .pine_builtins import SCRIPT_DECLARATION_POSITIONAL_PARAMS, VERSION_INVARIANT_POSITIONAL_COUNT
340
+ names = SCRIPT_DECLARATION_POSITIONAL_PARAMS[kind]
341
+ invariant_count = VERSION_INVARIANT_POSITIONAL_COUNT[kind]
342
+ function_name = name_token.value
343
+ if len(positionals) > len(names):
344
+ raise self._declaration_error(
345
+ f"{function_name}() takes at most {len(names)} positional arguments, got {len(positionals)}",
346
+ name_token)
347
+ if len(positionals) > invariant_count and _pine_version(version) < 5:
348
+ raise self._declaration_error(
349
+ f"{function_name}() positional argument {invariant_count + 1} ('{names[invariant_count]}') "
350
+ f"depends on the Pine version; pass it by keyword or declare //@version=5 or later",
351
+ name_token)
352
+ if function_name == 'study':
353
+ keywords = self._study_keywords_as_indicator(keywords, name_token)
354
+ bound = dict(zip(names, positionals))
355
+ bound_twice = [name for name in bound if name in keywords]
356
+ if bound_twice:
357
+ raise self._declaration_error(
358
+ f"{function_name}() got multiple values for argument '{bound_twice[0]}'", name_token)
359
+ return {**bound, **keywords}
360
+
361
+ def _study_keywords_as_indicator(self, keywords: dict, name_token: Token) -> dict:
362
+ """v4 study() keywords under indicator()'s names; one indicator() lacks is refused."""
363
+ from .pine_builtins import SCRIPT_DECLARATION_POSITIONAL_PARAMS, STUDY_KEYWORD_RENAMES
364
+ renamed = {STUDY_KEYWORD_RENAMES.get(key, key): value for key, value in keywords.items()}
365
+ unknown = [key for key in renamed if key not in SCRIPT_DECLARATION_POSITIONAL_PARAMS['indicator']]
366
+ if unknown:
367
+ raise self._declaration_error(f"study() argument '{unknown[0]}' has no indicator() equivalent",
368
+ name_token)
369
+ return renamed
370
+
371
+ def _declaration_error(self, message: str, token: Token) -> ParserError:
372
+ """A declaration binding error at `token`."""
373
+ error = ParserError(message, token.line, token.column)
374
+ error.token_index = next(index for index, candidate in enumerate(self.tokens) if candidate is token)
375
+ return error
376
+
377
+ def parse_import(self) -> ImportDecl:
378
+ """Parse import statement: import user/library/version as alias"""
379
+ import_token = self.expect(TokenType.IMPORT)
380
+
381
+ # Parse user/library/version as separate tokens joined by DIV (/)
382
+ # e.g., doqkhanh/tafirstlib/2 → IDENTIFIER DIV IDENTIFIER DIV INT_LITERAL
383
+ user = self.expect(TokenType.IDENTIFIER).value
384
+ library = ''
385
+ version = None
386
+
387
+ if self.match(TokenType.DIV):
388
+ self.advance() # consume /
389
+ library = self.expect(TokenType.IDENTIFIER).value
390
+
391
+ if self.match(TokenType.DIV):
392
+ self.advance() # consume /
393
+ # Version can be an integer or identifier
394
+ if self.match(TokenType.INT_LITERAL):
395
+ version = self.advance().value
396
+ elif self.match(TokenType.IDENTIFIER):
397
+ version = self.advance().value
398
+
399
+ # Parse optional 'as alias'
400
+ alias = None
401
+ if self.match(TokenType.IDENTIFIER) and self.current_token().value == 'as':
402
+ self.advance()
403
+ alias = self.expect(TokenType.IDENTIFIER).value
404
+
405
+ return ImportDecl(user=user, library=library, version=version, alias=alias, line=import_token.line)
406
+
407
+ def is_function_declaration(self) -> bool:
408
+ """Check if current position is a function declaration."""
409
+ # Look for: [export] [method] name(params) =>
410
+ saved_pos = self.pos
411
+
412
+ # Skip export/method keywords
413
+ if self.match(TokenType.EXPORT):
414
+ self.pos += 1
415
+ if self.match(TokenType.METHOD):
416
+ self.pos += 1
417
+
418
+ # Check for identifier followed by ( and eventually =>; `function` is a valid name (`export function (...)`)
419
+ if not (self.match(TokenType.IDENTIFIER, TokenType.FUNCTION) and self.peek_token().type == TokenType.LPAREN):
420
+ self.pos = saved_pos
421
+ return False
422
+
423
+ # Scan ahead to find => arrow (function declarations must have this)
424
+ # Skip past the parameter list
425
+ paren_depth = 0
426
+ while self.pos < len(self.tokens):
427
+ token = self.tokens[self.pos]
428
+ if token.type == TokenType.LPAREN:
429
+ paren_depth += 1
430
+ elif token.type == TokenType.RPAREN:
431
+ paren_depth -= 1
432
+ if paren_depth == 0:
433
+ # Found closing paren, check next token for =>
434
+ self.pos += 1
435
+ if self.pos < len(self.tokens) and self.tokens[self.pos].type == TokenType.ARROW:
436
+ self.pos = saved_pos
437
+ return True
438
+ else:
439
+ self.pos = saved_pos
440
+ return False
441
+ self.pos += 1
442
+
443
+ self.pos = saved_pos
444
+ return False
445
+
446
+ def parse_function_declaration(self) -> FuncDecl:
447
+ """Parse function declaration."""
448
+ is_export = False
449
+ is_method = False
450
+
451
+ if self.match(TokenType.EXPORT):
452
+ is_export = True
453
+ self.advance()
454
+
455
+ if self.match(TokenType.METHOD):
456
+ is_method = True
457
+ self.advance()
458
+
459
+ name = (self.advance().value if self.match(TokenType.FUNCTION)
460
+ else self.expect(TokenType.IDENTIFIER).value)
461
+
462
+ # Parse parameters
463
+ self.expect(TokenType.LPAREN)
464
+ self.skip_newlines() # Skip newlines after opening paren
465
+ params = []
466
+
467
+ while not self.match(TokenType.RPAREN):
468
+ self.skip_newlines() # Skip newlines before each parameter
469
+
470
+ if self.match(TokenType.RPAREN):
471
+ break
472
+
473
+ if self.match(TokenType.COMMA):
474
+ self.advance()
475
+ continue
476
+
477
+ # Parse parameter: [type] name [= default]
478
+ # Type can be: simple (float), generic (map<K,V>), or array (int[]), or custom type (TradeInfo)
479
+ is_series = False
480
+ type_hint = None
481
+ if self.match(TokenType.TYPE_IDENTIFIER):
482
+ # Look ahead to disambiguate type hint vs param name:
483
+ # TYPE_IDENTIFIER + IDENTIFIER → type hint (e.g. "float x")
484
+ # TYPE_IDENTIFIER + LT → generic type hint (e.g. "map<string, int> this")
485
+ # TYPE_IDENTIFIER + RPAREN/COMMA/ASSIGN → param name (e.g. "array" as name)
486
+ next_tok = self.peek_token()
487
+ if next_tok.type in (TokenType.IDENTIFIER, TokenType.LT, TokenType.TYPE_IDENTIFIER) \
488
+ or self._array_type_ahead():
489
+ is_series = self.current_token().value == 'series' and (
490
+ next_tok.type == TokenType.TYPE_IDENTIFIER
491
+ or (next_tok.type == TokenType.IDENTIFIER
492
+ and self.peek_token(2).type in (TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER)))
493
+ type_hint = self.parse_generic_type()
494
+ # else: fall through, treat TYPE_IDENTIFIER as param name
495
+ elif self.match(TokenType.IDENTIFIER) and self._array_type_ahead():
496
+ # `FVG[] arr`: an array of a user type
497
+ element = self.advance().value
498
+ self.advance() # [
499
+ self.advance() # ]
500
+ type_hint = f'array<{element}>'
501
+ elif self.match(TokenType.IDENTIFIER) and self.peek_token().type == TokenType.IDENTIFIER:
502
+ # For method parameters like "TradeInfo this" or "int val"
503
+ # First IDENTIFIER is the type, second is the parameter name
504
+ type_hint = self.advance().value
505
+
506
+ # Param name can be IDENTIFIER or TYPE_IDENTIFIER used as a name
507
+ if self.match(TokenType.IDENTIFIER):
508
+ param_name = self.advance().value
509
+ elif self.match(TokenType.TYPE_IDENTIFIER):
510
+ param_name = self.advance().value
511
+ else:
512
+ param_name = self.expect(TokenType.IDENTIFIER).value
513
+
514
+ default = None
515
+ if self.match(TokenType.ASSIGN):
516
+ self.advance()
517
+ default = self.parse_expression()
518
+
519
+ params.append(Parameter(name=param_name, type_hint=type_hint, default=default, is_series=is_series))
520
+ self.skip_newlines() # Skip newlines after parameter
521
+
522
+ self.expect(TokenType.RPAREN)
523
+
524
+ # Parse => and body
525
+ self.expect(TokenType.ARROW)
526
+ self.skip_newlines()
527
+
528
+ # Check if single-line or multi-line body
529
+ if self.match(TokenType.INDENT):
530
+ # Multi-line body
531
+ self.advance()
532
+ body = self.parse_block()
533
+ self.expect(TokenType.DEDENT)
534
+ else:
535
+ # Single-line body — may have comma-separated statements
536
+ # e.g., fn(x) => a = expr1, b = expr2, return_expr
537
+ body = self._parse_single_line_body()
538
+
539
+ return FuncDecl(name=name, params=params, body=body,
540
+ is_method=is_method, is_export=is_export)
541
+
542
+ def _parse_single_line_body(self):
543
+ """Parse single-line function body with optional comma-separated statements.
544
+ Pine Script allows: fn(x) => stmt1, stmt2, return_expr
545
+ Each comma-separated item can be an assignment or expression.
546
+ Returns a single expression (no commas) or a list of statements (commas present).
547
+ """
548
+ # Parse first item — could be var declaration, assignment, or expression
549
+ first = self._parse_positioned_single_line_item()
550
+
551
+ # Check for comma (multiple statements)
552
+ if not self.match(TokenType.COMMA):
553
+ return first # Single expression, return as-is
554
+
555
+ # Multiple comma-separated statements
556
+ items = [first]
557
+ while self.match(TokenType.COMMA):
558
+ self.advance() # consume comma
559
+ self.skip_newlines()
560
+ items.append(self._parse_positioned_single_line_item())
561
+
562
+ return items
563
+
564
+ def _parse_single_line_item(self):
565
+ """Parse a single item in a comma-separated single-line function body.
566
+ Can be: var type name = expr, name = expr, or just expr.
567
+ """
568
+ # Handle 'var' declarations: var type name = expr
569
+ if self.match(TokenType.VAR):
570
+ return self.parse_statement()
571
+
572
+ # Try assignment: name = expr (look ahead for IDENTIFIER followed by ASSIGN)
573
+ saved_pos = self.pos
574
+ if self.match(TokenType.TYPE_IDENTIFIER):
575
+ # Could be typed assignment: float x = expr
576
+ type_hint = self.parse_generic_type()
577
+ if self.match(TokenType.IDENTIFIER):
578
+ var_name = self.advance().value
579
+ if self.match(TokenType.ASSIGN):
580
+ self.advance()
581
+ value = self.parse_expression()
582
+ return Assignment(target=var_name, value=value, type_hint=type_hint)
583
+ self.pos = saved_pos
584
+
585
+ if self.match(TokenType.IDENTIFIER):
586
+ maybe_name = self.current_token().value
587
+ next_pos = self.pos + 1
588
+ if next_pos < len(self.tokens) and self.tokens[next_pos].type == TokenType.ASSIGN:
589
+ var_name = self.advance().value
590
+ self.advance() # consume =
591
+ value = self.parse_expression()
592
+ return Assignment(target=var_name, value=value)
593
+ # Not an assignment — fall through to expression parse
594
+ self.pos = saved_pos
595
+
596
+ return self.parse_expression()
597
+
598
+ def _array_type_ahead(self) -> bool:
599
+ """At `T[] name`: an array type in the bracket spelling, followed by a name."""
600
+ return (self.peek_token().type == TokenType.LBRACKET and self.peek_token(2).type == TokenType.RBRACKET
601
+ and self.peek_token(3).type in (TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER))
602
+
603
+ def parse_generic_type(self) -> str:
604
+ """
605
+ Parse type syntax:
606
+ - Modifier + type: simple int, series float, const string, input float
607
+ - Generic: type<T> or map<K, V>
608
+ - Array bracket: type[] (e.g., string[], int[])
609
+ """
610
+ # Pine Script type modifiers (no Python equivalent, consume and drop)
611
+ TYPE_MODIFIERS = {'simple', 'series', 'const', 'input'}
612
+
613
+ base_type = self.advance().value # Get base type (map, array, string, int, etc.)
614
+
615
+ # Handle type modifiers: simple int, series float, etc.
616
+ # The modifier is consumed; `const` is remembered for the declaration being parsed
617
+ self.last_type_modifier = None
618
+ if base_type in TYPE_MODIFIERS and (
619
+ self.match(TokenType.TYPE_IDENTIFIER)
620
+ # `simple Timezone tz`, `series collectedData value`: a modifier before a user type
621
+ or (self.match(TokenType.IDENTIFIER) and self.peek_token().type in (
622
+ TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER, TokenType.LBRACKET))):
623
+ self.last_type_modifier = base_type
624
+ base_type = self.advance().value
625
+
626
+ # Check for array bracket syntax: type[]
627
+ if self.match(TokenType.LBRACKET):
628
+ self.advance() # consume [
629
+ self.expect(TokenType.RBRACKET) # consume ]
630
+ # Convert to generic array syntax: string[] -> array<string>
631
+ return f"array<{base_type}>"
632
+
633
+ # Check for generic parameters: type<T>
634
+ if self.match(TokenType.LT):
635
+ self.advance() # consume <
636
+
637
+ generic_params = []
638
+ while not self.match(TokenType.GT):
639
+ if self.match(TokenType.COMMA):
640
+ self.advance()
641
+ continue
642
+
643
+ # Parse type parameter (can be TYPE_IDENTIFIER or IDENTIFIER)
644
+ if self.match(TokenType.TYPE_IDENTIFIER, TokenType.IDENTIFIER):
645
+ generic_params.append(self.advance().value)
646
+ else:
647
+ raise self.error(f"Expected type parameter, got {self.current_token().type.name}")
648
+
649
+ self.expect(TokenType.GT) # consume >
650
+
651
+ # Format as Python generic: map<string, float> -> dict[str, float]
652
+ return f"{base_type}<{', '.join(generic_params)}>"
653
+
654
+ return base_type
655
+
656
+ def parse_var_declaration(self) -> Union[VarDecl, VaripDecl]:
657
+ """Parse var/varip declaration."""
658
+ is_var = self.match(TokenType.VAR)
659
+ self.advance() # var or varip
660
+
661
+ # Parse optional type hint (with generic support or UDT name)
662
+ type_hint = None
663
+ # A type keyword directly followed by '=' is being used as the variable
664
+ # NAME, not a type hint — Pine lets a variable shadow a type namespace
665
+ # (e.g. `var matrix = matrix.new<float>(...)`, `var array = ...`). A real
666
+ # type hint is instead followed by a name or a generic '<'. Skip the
667
+ # type-hint branch so the keyword is consumed as the name below.
668
+ if self.match(TokenType.TYPE_IDENTIFIER) and self.peek_token().type != TokenType.ASSIGN:
669
+ type_hint = self.parse_generic_type()
670
+ elif self.match(TokenType.IDENTIFIER):
671
+ # Could be a UDT type name like Statistics, TradeInfo
672
+ next_type = self.peek_token().type
673
+ if next_type == TokenType.IDENTIFIER:
674
+ # Simple UDT: TradeInfo myVar = ...
675
+ type_hint = self.advance().value
676
+ elif next_type in (TokenType.LBRACKET, TokenType.LT):
677
+ # UDT array/generic: TestResult[] results = ..., MyType<T> x = ...
678
+ type_hint = self.advance().value # UDT name
679
+ if self.match(TokenType.LBRACKET):
680
+ self.advance() # consume [
681
+ self.expect(TokenType.RBRACKET) # consume ]
682
+ type_hint = f"array<{type_hint}>"
683
+ elif self.match(TokenType.LT):
684
+ # Parse generic params
685
+ self.advance() # consume <
686
+ generic_params = []
687
+ while not self.match(TokenType.GT):
688
+ if self.match(TokenType.COMMA):
689
+ self.advance()
690
+ continue
691
+ if self.match(TokenType.TYPE_IDENTIFIER, TokenType.IDENTIFIER):
692
+ generic_params.append(self.advance().value)
693
+ else:
694
+ raise self.error(f"Expected type parameter, got {self.current_token().type.name}")
695
+ self.expect(TokenType.GT)
696
+ type_hint = f"{type_hint}<{', '.join(generic_params)}>"
697
+
698
+ # Parse variable name. Accept a TYPE_IDENTIFIER here too: when no type
699
+ # hint was taken, a type keyword in this position is the variable name
700
+ # (`var matrix = ...`), which Pine permits.
701
+ # After a type, the name may be a type keyword too (`var color color = na`)
702
+ if self.match(TokenType.TYPE_IDENTIFIER) and self.peek_token().type == TokenType.ASSIGN:
703
+ name = self.advance().value
704
+ else:
705
+ name = self.expect(TokenType.IDENTIFIER).value
706
+
707
+ # Parse = value
708
+ self.expect(TokenType.ASSIGN)
709
+ value = self.parse_expression()
710
+
711
+ if is_var:
712
+ return VarDecl(name=name, value=value, type_hint=type_hint)
713
+ else:
714
+ return VaripDecl(name=name, value=value, type_hint=type_hint)
715
+
716
+ def parse_type_declaration(self) -> TypeDecl:
717
+ """Parse type (UDT) declaration."""
718
+ self.expect(TokenType.TYPE)
719
+ name = self.expect(TokenType.IDENTIFIER).value
720
+
721
+ self.skip_newlines()
722
+ self.expect(TokenType.INDENT)
723
+
724
+ fields = []
725
+ field_lines = []
726
+ while not self.match(TokenType.DEDENT):
727
+ self.skip_newlines()
728
+ if self.match(TokenType.DEDENT):
729
+ break
730
+ field_lines.append(self.current_token().line)
731
+
732
+ # Parse field: [var|varip] type name [= default_value]
733
+ # Type can be a built-in type (TYPE_IDENTIFIER), generic (array<T>), or UDT (IDENTIFIER)
734
+ # varip behaves like var since we don't use realtime candles
735
+ field_is_var = False
736
+ if self.match(TokenType.VAR, TokenType.VARIP):
737
+ self.advance() # consume var/varip
738
+ field_is_var = True
739
+ if self.match(TokenType.TYPE_IDENTIFIER):
740
+ type_hint = self.parse_generic_type()
741
+ elif self.match(TokenType.IDENTIFIER):
742
+ type_hint = self.advance().value
743
+ else:
744
+ raise self.error(f"Expected type, got {self.current_token().type.name}")
745
+ # a field may be named like a type: `box box`
746
+ field_token = (self.advance() if self.match(TokenType.TYPE_IDENTIFIER)
747
+ else self.expect(TokenType.IDENTIFIER))
748
+ field_name = field_token.value
749
+
750
+ # Handle optional default value; it carries its field's line, so a
751
+ # refused default names the Pine line (card #17)
752
+ default_value = None
753
+ if self.match(TokenType.ASSIGN):
754
+ self.advance() # consume =
755
+ default_value = self.parse_expression()
756
+ if isinstance(default_value, ASTNode) and not default_value.line:
757
+ default_value = dataclasses.replace(default_value, line=field_token.line)
758
+
759
+ fields.append((field_name, type_hint, default_value))
760
+ self.skip_newlines()
761
+
762
+ self.expect(TokenType.DEDENT)
763
+
764
+ return TypeDecl(name=name, fields=fields, field_lines=field_lines)
765
+
766
+ def parse_enum_declaration(self) -> EnumDecl:
767
+ """Parse enum declaration."""
768
+ self.expect(TokenType.ENUM)
769
+ # Enum name can be IDENTIFIER or TYPE_IDENTIFIER (e.g., "enum polyline", "enum ta")
770
+ if self.match(TokenType.IDENTIFIER):
771
+ name = self.advance().value
772
+ elif self.match(TokenType.TYPE_IDENTIFIER):
773
+ name = self.advance().value
774
+ else:
775
+ name = self.expect(TokenType.IDENTIFIER).value
776
+
777
+ self.skip_newlines()
778
+ self.expect(TokenType.INDENT)
779
+
780
+ members = []
781
+ while not self.match(TokenType.DEDENT):
782
+ self.skip_newlines()
783
+ if self.match(TokenType.DEDENT):
784
+ break
785
+
786
+ # Parse enum member: name [= value]; can also be TYPE_IDENTIFIER
787
+ if self.match(TokenType.IDENTIFIER):
788
+ member = self.advance().value
789
+ elif self.match(TokenType.TYPE_IDENTIFIER):
790
+ member = self.advance().value
791
+ else:
792
+ member = self.expect(TokenType.IDENTIFIER).value
793
+
794
+ # Check for optional value assignment
795
+ if self.match(TokenType.ASSIGN):
796
+ self.advance() # consume =
797
+ # Parse the value (can be string, int, etc.)
798
+ value = self.parse_expression()
799
+ # Store as tuple (name, value) or just name
800
+ members.append((member, value))
801
+ else:
802
+ members.append(member)
803
+
804
+ self.skip_newlines()
805
+
806
+ self.expect(TokenType.DEDENT)
807
+
808
+ return EnumDecl(name=name, members=members)
809
+
810
+ def is_input_declaration(self) -> bool:
811
+ """Check if current position is an input declaration.
812
+ Matches: name = input.*() or type name = input.*()
813
+ """
814
+ saved_pos = self.pos
815
+
816
+ # Skip optional type hint (e.g., float, int, string)
817
+ if self.match(TokenType.TYPE_IDENTIFIER):
818
+ self.pos += 1
819
+
820
+ # Must have an identifier (the variable name)
821
+ if not self.match(TokenType.IDENTIFIER):
822
+ self.pos = saved_pos
823
+ return False
824
+
825
+ self.pos += 1 # Skip identifier
826
+
827
+ # Check for = input.
828
+ result = (self.match(TokenType.ASSIGN) and
829
+ ((self.peek_token().type == TokenType.IDENTIFIER and
830
+ self.peek_token().value.startswith('input.')) or
831
+ self._legacy_typed_input_call_ahead()))
832
+
833
+ self.pos = saved_pos
834
+ return result
835
+
836
+ def _legacy_typed_input_call_ahead(self) -> bool:
837
+ """At `= input(` ... `type=<kind>` ... `)` with a kind parse_function_call rewrites to an input.* call
838
+ (card #115): such a call is an input declaration, as its v5 spelling is."""
839
+ from .pine_builtins import LEGACY_INPUT_TYPE_ARGUMENT_FUNCTIONS
840
+ if self.peek_token().value != 'input' or self.peek_token(2).type != TokenType.LPAREN:
841
+ return False
842
+ depth, offset = 0, 2
843
+ while True:
844
+ token = self.peek_token(offset)
845
+ if token.type == TokenType.EOF:
846
+ return False
847
+ if token.type in (TokenType.LPAREN, TokenType.LBRACKET):
848
+ depth += 1
849
+ elif token.type in (TokenType.RPAREN, TokenType.RBRACKET):
850
+ depth -= 1
851
+ if depth == 0:
852
+ return False
853
+ elif (depth == 1 and token.type == TokenType.IDENTIFIER and token.value == 'type'
854
+ and self.peek_token(offset + 1).type == TokenType.ASSIGN):
855
+ kind = self.peek_token(offset + 2)
856
+ # a bare `float`/`bool`/`string`/`color` lexes as TYPE_IDENTIFIER; parse_function_call rewrites it too
857
+ return (kind.type in (TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER)
858
+ and kind.value.removeprefix('input.') in LEGACY_INPUT_TYPE_ARGUMENT_FUNCTIONS)
859
+ offset += 1
860
+
861
+ def parse_input_declaration(self) -> InputDecl:
862
+ """Parse input declaration. Handles: name = input.*() and type name = input.*()"""
863
+ # Skip optional type hint
864
+ if self.match(TokenType.TYPE_IDENTIFIER):
865
+ self.advance()
866
+
867
+ name = self.advance().value
868
+ self.expect(TokenType.ASSIGN)
869
+
870
+ # Parse input.*() call
871
+ func_call = self.parse_expression()
872
+
873
+ if not isinstance(func_call, FunctionCall):
874
+ raise self.error("Expected input function call")
875
+
876
+ # Extract function name and arguments
877
+ if isinstance(func_call.func, str):
878
+ func = func_call.func
879
+ elif isinstance(func_call.func, MemberAccess):
880
+ func = f"{func_call.func.object}.{func_call.func.member}"
881
+ else:
882
+ func = "input"
883
+
884
+ return InputDecl(name=name, func=func, args=func_call.args, kwargs=func_call.kwargs)
885
+
886
+ # ========================================================================
887
+ # Statement parsing
888
+ # ========================================================================
889
+
890
+ def parse_statement(self) -> Union[Statement, List[Statement], None]:
891
+ """Parse one source statement: a statement, a list of statements when the source statement is
892
+ lowered to several (an assignment to a field of a call result, card #98), or None."""
893
+ self.skip_newlines()
894
+
895
+ # If statement
896
+ if self.match(TokenType.IF):
897
+ return self.parse_if_statement()
898
+
899
+ # For loop
900
+ if self.match(TokenType.FOR):
901
+ return self.parse_for_loop()
902
+
903
+ # While loop
904
+ if self.match(TokenType.WHILE):
905
+ return self.parse_while_loop()
906
+
907
+ # Switch statement
908
+ if self.match(TokenType.SWITCH):
909
+ return self.parse_switch_statement()
910
+
911
+ # Break
912
+ if self.match(TokenType.BREAK):
913
+ self.advance()
914
+ return BreakStatement()
915
+
916
+ # Continue
917
+ if self.match(TokenType.CONTINUE):
918
+ self.advance()
919
+ return ContinueStatement()
920
+
921
+ # once structure (Pine v6): `once [condition]` + an indented block (card #59)
922
+ if self._at_once_structure():
923
+ return self.parse_once_statement()
924
+
925
+ # Var/varip declarations (can appear inside blocks in Pine Script v6)
926
+ if self.match(TokenType.VAR, TokenType.VARIP):
927
+ return self.parse_var_declaration()
928
+
929
+ # Local variable declaration with explicit type: type name = value
930
+ # Examples: float x = 10.0, string[] ids = array.new_string()
931
+ # Also handles user-defined types: Signal signalState = ...
932
+ # A leading type keyword is only a declaration when followed by the var
933
+ # NAME (IDENTIFIER), a generic '<', or an array '[]'. Anything else means
934
+ # the keyword is being used as a VALUE — a type-conversion call
935
+ # `bool(na)` (LPAREN), a bare variable reference `matrix` (NEWLINE, when a
936
+ # variable shadows the type namespace), member access `matrix.set(...)`
937
+ # (DOT), or history access `matrix[0]` — all of which are expressions.
938
+ # A following TYPE_IDENTIFIER also means a decl — a qualifier/modifier
939
+ # chain such as `const matrix<T> m`, `series int x`, `simple string s`.
940
+ _pk = self.peek_token().type
941
+ _is_typed_decl = _pk in (TokenType.LT, TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER) or (
942
+ _pk == TokenType.LBRACKET
943
+ and self.pos + 2 < len(self.tokens)
944
+ and self.tokens[self.pos + 2].type == TokenType.RBRACKET
945
+ )
946
+ if self.match(TokenType.TYPE_IDENTIFIER) and _is_typed_decl:
947
+ type_hint = self.parse_generic_type()
948
+ is_const = getattr(self, 'last_type_modifier', None) == 'const'
949
+ var_name = (self.advance().value if self.match(TokenType.TYPE_IDENTIFIER)
950
+ and self.peek_token().type == TokenType.ASSIGN
951
+ else self.expect(TokenType.IDENTIFIER).value)
952
+ self.expect(TokenType.ASSIGN)
953
+ value = self.parse_expression()
954
+ # Return as a local variable assignment with type hint
955
+ return Assignment(target=var_name, value=value, type_hint=type_hint, is_const=is_const)
956
+
957
+ # Check for user-defined type declaration: UDT varName = value
958
+ # Heuristic: IDENTIFIER IDENTIFIER ASSIGN (e.g., Signal signalState =)
959
+ if (self.match(TokenType.IDENTIFIER) and
960
+ self.pos + 1 < len(self.tokens) and
961
+ self.tokens[self.pos + 1].type == TokenType.IDENTIFIER and
962
+ self.pos + 2 < len(self.tokens) and
963
+ self.tokens[self.pos + 2].type == TokenType.ASSIGN):
964
+ type_name = self.advance().value
965
+ var_name = self.advance().value
966
+ self.expect(TokenType.ASSIGN)
967
+ value = self.parse_expression()
968
+ return Assignment(target=var_name, value=value, type_hint=type_name)
969
+
970
+ # Check for UDT array declaration: UDT[] varName = value
971
+ # Heuristic: IDENTIFIER LBRACKET RBRACKET IDENTIFIER ASSIGN
972
+ if (self.match(TokenType.IDENTIFIER) and
973
+ self.pos + 1 < len(self.tokens) and
974
+ self.tokens[self.pos + 1].type == TokenType.LBRACKET and
975
+ self.pos + 2 < len(self.tokens) and
976
+ self.tokens[self.pos + 2].type == TokenType.RBRACKET and
977
+ self.pos + 3 < len(self.tokens) and
978
+ self.tokens[self.pos + 3].type == TokenType.IDENTIFIER and
979
+ self.pos + 4 < len(self.tokens) and
980
+ self.tokens[self.pos + 4].type == TokenType.ASSIGN):
981
+ udt_name = self.advance().value
982
+ self.advance() # consume [
983
+ self.advance() # consume ]
984
+ var_name = self.advance().value
985
+ self.expect(TokenType.ASSIGN)
986
+ value = self.parse_expression()
987
+ return Assignment(target=var_name, value=value, type_hint=f"array<{udt_name}>")
988
+
989
+ # Check for nested function declarations: name(params) =>
990
+ if self.is_function_declaration():
991
+ return self.parse_function_declaration()
992
+
993
+ # Assignment or expression statement
994
+ start_token = self.current_token()
995
+ expr = self.parse_expression()
996
+
997
+ # Check for assignment/reassignment
998
+ if self.match(TokenType.ASSIGN):
999
+ operator_token = self.advance()
1000
+ self._refuse_history_target(expr, operator_token)
1001
+ call_result_field = self._is_call_result_field(expr)
1002
+ if not call_result_field:
1003
+ self._refuse_unassignable_target(expr, operator_token, tuple_allowed=True)
1004
+ value = self.parse_expression()
1005
+
1006
+ if call_result_field:
1007
+ return self._lower_call_result_field_assignment(expr, operator_token, value, start_token)
1008
+ if isinstance(expr, Identifier):
1009
+ return Assignment(target=expr.name, value=value)
1010
+ elif isinstance(expr, ArrayLiteral):
1011
+ # Tuple destructuring
1012
+ names = [e.name if isinstance(e, Identifier) else str(_without_positions(e)) for e in expr.elements]
1013
+ return Assignment(target=TupleDestructure(names=names, value=value), value=value)
1014
+
1015
+ elif self.match(TokenType.REASSIGN):
1016
+ operator_token = self.advance()
1017
+ self._refuse_history_target(expr, operator_token)
1018
+ call_result_field = self._is_call_result_field(expr)
1019
+ if not call_result_field:
1020
+ self._refuse_unassignable_target(expr, operator_token)
1021
+ value = self.parse_expression()
1022
+
1023
+ if call_result_field:
1024
+ return self._lower_call_result_field_assignment(expr, operator_token, value, start_token)
1025
+ if isinstance(expr, Identifier):
1026
+ return Reassignment(target=expr.name, value=value)
1027
+
1028
+ # Compound assignment operators: +=, -=, *=, /=, %=
1029
+ elif self.match(TokenType.PLUS_ASSIGN, TokenType.MINUS_ASSIGN, TokenType.MULT_ASSIGN,
1030
+ TokenType.DIV_ASSIGN, TokenType.MOD_ASSIGN):
1031
+ op_token = self.advance()
1032
+ self._refuse_history_target(expr, op_token)
1033
+ call_result_field = self._is_call_result_field(expr)
1034
+ if not call_result_field:
1035
+ self._refuse_unassignable_target(expr, op_token)
1036
+ right_value = self.parse_expression()
1037
+
1038
+ if call_result_field:
1039
+ return self._lower_call_result_field_assignment(expr, op_token, right_value, start_token)
1040
+ if isinstance(expr, Identifier):
1041
+ # Convert x += 5 to x := x + 5
1042
+ op_symbol = COMPOUND_ASSIGNMENT_OPERATORS[op_token.type]
1043
+
1044
+ # Create binary expression: x + 5
1045
+ from .ast_nodes import BinaryOp
1046
+ new_value = BinaryOp(left=expr, op=op_symbol, right=right_value, compound=True)
1047
+
1048
+ # Return reassignment: x := (x + 5)
1049
+ return Reassignment(target=expr.name, value=new_value)
1050
+
1051
+ # Expression statement
1052
+ return ExpressionStatement(expr=expr)
1053
+
1054
+ @staticmethod
1055
+ def _refuse_history_target(target: Expression, operator_token: Token) -> None:
1056
+ """`x[1] := v`, `x[1] += v`, `x[1] = v`: TradingView refuses an
1057
+ assignment to a history reference. The tree has no node that could keep
1058
+ it (the statement used to be dropped), so it is refused here, at the
1059
+ operator (card #28)."""
1060
+ if isinstance(target, IndexAccess):
1061
+ raise SemanticError('history-target',
1062
+ f'cannot assign with {operator_token.value!r} to a history reference (x[n])',
1063
+ operator_token.line, operator_token.column)
1064
+
1065
+ @staticmethod
1066
+ def _is_call_result_field(target: Expression) -> bool:
1067
+ """`arr.get(0).v`: a field of the value an expression returns (card #98)."""
1068
+ return isinstance(target, MemberAccess) and not isinstance(target.object, str)
1069
+
1070
+ def _lower_call_result_field_assignment(self, target: MemberAccess, operator_token: Token, value: Expression,
1071
+ start_token: Token) -> List[Statement]:
1072
+ """`arr.get(0).v += 1` -> `assigned_field_receiver1 = arr.get(0)` then
1073
+ `assigned_field_receiver1.v := assigned_field_receiver1.v + 1` (card #98).
1074
+
1075
+ The receiver is the object a call returns, so the field changes in that object; binding it
1076
+ to a variable first evaluates the call once, as Pine does, and uses the dotted-name
1077
+ assignment every later stage already handles. The variable is fresh against every
1078
+ identifier of the script."""
1079
+ receiver = self._fresh_receiver_name()
1080
+ field = f'{receiver}.{target.member}'
1081
+ position = self._position_of(start_token)
1082
+ if operator_token.type is TokenType.ASSIGN:
1083
+ assigned = Assignment(target=field, value=value)
1084
+ elif operator_token.type is TokenType.REASSIGN:
1085
+ assigned = Reassignment(target=field, value=value)
1086
+ else:
1087
+ current = Identifier(name=field, **position)
1088
+ assigned = Reassignment(target=field, value=BinaryOp(
1089
+ left=current, op=COMPOUND_ASSIGNMENT_OPERATORS[operator_token.type], right=value, compound=True,
1090
+ **position))
1091
+ statements = [Assignment(target=receiver, value=target.object), assigned]
1092
+ for statement in statements:
1093
+ statement.line, statement.column = position.get('line', 0), position.get('column', 0)
1094
+ return statements
1095
+
1096
+ def _fresh_receiver_name(self) -> str:
1097
+ """`assigned_field_receiver<N>`, the lowest N no identifier of the script holds."""
1098
+ if self._token_names is None:
1099
+ self._token_names = {part for token in self.tokens if isinstance(token.value, str)
1100
+ for part in token.value.split('.')}
1101
+ return fresh_name(RECEIVER_NAME_STEM, self._token_names)
1102
+
1103
+ @staticmethod
1104
+ def _refuse_unassignable_target(target: Expression, operator_token: Token, tuple_allowed: bool = False) -> None:
1105
+ """`arr.get(0).v += 1` is valid Pine, but the tree keeps an assignment only to a name (a
1106
+ dotted field path included) or, for `=`, a tuple. Any other target used to be dropped
1107
+ without a word, leaving a bare expression statement, so it is refused at the operator
1108
+ (card #94). A tuple that is not allowed (`[a, b] := f()`, `[a, b] += ...`) has no receiver
1109
+ and no field, so it gets its own text (card #99)."""
1110
+ if isinstance(target, Identifier) or (tuple_allowed and isinstance(target, ArrayLiteral)):
1111
+ return
1112
+ if isinstance(target, ArrayLiteral):
1113
+ advice = ("to a tuple is not supported; destructure into new names with '=' "
1114
+ "and assign each name with its own ':=' statement")
1115
+ else:
1116
+ advice = 'to the result of an expression; assign the receiver to a variable first and assign its field'
1117
+ raise UnsupportedFeatureError(f'assignment with {operator_token.value!r} {advice}',
1118
+ operator_token.line, operator_token.column)
1119
+
1120
+ def parse_block(self) -> List[Statement]:
1121
+ """Parse a block of statements (indented)."""
1122
+ statements = []
1123
+
1124
+ while not self.match(TokenType.DEDENT, TokenType.EOF):
1125
+ self.skip_newlines()
1126
+ if self.match(TokenType.DEDENT, TokenType.EOF):
1127
+ break
1128
+
1129
+ statements.extend(_as_statement_list(self._parse_positioned_statement()))
1130
+
1131
+ # Handle comma-separated statements on the same line
1132
+ # e.g., colNum = 8, rowNum = 8
1133
+ while self.match(TokenType.COMMA):
1134
+ self.advance() # consume comma
1135
+ statements.extend(_as_statement_list(self._parse_positioned_statement()))
1136
+
1137
+ return statements
1138
+
1139
+ # ------------------------------------------------------------------ positions (card #28)
1140
+
1141
+ @staticmethod
1142
+ def _position_of(token: Token) -> dict:
1143
+ """The Pine line and 1-based column of `token`, as node fields: a nested
1144
+ statement, an identifier, a call and a `[]` carry the position a
1145
+ refusal points at. The one source of the positions card #28 adds."""
1146
+ return {'line': token.line, 'column': token.column}
1147
+
1148
+ def _stamp_top_level(self, node, start_token: Token) -> None:
1149
+ """A top-level item's line, as always, and its column. Only the column
1150
+ goes through _position_of, so switching positions off keeps the line."""
1151
+ node.line = start_token.line
1152
+ node.column = self._position_of(start_token).get('column', 0)
1153
+
1154
+ def _stamp_nested(self, node, start_token: Token):
1155
+ """A freshly parsed nested statement's position, unless it has one."""
1156
+ if node is not None and not node.line:
1157
+ position = self._position_of(start_token)
1158
+ node.line, node.column = position.get('line', 0), position.get('column', 0)
1159
+ return node
1160
+
1161
+ def _parse_positioned_statement(self):
1162
+ start_token = self.current_token()
1163
+ statement = self.parse_statement()
1164
+ if isinstance(statement, list): # one source statement lowered to several (card #98)
1165
+ return [self._stamp_nested(item, start_token) for item in statement]
1166
+ return self._stamp_nested(statement, start_token)
1167
+
1168
+ def _parse_positioned_single_line_item(self):
1169
+ start_token = self.current_token()
1170
+ item = self._parse_single_line_item()
1171
+ if isinstance(item, (Assignment, VarDecl, VaripDecl)):
1172
+ return self._stamp_nested(item, start_token)
1173
+ return item
1174
+
1175
+ def parse_if_statement(self) -> IfStatement:
1176
+ """Parse if statement."""
1177
+ self.expect(TokenType.IF)
1178
+ condition = self.parse_expression()
1179
+
1180
+ self.skip_newlines()
1181
+ self.expect(TokenType.INDENT)
1182
+ body = self.parse_block()
1183
+ self.expect(TokenType.DEDENT)
1184
+
1185
+ # Parse else if clauses
1186
+ elseifs = []
1187
+ while self.match(TokenType.ELSE):
1188
+ self.advance()
1189
+ if self.match(TokenType.IF):
1190
+ self.advance()
1191
+ elif_condition = self.parse_expression()
1192
+ self.skip_newlines()
1193
+ self.expect(TokenType.INDENT)
1194
+ elif_body = self.parse_block()
1195
+ self.expect(TokenType.DEDENT)
1196
+ elseifs.append((elif_condition, elif_body))
1197
+ else:
1198
+ # Else clause
1199
+ self.skip_newlines()
1200
+ self.expect(TokenType.INDENT)
1201
+ else_body = self.parse_block()
1202
+ self.expect(TokenType.DEDENT)
1203
+ return IfStatement(condition=condition, body=body, elseifs=elseifs, else_body=else_body)
1204
+
1205
+ return IfStatement(condition=condition, body=body, elseifs=elseifs)
1206
+
1207
+ _ASSIGNMENT_OPERATORS = frozenset({
1208
+ TokenType.ASSIGN, TokenType.REASSIGN, TokenType.PLUS_ASSIGN, TokenType.MINUS_ASSIGN,
1209
+ TokenType.MULT_ASSIGN, TokenType.DIV_ASSIGN, TokenType.MOD_ASSIGN,
1210
+ })
1211
+ # Tokens after `once` that make it a name: `once = 1`, `once := x`, `once.f()`, `once[1]`
1212
+ _ONCE_AS_NAME_FOLLOWERS = _ASSIGNMENT_OPERATORS | {
1213
+ TokenType.DOT, TokenType.LBRACKET, TokenType.COMMA, TokenType.ARROW, TokenType.EOF,
1214
+ }
1215
+
1216
+ def _at_once_structure(self) -> bool:
1217
+ """At a Pine v6 `once` structure: the identifier `once` whose logical
1218
+ line is followed by an indented block. `once` is not a keyword here, so
1219
+ a variable or function named `once` (Pine v5) stays a name (card #59)."""
1220
+ token = self.current_token()
1221
+ if token.type != TokenType.IDENTIFIER or token.value != 'once':
1222
+ return False
1223
+ if self.peek_token().type in self._ONCE_AS_NAME_FOLLOWERS or self.is_function_declaration():
1224
+ return False
1225
+ index, depth = self.pos + 1, 0
1226
+ while index < len(self.tokens):
1227
+ kind = self.tokens[index].type
1228
+ if kind in (TokenType.LPAREN, TokenType.LBRACKET):
1229
+ depth += 1
1230
+ elif kind in (TokenType.RPAREN, TokenType.RBRACKET):
1231
+ depth -= 1
1232
+ elif kind == TokenType.EOF or (kind == TokenType.NEWLINE and depth <= 0):
1233
+ break
1234
+ index += 1
1235
+ while index < len(self.tokens) and self.tokens[index].type == TokenType.NEWLINE:
1236
+ index += 1
1237
+ return index < len(self.tokens) and self.tokens[index].type == TokenType.INDENT
1238
+
1239
+ def parse_once_statement(self) -> OnceStatement:
1240
+ """`once [condition]` + an indented block. Pine gives it no `else`."""
1241
+ self.advance() # once
1242
+ condition = None if self.match(TokenType.NEWLINE) else self.parse_expression()
1243
+ self.skip_newlines()
1244
+ self.expect(TokenType.INDENT)
1245
+ body = self.parse_block()
1246
+ self.expect(TokenType.DEDENT)
1247
+ if self.match(TokenType.ELSE):
1248
+ raise self.error("a 'once' structure has no 'else'")
1249
+ return OnceStatement(condition=condition, body=body)
1250
+
1251
+ def parse_for_loop(self) -> Union[ForLoop, ForInLoop]:
1252
+ """Parse for loop (range or for-in style)."""
1253
+ self.expect(TokenType.FOR)
1254
+
1255
+ # Check for tuple destructuring [a, b] in ...
1256
+ if self.match(TokenType.LBRACKET):
1257
+ return self.parse_for_in_loop()
1258
+
1259
+ # Skip optional type hint: for int i = ... or for float x in ...
1260
+ if self.match(TokenType.TYPE_IDENTIFIER):
1261
+ self.advance() # consume type hint (int, float, etc.)
1262
+
1263
+ # Check for regular 'for var in iterable'
1264
+ var_name = self.expect(TokenType.IDENTIFIER).value
1265
+
1266
+ if self.match(TokenType.IN):
1267
+ # for-in loop
1268
+ self.advance()
1269
+ iterable = self.parse_expression()
1270
+
1271
+ self.skip_newlines()
1272
+ self.expect(TokenType.INDENT)
1273
+ body = self.parse_block()
1274
+ self.expect(TokenType.DEDENT)
1275
+
1276
+ return ForInLoop(vars=[var_name], iterable=iterable, body=body)
1277
+
1278
+ # for var = from to to_val [by step]
1279
+ self.expect(TokenType.ASSIGN)
1280
+ from_val = self.parse_expression()
1281
+
1282
+ self.expect(TokenType.TO)
1283
+ to_val = self.parse_expression()
1284
+
1285
+ step = None
1286
+ if self.match(TokenType.BY):
1287
+ self.advance()
1288
+ step = self.parse_expression()
1289
+
1290
+ self.skip_newlines()
1291
+ self.expect(TokenType.INDENT)
1292
+ body = self.parse_block()
1293
+ self.expect(TokenType.DEDENT)
1294
+
1295
+ return ForLoop(var=var_name, from_val=from_val, to_val=to_val, step=step, body=body)
1296
+
1297
+ def parse_for_in_loop(self) -> ForInLoop:
1298
+ """Parse for-in loop with tuple destructuring."""
1299
+ self.expect(TokenType.LBRACKET)
1300
+
1301
+ vars = []
1302
+ while not self.match(TokenType.RBRACKET):
1303
+ if self.match(TokenType.COMMA):
1304
+ self.advance()
1305
+ continue
1306
+
1307
+ vars.append(self.expect(TokenType.IDENTIFIER).value)
1308
+
1309
+ self.expect(TokenType.RBRACKET)
1310
+ self.expect(TokenType.IN)
1311
+
1312
+ iterable = self.parse_expression()
1313
+
1314
+ self.skip_newlines()
1315
+ self.expect(TokenType.INDENT)
1316
+ body = self.parse_block()
1317
+ self.expect(TokenType.DEDENT)
1318
+
1319
+ return ForInLoop(vars=vars, iterable=iterable, body=body)
1320
+
1321
+ def parse_while_loop(self) -> WhileLoop:
1322
+ """Parse while loop."""
1323
+ self.expect(TokenType.WHILE)
1324
+ condition = self.parse_expression()
1325
+
1326
+ self.skip_newlines()
1327
+ self.expect(TokenType.INDENT)
1328
+ body = self.parse_block()
1329
+ self.expect(TokenType.DEDENT)
1330
+
1331
+ return WhileLoop(condition=condition, body=body)
1332
+
1333
+ def _inline_statements(self) -> list:
1334
+ """The statements of a one-line block, separated by commas (`a := 1, b := 2`)."""
1335
+ statements = _as_statement_list(self._parse_positioned_statement())
1336
+ while self.match(TokenType.COMMA):
1337
+ self.advance()
1338
+ statements.extend(_as_statement_list(self._parse_positioned_statement()))
1339
+ return statements
1340
+
1341
+ def parse_switch_statement(self) -> SwitchStatement:
1342
+ """Parse switch statement."""
1343
+ self.expect(TokenType.SWITCH)
1344
+
1345
+ # Optional expression
1346
+ expr = None
1347
+ if not self.match(TokenType.NEWLINE):
1348
+ expr = self.parse_expression()
1349
+
1350
+ self.skip_newlines()
1351
+ self.expect(TokenType.INDENT)
1352
+
1353
+ cases = []
1354
+ default = None
1355
+
1356
+ while not self.match(TokenType.DEDENT):
1357
+ self.skip_newlines()
1358
+ if self.match(TokenType.DEDENT):
1359
+ break
1360
+
1361
+ # Check for default case (starts with =>)
1362
+ if self.match(TokenType.ARROW):
1363
+ self.advance() # consume =>
1364
+ self.skip_newlines()
1365
+ if self.match(TokenType.INDENT):
1366
+ self.advance()
1367
+ default = self.parse_block()
1368
+ self.expect(TokenType.DEDENT)
1369
+ else:
1370
+ default = self._inline_statements()
1371
+ self.skip_newlines()
1372
+ continue
1373
+
1374
+ # Parse case expression
1375
+ case_expr = self.parse_expression()
1376
+
1377
+ self.expect(TokenType.ARROW)
1378
+ self.skip_newlines()
1379
+
1380
+ # Parse case body
1381
+ if self.match(TokenType.INDENT):
1382
+ self.advance()
1383
+ case_body = self.parse_block()
1384
+ self.expect(TokenType.DEDENT)
1385
+ else:
1386
+ # Single line, possibly several statements: `'FX' => a := 1, b := 2`
1387
+ case_body = self._inline_statements()
1388
+
1389
+ cases.append((case_expr, case_body))
1390
+
1391
+ self.expect(TokenType.DEDENT)
1392
+
1393
+ return SwitchStatement(expr=expr, cases=cases, default=default)
1394
+
1395
+ # ========================================================================
1396
+ # Expression parsing
1397
+ # ========================================================================
1398
+
1399
+ def parse_expression(self) -> Expression:
1400
+ """Parse expression (entry point for expression parsing)."""
1401
+ return self.parse_ternary()
1402
+
1403
+ def parse_ternary(self) -> Expression:
1404
+ """Parse ternary operator: cond ? true_expr : false_expr"""
1405
+ expr = self.parse_or()
1406
+
1407
+ if self.match(TokenType.TERNARY):
1408
+ self.advance()
1409
+ true_expr = self.parse_ternary()
1410
+ self.expect(TokenType.COLON)
1411
+ false_expr = self.parse_ternary()
1412
+ return TernaryOp(condition=expr, true_expr=true_expr, false_expr=false_expr)
1413
+
1414
+ return expr
1415
+
1416
+ def parse_or(self) -> Expression:
1417
+ """Parse logical OR."""
1418
+ left = self.parse_and()
1419
+
1420
+ while self.match(TokenType.OR):
1421
+ op = self.advance().value
1422
+ right = self.parse_and()
1423
+ left = BinaryOp(left=left, op=op, right=right)
1424
+
1425
+ return left
1426
+
1427
+ def parse_and(self) -> Expression:
1428
+ """Parse logical AND."""
1429
+ left = self.parse_equality()
1430
+
1431
+ while self.match(TokenType.AND):
1432
+ op = self.advance().value
1433
+ right = self.parse_equality()
1434
+ left = BinaryOp(left=left, op=op, right=right)
1435
+
1436
+ return left
1437
+
1438
+ def parse_equality(self) -> Expression:
1439
+ """Parse == and != operators."""
1440
+ left = self.parse_comparison()
1441
+
1442
+ while self.match(TokenType.EQ, TokenType.NEQ):
1443
+ op = self.advance().value
1444
+ right = self.parse_comparison()
1445
+ left = BinaryOp(left=left, op=op, right=right)
1446
+
1447
+ return left
1448
+
1449
+ def parse_comparison(self) -> Expression:
1450
+ """Parse <, >, <=, >= operators."""
1451
+ left = self.parse_addition()
1452
+
1453
+ while self.match(TokenType.LT, TokenType.GT, TokenType.LTE, TokenType.GTE):
1454
+ op = self.advance().value
1455
+ right = self.parse_addition()
1456
+ left = BinaryOp(left=left, op=op, right=right)
1457
+
1458
+ return left
1459
+
1460
+ def parse_addition(self) -> Expression:
1461
+ """Parse + and - operators."""
1462
+ left = self.parse_multiplication()
1463
+
1464
+ while self.match(TokenType.PLUS, TokenType.MINUS):
1465
+ op = self.advance().value
1466
+ right = self.parse_multiplication()
1467
+ left = BinaryOp(left=left, op=op, right=right)
1468
+
1469
+ return left
1470
+
1471
+ def parse_multiplication(self) -> Expression:
1472
+ """Parse *, /, % operators."""
1473
+ left = self.parse_unary()
1474
+
1475
+ while self.match(TokenType.MULT, TokenType.DIV, TokenType.MOD):
1476
+ op = self.advance().value
1477
+ right = self.parse_unary()
1478
+ left = BinaryOp(left=left, op=op, right=right)
1479
+
1480
+ return left
1481
+
1482
+ def parse_unary(self) -> Expression:
1483
+ """Parse unary operators (-, not)."""
1484
+ if self.match(TokenType.MINUS, TokenType.NOT):
1485
+ op = self.advance().value
1486
+ operand = self.parse_unary()
1487
+ return UnaryOp(op=op, operand=operand)
1488
+
1489
+ return self.parse_postfix()
1490
+
1491
+ def parse_postfix(self) -> Expression:
1492
+ """Parse postfix operations (function calls, member access, indexing)."""
1493
+ expr = self.parse_primary()
1494
+
1495
+ # Block-level expressions (switch/if) already consumed their DEDENT;
1496
+ # don't greedily attach postfix [ or . from the NEXT statement
1497
+ if isinstance(expr, (SwitchExpression, IfExpression)):
1498
+ return expr
1499
+
1500
+ while True:
1501
+ # Function call (may have generic parameters)
1502
+ # Parse as generics only for function/method names, not after index access
1503
+ # This prevents `arr[0] < 5` and `close < ema` from being parsed as generics
1504
+ # Heuristic: generics MUST be followed by ( for function call
1505
+ if (self.match(TokenType.LT) and
1506
+ self.peek_token().type in (TokenType.TYPE_IDENTIFIER, TokenType.IDENTIFIER) and
1507
+ isinstance(expr, (Identifier, MemberAccess)) and
1508
+ self._looks_like_generic_call()): # Additional check
1509
+ # Generic function call: func<T>() or obj.method<T>()
1510
+ generics = self.parse_generic_params()
1511
+ # Add generics to the expression (store as string annotation)
1512
+ if isinstance(expr, Identifier):
1513
+ expr.name = f"{expr.name}<{generics}>"
1514
+ elif isinstance(expr, MemberAccess):
1515
+ expr.member = f"{expr.member}<{generics}>"
1516
+ # Continue to parse the actual function call
1517
+ if self.match(TokenType.LPAREN):
1518
+ expr = self.parse_function_call(expr)
1519
+ elif self.match(TokenType.LPAREN):
1520
+ expr = self.parse_function_call(expr)
1521
+ # Member access
1522
+ elif self.match(TokenType.DOT):
1523
+ self.advance()
1524
+ member = self.expect(TokenType.IDENTIFIER).value
1525
+
1526
+ # Check for generic parameters after member (support both built-in and user-defined types)
1527
+ if (self.match(TokenType.LT) and
1528
+ self.peek_token().type in (TokenType.TYPE_IDENTIFIER, TokenType.IDENTIFIER) and
1529
+ self._looks_like_generic_call()):
1530
+ generics = self.parse_generic_params()
1531
+ member = f"{member}<{generics}>"
1532
+
1533
+ # Check if this is a method call
1534
+ if self.match(TokenType.LPAREN):
1535
+ expr = self.parse_method_call(expr, member)
1536
+ else:
1537
+ expr = MemberAccess(object=expr, member=member)
1538
+ # Index access
1539
+ elif self.match(TokenType.LBRACKET):
1540
+ bracket = self.advance()
1541
+ index = self.parse_expression()
1542
+ self.expect(TokenType.RBRACKET)
1543
+ expr = IndexAccess(object=expr, index=index, **self._position_of(bracket))
1544
+ else:
1545
+ break
1546
+
1547
+ return expr
1548
+
1549
+ def parse_generic_params(self) -> str:
1550
+ """Parse generic parameters in angle brackets: <T> or <K, V>."""
1551
+ self.expect(TokenType.LT) # consume <
1552
+
1553
+ params = []
1554
+ while not self.match(TokenType.GT):
1555
+ if self.match(TokenType.COMMA):
1556
+ self.advance()
1557
+ continue
1558
+
1559
+ # Parse type parameter
1560
+ if self.match(TokenType.TYPE_IDENTIFIER, TokenType.IDENTIFIER):
1561
+ params.append(self.advance().value)
1562
+ else:
1563
+ raise self.error(f"Expected type parameter, got {self.current_token().type.name}")
1564
+
1565
+ self.expect(TokenType.GT) # consume >
1566
+
1567
+ return ', '.join(params)
1568
+
1569
+ def parse_function_call(self, func: Expression) -> FunctionCall:
1570
+ """Parse function call arguments."""
1571
+ self.expect(TokenType.LPAREN)
1572
+
1573
+ args = []
1574
+ kwargs = {}
1575
+
1576
+ while not self.match(TokenType.RPAREN):
1577
+ if self.match(TokenType.COMMA):
1578
+ self.advance()
1579
+ continue
1580
+
1581
+ self.skip_newlines()
1582
+ if self.match(TokenType.RPAREN):
1583
+ break
1584
+
1585
+ # Check for keyword argument (can be IDENTIFIER or TYPE_IDENTIFIER like 'color')
1586
+ if self.match(TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER) and self.peek_token().type == TokenType.ASSIGN:
1587
+ key = self.advance().value
1588
+ self.advance() # =
1589
+ value = self.parse_expression()
1590
+ kwargs[key] = value
1591
+ else:
1592
+ # Positional argument
1593
+ args.append(self.parse_expression())
1594
+
1595
+ self.expect(TokenType.RPAREN)
1596
+
1597
+ # Convert func expression to string if it's an identifier or member access
1598
+ if isinstance(func, Identifier):
1599
+ func_name = func.name
1600
+ elif isinstance(func, MemberAccess):
1601
+ func_name = func
1602
+ else:
1603
+ func_name = func
1604
+
1605
+ # Pine v2-v4 `input(defval, title, type=input.float)` is the v5 `input.float(defval, title)` (card #115)
1606
+ if func_name == 'input' and isinstance(kwargs.get('type'), Identifier):
1607
+ from .pine_builtins import LEGACY_INPUT_TYPE_ARGUMENT_FUNCTIONS
1608
+ typed_function = LEGACY_INPUT_TYPE_ARGUMENT_FUNCTIONS.get(kwargs['type'].name.removeprefix('input.'))
1609
+ if typed_function is not None:
1610
+ func_name = typed_function
1611
+ kwargs = {key: value for key, value in kwargs.items() if key != 'type'}
1612
+
1613
+ # a call is placed at its callee's name, where a refusal points (card #28)
1614
+ return FunctionCall(func=func_name, args=args, kwargs=kwargs,
1615
+ line=getattr(func, 'line', 0), column=getattr(func, 'column', 0))
1616
+
1617
+ def parse_method_call(self, obj: Expression, method: str) -> MethodCall:
1618
+ """Parse method call arguments."""
1619
+ self.expect(TokenType.LPAREN)
1620
+
1621
+ args = []
1622
+ kwargs = {}
1623
+
1624
+ while not self.match(TokenType.RPAREN):
1625
+ if self.match(TokenType.COMMA):
1626
+ self.advance()
1627
+ continue
1628
+
1629
+ # Check for keyword argument (can be IDENTIFIER or TYPE_IDENTIFIER like 'color')
1630
+ if self.match(TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER) and self.peek_token().type == TokenType.ASSIGN:
1631
+ key = self.advance().value
1632
+ self.advance() # =
1633
+ value = self.parse_expression()
1634
+ kwargs[key] = value
1635
+ else:
1636
+ # Positional argument
1637
+ args.append(self.parse_expression())
1638
+
1639
+ self.expect(TokenType.RPAREN)
1640
+
1641
+ return MethodCall(object=obj, method=method, args=args, kwargs=kwargs)
1642
+
1643
+ def parse_primary(self) -> Expression:
1644
+ """Parse primary expressions (literals, identifiers, parenthesized expressions)."""
1645
+ if self.pos > 0 and self.tokens[self.pos - 1].type in self._ASSIGNMENT_OPERATORS and self._at_once_structure():
1646
+ raise self.error("a 'once' structure returns no value: it cannot be assigned")
1647
+ # Literals
1648
+ if self.match(TokenType.INT_LITERAL):
1649
+ token = self.advance()
1650
+ return Literal(value=token.value, literal_type='int')
1651
+
1652
+ if self.match(TokenType.FLOAT_LITERAL):
1653
+ token = self.advance()
1654
+ return Literal(value=token.value, literal_type='float')
1655
+
1656
+ if self.match(TokenType.STRING_LITERAL):
1657
+ token = self.advance()
1658
+ return Literal(value=token.value, literal_type='string', is_double_quoted=token.is_double_quoted)
1659
+
1660
+ if self.match(TokenType.TRUE):
1661
+ self.advance()
1662
+ return Literal(value=True, literal_type='bool')
1663
+
1664
+ if self.match(TokenType.FALSE):
1665
+ self.advance()
1666
+ return Literal(value=False, literal_type='bool')
1667
+
1668
+ if self.match(TokenType.COLOR_LITERAL):
1669
+ token = self.advance()
1670
+ return Literal(value=token.value, literal_type='color', is_double_quoted=token.is_double_quoted)
1671
+
1672
+ if self.match(TokenType.NA_LITERAL):
1673
+ self.advance()
1674
+ return NaLiteral()
1675
+
1676
+ # Array literal
1677
+ if self.match(TokenType.LBRACKET):
1678
+ self.advance()
1679
+ elements = []
1680
+
1681
+ while not self.match(TokenType.RBRACKET):
1682
+ if self.match(TokenType.COMMA):
1683
+ self.advance()
1684
+ continue
1685
+
1686
+ elements.append(self.parse_expression())
1687
+
1688
+ self.expect(TokenType.RBRACKET)
1689
+ return ArrayLiteral(elements=elements)
1690
+
1691
+ # Parenthesized expression
1692
+ if self.match(TokenType.LPAREN):
1693
+ self.advance()
1694
+ expr = self.parse_expression()
1695
+ self.expect(TokenType.RPAREN)
1696
+ return expr
1697
+
1698
+ # Identifier (`function` too, when a user function is named so: `function(x)`)
1699
+ if self.match(TokenType.IDENTIFIER, TokenType.TYPE_IDENTIFIER) or (
1700
+ self.match(TokenType.FUNCTION) and self.peek_token().type == TokenType.LPAREN):
1701
+ token = self.advance()
1702
+ return Identifier(name=token.value, **self._position_of(token))
1703
+
1704
+ # If expression
1705
+ if self.match(TokenType.IF):
1706
+ return self.parse_if_expression()
1707
+
1708
+ # Switch expression
1709
+ if self.match(TokenType.SWITCH):
1710
+ return self.parse_switch_expression()
1711
+
1712
+ # For/for-in as value expression: x = for i in arr ...
1713
+ if self.match(TokenType.FOR):
1714
+ return self.parse_for_loop()
1715
+
1716
+ # While as a value expression: x = while cond ... (card #50)
1717
+ if self.match(TokenType.WHILE):
1718
+ return self.parse_while_loop()
1719
+
1720
+ raise self.error(f"Unexpected token in expression: {self.current_token().type.name}")
1721
+
1722
+ def parse_if_expression(self) -> IfExpression:
1723
+ """Parse if expression (returns a value)."""
1724
+ self.expect(TokenType.IF)
1725
+ condition = self.parse_expression()
1726
+
1727
+ self.skip_newlines()
1728
+ self.expect(TokenType.INDENT)
1729
+
1730
+ # Parse true branch (can be expression or statements)
1731
+ true_expr = self.parse_expression_or_block()
1732
+
1733
+ self.skip_newlines()
1734
+ self.expect(TokenType.DEDENT)
1735
+
1736
+ # Parse else/else-if branch
1737
+ false_expr = None
1738
+ if self.match(TokenType.ELSE):
1739
+ self.advance()
1740
+ self.skip_newlines()
1741
+ if self.match(TokenType.IF):
1742
+ # else if → recurse into another if-expression
1743
+ false_expr = dataclasses.replace(self.parse_if_expression(), chained=True)
1744
+ else:
1745
+ self.expect(TokenType.INDENT)
1746
+ false_expr = self.parse_expression_or_block()
1747
+ self.skip_newlines()
1748
+ self.expect(TokenType.DEDENT)
1749
+
1750
+ return IfExpression(condition=condition, true_expr=true_expr, false_expr=false_expr)
1751
+
1752
+ def parse_switch_expression(self) -> SwitchExpression:
1753
+ """Parse switch expression (returns a value)."""
1754
+ self.expect(TokenType.SWITCH)
1755
+
1756
+ # Optional expression to switch on
1757
+ expr = None
1758
+ if not self.match(TokenType.NEWLINE):
1759
+ expr = self.parse_expression()
1760
+
1761
+ self.skip_newlines()
1762
+ self.expect(TokenType.INDENT)
1763
+
1764
+ cases = []
1765
+ default = None
1766
+
1767
+ while not self.match(TokenType.DEDENT):
1768
+ self.skip_newlines()
1769
+ if self.match(TokenType.DEDENT):
1770
+ break
1771
+
1772
+ # Check for default case (starts with =>)
1773
+ if self.match(TokenType.ARROW):
1774
+ self.advance() # consume =>
1775
+ self.skip_newlines()
1776
+ # Parse default value — may be a multi-statement block
1777
+ if self.match(TokenType.INDENT):
1778
+ self.advance() # consume INDENT
1779
+ default = self.parse_expression_or_block()
1780
+ self.skip_newlines()
1781
+ self.expect(TokenType.DEDENT)
1782
+ else:
1783
+ default = self._comma_separated_arm(self.parse_expression())
1784
+ self.skip_newlines()
1785
+ continue
1786
+
1787
+ # Parse case condition
1788
+ case_condition = self.parse_expression()
1789
+
1790
+ self.expect(TokenType.ARROW)
1791
+ self.skip_newlines()
1792
+
1793
+ # Parse case value — may be a multi-statement block
1794
+ if self.match(TokenType.INDENT):
1795
+ self.advance() # consume INDENT
1796
+ case_value = self.parse_expression_or_block()
1797
+ self.skip_newlines()
1798
+ self.expect(TokenType.DEDENT)
1799
+ else:
1800
+ case_value = self._comma_separated_arm(self.parse_expression())
1801
+
1802
+ cases.append((case_condition, case_value))
1803
+ self.skip_newlines()
1804
+
1805
+ self.expect(TokenType.DEDENT)
1806
+
1807
+ return SwitchExpression(expr=expr, cases=cases, default=default)
1808
+
1809
+ def _comma_separated_arm(self, first):
1810
+ """`=> runtime.error("..."), na`: an arm of several expressions is a block whose last one is its
1811
+ value (PyneComp: the call, then the block result)."""
1812
+ if not self.match(TokenType.COMMA):
1813
+ return first
1814
+ statements = [ExpressionStatement(expr=first, line=getattr(first, 'line', 0))]
1815
+ while self.match(TokenType.COMMA):
1816
+ self.advance()
1817
+ expression = self.parse_expression()
1818
+ statements.append(ExpressionStatement(expr=expression, line=getattr(expression, 'line', 0)))
1819
+ return statements
1820
+
1821
+ def parse_expression_or_block(self) -> Union[Expression, List[Statement]]:
1822
+ """Parse either a single expression or a block of statements.
1823
+ For multi-statement blocks, returns a list of statements where the
1824
+ last expression is the return value.
1825
+ """
1826
+ # Try to parse as expression first
1827
+ start_pos = self.pos
1828
+ try:
1829
+ expr = self.parse_expression()
1830
+ # A loop parsed as an expression is a statement: a branch is either
1831
+ # a plain expression or a list of statements, never a bare loop (card #50)
1832
+ if isinstance(expr, (ForLoop, ForInLoop, WhileLoop)):
1833
+ expr = [expr]
1834
+ # If next is DEDENT, it's a single expression (end of block)
1835
+ if self.match(TokenType.DEDENT):
1836
+ return expr
1837
+ # If next is NEWLINE, check if more statements follow
1838
+ if self.match(TokenType.NEWLINE):
1839
+ self.skip_newlines()
1840
+ # If DEDENT follows the newlines, it's a single expression
1841
+ if self.match(TokenType.DEDENT):
1842
+ return expr
1843
+ # Otherwise there are more statements — fall through to block parse
1844
+ except:
1845
+ pass
1846
+
1847
+ # Otherwise, parse as block
1848
+ self.pos = start_pos
1849
+ return self.parse_block()
1850
+
1851
+
1852
+ def _without_positions(value):
1853
+ """`value` with every line and column 0, as the parser built nodes before
1854
+ card #28: a non-name element of a destructuring target is emitted as its
1855
+ repr, which must not change."""
1856
+ if isinstance(value, list):
1857
+ return [_without_positions(item) for item in value]
1858
+ if isinstance(value, dict):
1859
+ return {key: _without_positions(item) for key, item in value.items()}
1860
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
1861
+ return dataclasses.replace(value, line=0, column=0, **{
1862
+ field.name: _without_positions(getattr(value, field.name))
1863
+ for field in dataclasses.fields(value) if field.name not in ('line', 'column')})
1864
+ return value
1865
+
1866
+
1867
+ def parse(tokens: List[Token]) -> Script:
1868
+ """Convenience function to parse Pine Script tokens into AST.
1869
+
1870
+ A rejected known spelling of valid Pine is repaired, as in transpile() (card #55).
1871
+ """
1872
+ from .parse_repair import parse_tokens
1873
+ return parse_tokens(tokens)