@pineforge/codegen-pyodide 0.7.6 → 0.8.1

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.
@@ -100,12 +100,16 @@ from ..ast_nodes import (
100
100
  )
101
101
  from ..symbols import TypeSpec
102
102
  from .tables import (
103
- SKIP_VAR_TYPES,
103
+ ARRAY_NEW_CTORS,
104
104
  TA_RETURNS_BOOL,
105
105
  TA_TUPLE_FIELDS,
106
106
  MATRIX_RETURNING_METHODS,
107
107
  )
108
108
 
109
+ # Sentinel for "no block-scoped var remap was activated" so an empty dict
110
+ # saved-remap is still distinguishable from the no-op case.
111
+ _NO_BLOCK_REMAP = object()
112
+
109
113
 
110
114
  class StmtVisitor:
111
115
  """Statement-level visitor methods shared across the codegen.
@@ -259,7 +263,7 @@ class StmtVisitor:
259
263
  if is_global_member and isinstance(node.value, FuncCall) and self._is_input_call(node.value):
260
264
  func_name_i, namespace_i = self._resolve_callee(node.value.callee)
261
265
  is_static_global_input = (
262
- func_name_i != "source"
266
+ not self._is_source_input(node.value)
263
267
  and node.name not in self._array_vars
264
268
  and node.name not in getattr(self, "_matrix_specs", {})
265
269
  and node.name not in getattr(self, "_map_vars", {})
@@ -292,7 +296,7 @@ class StmtVisitor:
292
296
  # array.new_float() etc., plus array-returning copy/slice.
293
297
  if isinstance(node.value, FuncCall):
294
298
  func_name, namespace = self._resolve_callee(node.value.callee)
295
- if namespace == "array" and func_name in ("new", "new_float", "new_int", "new_bool", "new_string", "from", "copy", "slice"):
299
+ if namespace == "array" and func_name in ARRAY_NEW_CTORS | {"new", "from", "copy", "slice"}:
296
300
  self._array_vars.add(node.name)
297
301
  spec = self._type_spec_from_expr(node.value) or self._array_spec_for_name(node.name)
298
302
  self._collection_types.setdefault(node.name, spec)
@@ -356,16 +360,19 @@ class StmtVisitor:
356
360
  lines.append(f"{pad}{cpp_type} {safe};")
357
361
  return
358
362
 
359
- # Skip visual function assignments — but still emit declaration for
360
- # table function results since the var may be used later
363
+ # Visual/drawing function assignments (line.new, label.new, box.new,
364
+ # table.new, ...) are no-ops in a backtest, but the assigned variable may
365
+ # still be referenced later (e.g. pushed into an array<line>, or used as a
366
+ # handle by sibling set_* calls). Emit a default-valued local declaration
367
+ # so those references compile; the value is inert. Global members are
368
+ # already declared at class scope, so only locals need this. (Previously
369
+ # only `table` results were declared, which dropped loop-local line/label
370
+ # handles and produced "use of undeclared identifier".)
361
371
  if isinstance(node.value, FuncCall) and self._is_skip_expr(node.value):
362
- func_name, namespace = self._resolve_callee(node.value.callee)
363
- if namespace in SKIP_VAR_TYPES:
364
- # Emit var with default value so references don't fail
365
- if not is_global_member:
366
- cpp_type = self._type_for_decl(node)
367
- default = "0" if cpp_type in ("int", "double") else ('std::string("")' if cpp_type == "std::string" else "false")
368
- lines.append(f"{pad}{cpp_type} {safe} = {default};")
372
+ if not is_global_member:
373
+ cpp_type = self._type_for_decl(node)
374
+ default = "0" if cpp_type in ("int", "double") else ('std::string("")' if cpp_type == "std::string" else "false")
375
+ lines.append(f"{pad}{cpp_type} {safe} = {default};")
369
376
  return
370
377
 
371
378
  # TA call
@@ -399,12 +406,57 @@ class StmtVisitor:
399
406
  self._visit_if_switch_expr(node.value, safe, lines, indent)
400
407
  return
401
408
 
409
+ # UDT lvalue alias (BUG C): a local initialised from a user-defined-UDT
410
+ # var/global lvalue (or a ternary/switch of such lvalues) and then
411
+ # mutated through must ALIAS the global, not value-copy — Pine UDTs are
412
+ # reference types. Emit a C++ reference (non-rebinding) or pointer
413
+ # (rebinding) alias instead of the default copy.
414
+ if not is_global_member:
415
+ alias = self._udt_local_alias_kind(node)
416
+ if alias is not None:
417
+ kind, udt_t = alias
418
+ if kind == "ref":
419
+ cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=udt_t)
420
+ lines.append(f"{pad}{udt_t}& {safe} = {cpp_val};")
421
+ return
422
+ # Pointer alias: take address of each selected lvalue; subsequent
423
+ # field access lowers to ``->`` and rebinds to ``&(other)``.
424
+ self._udt_ptr_alias_locals.add(node.name)
425
+ cpp_val = self._addr_of_udt_selection(node.value, node.name)
426
+ lines.append(f"{pad}{udt_t}* {safe} = {cpp_val};")
427
+ return
428
+
429
+ # Collection lvalue alias (BUG 2): a local bound to an existing array /
430
+ # map / matrix lvalue (or a ternary/switch selecting same-typed ones)
431
+ # and later MUTATED through must ALIAS the member, not value-copy — Pine
432
+ # collections are reference types. Proven: jevondijefferson-big-breakout
433
+ # does ``array<orderBlock> orderBlocks = internal ? internalOrderBlocks
434
+ # : swingOrderBlocks`` then ``orderBlocks.unshift(ob)`` in three helpers;
435
+ # the value-copy left the member arrays empty. Emit a non-rebinding C++
436
+ # reference instead.
437
+ if not is_global_member:
438
+ coll_spec = self._collection_lvalue_selection_spec(node.value)
439
+ if coll_spec is not None and self._collection_local_must_alias(node):
440
+ # Register the local's collection kind so subsequent
441
+ # ``.size()/.get()/.unshift()`` dispatch resolves correctly.
442
+ self._collection_types[node.name] = coll_spec
443
+ if coll_spec.kind == "array":
444
+ self._array_vars.add(node.name)
445
+ elif coll_spec.kind == "map":
446
+ self._map_vars.add(node.name)
447
+ elif coll_spec.kind == "matrix":
448
+ self._matrix_specs[node.name] = coll_spec
449
+ cpp_type = self._type_spec_to_cpp(coll_spec)
450
+ cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type)
451
+ lines.append(f"{pad}{cpp_type}& {safe} = {cpp_val};")
452
+ return
453
+
402
454
  # General declaration
403
- cpp_val = self._visit_expr(node.value)
455
+ cpp_type = self._type_for_decl(node) if not is_global_member else None
456
+ cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type)
404
457
  if is_global_member:
405
458
  lines.append(f"{pad}{safe} = {cpp_val};")
406
459
  else:
407
- cpp_type = self._type_for_decl(node)
408
460
  lines.append(f"{pad}{cpp_type} {safe} = {cpp_val};")
409
461
 
410
462
  @staticmethod
@@ -474,6 +526,12 @@ class StmtVisitor:
474
526
  if self._active_var_remap and safe in self._active_var_remap:
475
527
  safe = self._active_var_remap[safe]
476
528
 
529
+ # Pointer-aliased UDT local (BUG C, rebinding case): ``p := other``
530
+ # rebinds the pointer to the address of the newly selected UDT lvalue.
531
+ if target_name in self._udt_ptr_alias_locals and node.op == ":=":
532
+ lines.append(f"{pad}{safe} = {self._addr_of_udt_selection(node.value, target_name)};")
533
+ return
534
+
477
535
  if target_name in self.ctx.series_vars:
478
536
  val_cpp = self._visit_expr(node.value)
479
537
  if node.op == ":=":
@@ -507,7 +565,7 @@ class StmtVisitor:
507
565
  f"expected {self._type_spec_to_cpp(lhs_spec)}, "
508
566
  f"got {self._type_spec_to_cpp(rhs_spec)}",
509
567
  )
510
- val_cpp = self._visit_expr(node.value)
568
+ val_cpp = self._visit_rhs_value(node.value, target_name)
511
569
  if node.op == ":=":
512
570
  lines.append(f"{pad}{safe} = {val_cpp};")
513
571
  else:
@@ -517,7 +575,7 @@ class StmtVisitor:
517
575
  else:
518
576
  lines.append(f"{pad}{safe} {node.op} {val_cpp};")
519
577
  else:
520
- val_cpp = self._visit_expr(node.value)
578
+ val_cpp = self._visit_rhs_value(node.value, target_name)
521
579
  if node.op == ":=":
522
580
  lines.append(f"{pad}{safe} = {val_cpp};")
523
581
  else:
@@ -542,7 +600,20 @@ class StmtVisitor:
542
600
  if name == "_":
543
601
  continue
544
602
  if i < len(fields):
545
- lines.append(f"{pad}double {name} = {result_var}.{fields[i]};")
603
+ field_expr = f"{result_var}.{fields[i]}"
604
+ # A history-referenced destructured name (e.g.
605
+ # ``[v, dir] = ta.supertrend(...)`` with ``dir[1]`` used
606
+ # later) is tracked in ``series_vars`` and declared as a
607
+ # ``Series<T>`` class member. Pushing into that member keeps
608
+ # its history buffer advancing so ``dir[n]`` resolves; a
609
+ # fresh ``double`` local would shadow the member and make
610
+ # ``dir[n]`` a scalar subscript (clang error). Non-series
611
+ # destructured names keep the plain scalar declaration.
612
+ if name in self.ctx.series_vars:
613
+ safe = self._safe_name(name)
614
+ lines.append(f"{pad}{safe}.push({field_expr});")
615
+ else:
616
+ lines.append(f"{pad}double {name} = {field_expr};")
546
617
  return
547
618
 
548
619
  # User-defined function returning a tuple: use C++17 structured bindings
@@ -585,7 +656,30 @@ class StmtVisitor:
585
656
 
586
657
  lines.append(f"{pad}/* unsupported tuple assignment */")
587
658
 
659
+ def _push_block_var_remap(self, node):
660
+ """Activate block-scoped var renames for ``node`` (BUG 1). Returns the
661
+ previous ``_active_var_remap`` to restore (or ``_NO_BLOCK_REMAP`` if this
662
+ block owns no renames). Renames are MERGED over the inherited remap so
663
+ nested blocks keep any enclosing func-clone / outer-block mapping."""
664
+ renames = self._block_var_renames.get(id(node))
665
+ if not renames:
666
+ return _NO_BLOCK_REMAP
667
+ saved = self._active_var_remap
668
+ self._active_var_remap = {**saved, **renames}
669
+ return saved
670
+
671
+ def _pop_block_var_remap(self, saved) -> None:
672
+ if saved is not _NO_BLOCK_REMAP:
673
+ self._active_var_remap = saved
674
+
588
675
  def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None:
676
+ _blk_saved = self._push_block_var_remap(node)
677
+ try:
678
+ self._visit_if_body(node, lines, indent)
679
+ finally:
680
+ self._pop_block_var_remap(_blk_saved)
681
+
682
+ def _visit_if_body(self, node: IfStmt, lines: list[str], indent: int) -> None:
589
683
  pad = " " * indent
590
684
 
591
685
  # TA hoisting: inside per-call-site function variants, execute ALL
@@ -622,20 +716,73 @@ class StmtVisitor:
622
716
  pad = " " * indent
623
717
  start = self._visit_expr(node.start)
624
718
  end = self._visit_expr(node.end)
625
- step = self._visit_expr(node.step) if node.step else "1"
626
719
  var = node.var # new AST uses .var instead of .var_name
627
- lines.append(f"{pad}for (int {var} = {start}; {var} <= {end}; {var} += {step}) {{")
720
+ if node.step is not None:
721
+ # Explicit `by` step: unchanged from before — ascending compare
722
+ # (matches every existing corpus use, all positive literal steps).
723
+ step = self._visit_expr(node.step)
724
+ lines.append(f"{pad}for (int {var} = {start}; {var} <= {end}; {var} += {step}) {{")
725
+ else:
726
+ # No `by` clause: Pine v6 auto-infers the loop direction from
727
+ # start/end — descending (step -1) when start > end, else
728
+ # ascending (step +1); see the Pine v6 `for` reference. start/end
729
+ # are arbitrary runtime expressions (``for i = array.size(arr)-1
730
+ # to 0`` — a common "iterate backward to safely remove an element
731
+ # while iterating" idiom), so the direction can't always be
732
+ # resolved at codegen time. Compute start/end into locals ONCE
733
+ # (avoids re-evaluating a side-effecting expression, same class
734
+ # of bug as nz()'s double-eval) and pick the comparison direction
735
+ # at runtime from their relative order — this previously always
736
+ # emitted an ascending `<=` loop, which never executes when
737
+ # start > end (silently dropping the whole loop body).
738
+ fid = self._for_counter
739
+ self._for_counter += 1
740
+ s_var, e_var = f"_for_start_{fid}", f"_for_end_{fid}"
741
+ lines.append(f"{pad}int {s_var} = ({start}), {e_var} = ({end});")
742
+ lines.append(
743
+ f"{pad}for (int {var} = {s_var}; "
744
+ f"({s_var} <= {e_var}) ? ({var} <= {e_var}) : ({var} >= {e_var}); "
745
+ f"{var} += ({s_var} <= {e_var}) ? 1 : -1) {{"
746
+ )
628
747
  # Register the loop counter so reads of it inside the body resolve (the
629
748
  # unknown-identifier guard in _visit_ident would otherwise flag it).
630
749
  saved_loop = self._current_loop_vars
631
750
  self._current_loop_vars = set(self._current_loop_vars)
632
751
  if var:
633
752
  self._current_loop_vars.add(var)
634
- for s in node.body:
635
- self._visit_stmt(s, lines, indent + 1)
753
+ _blk_saved = self._push_block_var_remap(node)
754
+ try:
755
+ for s in node.body:
756
+ self._visit_stmt(s, lines, indent + 1)
757
+ finally:
758
+ self._pop_block_var_remap(_blk_saved)
636
759
  self._current_loop_vars = saved_loop
637
760
  lines.append(f"{pad}}}")
638
761
 
762
+ def _loop_elem_is_writeback_udt(self, iterable) -> bool:
763
+ """Whether a ``for x in coll`` loop variable must bind by reference.
764
+
765
+ In Pine a ``for x in arr`` loop variable over an array of *user-defined
766
+ objects* is a reference to the element — field writes (``x.f := v``)
767
+ mutate the array in place — whereas over a primitive array it is a
768
+ copy. So emit C++ ``auto&`` only for arrays whose element is a
769
+ user-defined UDT struct. Primitive elements keep ``auto`` (Pine copy
770
+ semantics: writing the loop var must NOT write back). Drawing handles
771
+ (line/box/label/linefill/...) also keep ``auto``: their element type
772
+ name is a builtin, not in ``_udt_defs``, and a handle copy already
773
+ mutates the shared engine object. (Reassigning the loop var itself —
774
+ ``x := ...`` — is not modelled by either form, but Pine forbids it for
775
+ objects in practice and it does not occur in the corpus.)
776
+ """
777
+ spec = self._type_spec_from_expr(iterable)
778
+ return (
779
+ spec is not None
780
+ and spec.kind == "array"
781
+ and spec.element is not None
782
+ and spec.element.kind == "udt"
783
+ and spec.element.name in self._udt_defs
784
+ )
785
+
639
786
  def _visit_for_in(self, node, lines: list[str], indent: int) -> None:
640
787
  pad = " " * indent
641
788
  iterable = self._visit_expr(node.iterable)
@@ -649,12 +796,17 @@ class StmtVisitor:
649
796
  self._current_loop_vars.add(v)
650
797
  if node.var:
651
798
  v_cpp = self._safe_name(node.var)
652
- lines.append(f"{pad}for (auto {v_cpp} : {iterable}) {{")
799
+ ref = "&" if self._loop_elem_is_writeback_udt(node.iterable) else ""
800
+ lines.append(f"{pad}for (auto{ref} {v_cpp} : {iterable}) {{")
653
801
  elif node.vars:
654
802
  bindings = ", ".join(node.vars)
655
803
  lines.append(f"{pad}for (auto [{bindings}] : {iterable}) {{")
656
- for s in node.body:
657
- self._visit_stmt(s, lines, indent + 1)
804
+ _blk_saved = self._push_block_var_remap(node)
805
+ try:
806
+ for s in node.body:
807
+ self._visit_stmt(s, lines, indent + 1)
808
+ finally:
809
+ self._pop_block_var_remap(_blk_saved)
658
810
  lines.append(f"{pad}}}")
659
811
  self._current_loop_vars = saved_loop
660
812
 
@@ -662,8 +814,12 @@ class StmtVisitor:
662
814
  pad = " " * indent
663
815
  cond = self._visit_expr(node.condition)
664
816
  lines.append(f"{pad}while ({cond}) {{")
665
- for s in node.body:
666
- self._visit_stmt(s, lines, indent + 1)
817
+ _blk_saved = self._push_block_var_remap(node)
818
+ try:
819
+ for s in node.body:
820
+ self._visit_stmt(s, lines, indent + 1)
821
+ finally:
822
+ self._pop_block_var_remap(_blk_saved)
667
823
  lines.append(f"{pad}}}")
668
824
 
669
825
  def _visit_switch(self, node: SwitchStmt, lines: list[str], indent: int) -> None:
@@ -712,6 +868,12 @@ class StmtVisitor:
712
868
  # Check if it's a skip expr
713
869
  if self._is_skip_expr(stmt.expr):
714
870
  return
871
+ # A void drawing setter / delete / visual-noop cannot be the
872
+ # branch's value (it lowers to a void C++ call) — emit it as
873
+ # a statement and leave ``target`` at its default.
874
+ if self._call_is_void(stmt.expr):
875
+ self._visit_stmt(stmt, lines, indent)
876
+ return
715
877
  cpp = self._visit_expr(stmt.expr)
716
878
  pad = " " * indent
717
879
  lines.append(f"{pad}{target} = {cpp};")
@@ -273,9 +273,16 @@ class Lexer:
273
273
  self._read_token()
274
274
  if not self._at_end() and self.source[self.pos] == "\n":
275
275
  self._advance()
276
- # If parens closed on this line, emit NEWLINE so parser sees end of statement
276
+ # If parens closed on this line, end the statement UNLESS the line
277
+ # ends with a continuation token (e.g. trailing operator), in which
278
+ # case the next line continues the same logical line.
277
279
  if self.paren_depth == 0 and emitted_in_parens:
278
- self._emit(TokenType.NEWLINE, "\\n", self.line - 1, self.col)
280
+ last_token = self.tokens[-1] if self.tokens else None
281
+ if last_token and last_token.type in self.CONTINUATION_TOKENS:
282
+ self._in_continuation = True
283
+ else:
284
+ self._in_continuation = False
285
+ self._emit(TokenType.NEWLINE, "\\n", self.line - 1, self.col)
279
286
  return
280
287
 
281
288
  # Indentation handling
@@ -290,9 +297,24 @@ class Lexer:
290
297
  else:
291
298
  indent_level = len(raw) // 4
292
299
 
300
+ # Operator-first line continuation: when a line *begins* with a binary
301
+ # / ternary operator that cannot start a statement (e.g. ``? x``,
302
+ # ``: y``, ``+ z``, ``and w``), it continues the previous logical line
303
+ # even though that line did not *end* with an operator (the break was
304
+ # placed before the operator instead of after it). Suppress this line's
305
+ # INDENT/DEDENT and drop the NEWLINE that ended the prior line so the
306
+ # parser sees one contiguous expression. Only applies outside parens
307
+ # and when not already in an end-of-line continuation.
308
+ starts_with_cont_op = (
309
+ not self._in_continuation
310
+ and self._line_starts_with_continuation_op()
311
+ )
312
+ if starts_with_cont_op and self.tokens and self.tokens[-1].type == TokenType.NEWLINE:
313
+ self.tokens.pop()
314
+
293
315
  # If we're in a continuation (previous line ended with an operator),
294
316
  # suppress INDENT/DEDENT — the indentation is cosmetic, not structural
295
- if not self._in_continuation:
317
+ if not self._in_continuation and not starts_with_cont_op:
296
318
  current_indent = self.indent_stack[-1]
297
319
  if indent_level > current_indent:
298
320
  self.indent_stack.append(indent_level)
@@ -329,6 +351,49 @@ class Lexer:
329
351
  else:
330
352
  self._in_continuation = False
331
353
 
354
+ def _line_starts_with_continuation_op(self) -> bool:
355
+ """True when the upcoming line content begins with a binary/ternary
356
+ operator that can never begin a statement, so the line is a
357
+ continuation of the previous logical line.
358
+
359
+ Called with ``self.pos`` positioned at the first non-whitespace
360
+ character of the line. Deliberately conservative: ``-`` is excluded
361
+ (ambiguous leading unary minus) and ``.`` is excluded (``.5`` is a
362
+ leading-dot number, not member access). The included operators
363
+ (``? : + * / % == != > < >= <= and or``) cannot legally start a Pine
364
+ statement, so suppressing the line break for them never merges two
365
+ independent statements that previously parsed."""
366
+ src = self.source
367
+ p = self.pos
368
+ n = len(src)
369
+ if p >= n:
370
+ return False
371
+ c = src[p]
372
+ c2 = src[p + 1] if p + 1 < n else ""
373
+ # Two-character comparison operators.
374
+ if c2 == "=" and c in ("=", "!", ">", "<"):
375
+ return True
376
+ # ':' ternary-else continuation, but not ':=' (reassignment).
377
+ if c == ":" and c2 != "=":
378
+ return True
379
+ # Single-character operators that cannot start a statement.
380
+ if c in "?+*%><":
381
+ return True
382
+ # '/' division continuation, but never '//' (comment).
383
+ if c == "/" and c2 != "/":
384
+ return True
385
+ # 'and' / 'or' keyword continuation (require a word boundary so names
386
+ # like ``android`` / ``organic`` are not misread).
387
+ def _kw(word: str) -> bool:
388
+ end = p + len(word)
389
+ if src[p:end] != word:
390
+ return False
391
+ nxt = src[end] if end < n else ""
392
+ return not (nxt.isalnum() or nxt == "_")
393
+ if _kw("and") or _kw("or"):
394
+ return True
395
+ return False
396
+
332
397
  def _advance_to(self, target: int) -> None:
333
398
  while self.pos < target and self.pos < len(self.source):
334
399
  self._advance()
@@ -197,6 +197,13 @@ class Parser:
197
197
  # Check that the IDENT is followed by = (not == ) to confirm declaration
198
198
  if self._peek(2).type == TokenType.EQUALS:
199
199
  return self._parse_typed_decl()
200
+ # Postfix-array type-annotated declaration: float[] x = ..., int[] x = ...
201
+ if (cur.type in TYPE_KEYWORDS
202
+ and self._peek().type == TokenType.LBRACKET
203
+ and self._peek(2).type == TokenType.RBRACKET
204
+ and self._peek(3).type == TokenType.IDENT
205
+ and self._peek(4).type == TokenType.EQUALS):
206
+ return self._parse_typed_decl()
200
207
 
201
208
  # IDENT-prefixed type-annotated declaration: ``Sample s = ...``,
202
209
  # ``array<Sample> arr = ...``, ``matrix<float> m = ...`` — when the
@@ -393,35 +400,42 @@ class Parser:
393
400
  return self._set_loc(node, start_tok)
394
401
 
395
402
  def _parse_type_hint_string(self) -> str:
396
- """Parse primitive, UDT, array<T>, or map<K,V> type hints."""
403
+ """Parse primitive, UDT, array<T>, map<K,V>, or postfix-array (``T[]``) hints."""
397
404
  base = self._advance().value
398
- if not self._check(TokenType.LT):
399
- return base
400
-
401
- parts: list[str] = []
402
- depth = 0
403
- self._advance() # <
404
- while not self._at_end():
405
- tok = self._current()
406
- if tok.type == TokenType.LT:
407
- depth += 1
408
- parts.append("<")
409
- self._advance()
410
- continue
411
- if tok.type == TokenType.GT:
412
- if depth == 0:
405
+ if self._check(TokenType.LT):
406
+ parts: list[str] = []
407
+ depth = 0
408
+ self._advance() # <
409
+ while not self._at_end():
410
+ tok = self._current()
411
+ if tok.type == TokenType.LT:
412
+ depth += 1
413
+ parts.append("<")
413
414
  self._advance()
414
- break
415
- depth -= 1
416
- parts.append(">")
415
+ continue
416
+ if tok.type == TokenType.GT:
417
+ if depth == 0:
418
+ self._advance()
419
+ break
420
+ depth -= 1
421
+ parts.append(">")
422
+ self._advance()
423
+ continue
424
+ if tok.type == TokenType.COMMA:
425
+ parts.append(",")
426
+ else:
427
+ parts.append(str(tok.value))
417
428
  self._advance()
418
- continue
419
- if tok.type == TokenType.COMMA:
420
- parts.append(",")
421
- else:
422
- parts.append(str(tok.value))
423
- self._advance()
424
- return f"{base}<{''.join(parts)}>"
429
+ base = f"{base}<{''.join(parts)}>"
430
+
431
+ # Pine postfix-array shorthand: `float[]` == `array<float>`, `T[]` == `array<T>`.
432
+ # Without this the trailing `[ ]` is left unconsumed, the following name
433
+ # fails to parse, and the whole declaration is silently dropped.
434
+ while self._check(TokenType.LBRACKET) and self._peek().type == TokenType.RBRACKET:
435
+ self._advance() # [
436
+ self._advance() # ]
437
+ base = f"array<{base}>"
438
+ return base
425
439
 
426
440
  def _parse_template_args(self) -> list[str]:
427
441
  """Parse and return generic args after a member name, e.g. new<K,V>()."""
@@ -487,6 +501,16 @@ class Parser:
487
501
  and self._current().value not in ("na",)):
488
502
  # Complex type: array<float>, table, etc.
489
503
  type_hint = self._parse_type_hint_string()
504
+ elif (self._current().type == TokenType.IDENT
505
+ and self._peek().type == TokenType.LBRACKET
506
+ and self._peek(2).type == TokenType.RBRACKET):
507
+ # Postfix-array of a non-primitive / UDT element type, e.g.
508
+ # ``var line[] lines = ...`` or ``var store[] xs = ...``. The
509
+ # empty ``[]`` can only form a type here (a subscript index would
510
+ # be non-empty), so this is unambiguously ``array<T>``. Without
511
+ # this branch the ``[]`` is left unconsumed, the name fails to
512
+ # parse, and the whole declaration is silently dropped.
513
+ type_hint = self._parse_type_hint_string()
490
514
 
491
515
  name_tok = self._consume(TokenType.IDENT)
492
516
  self._consume(TokenType.EQUALS)
@@ -552,39 +576,64 @@ class Parser:
552
576
 
553
577
  # -- Function definition --
554
578
 
579
+ def _parse_param_type_annotation(self) -> str | None:
580
+ """Consume an optional function-parameter type annotation and return it
581
+ as a canonical hint string ('float', 'string', 'array<line>', 'pivot',
582
+ 'chart.point', ...), or ``None`` for a bare untyped param.
583
+
584
+ Leaves the parser positioned at the parameter name. Supports the Pine
585
+ qualifier prefixes (``series`` / ``simple`` / ``const`` — consumed but
586
+ not part of the C++ type), builtin types, user-defined / drawing type
587
+ names, and the ``T[]`` postfix-array shorthand (normalised to
588
+ ``array<T>`` by ``_parse_type_hint_string``).
589
+ """
590
+ TYPE_TOKENS = {TokenType.TYPE_INT, TokenType.TYPE_FLOAT,
591
+ TokenType.TYPE_BOOL, TokenType.TYPE_STRING}
592
+ # Optional qualifiers — they do not affect the C++ param type.
593
+ while self._check(TokenType.IDENT) and self._current().value in (
594
+ "series", "simple", "const",
595
+ ):
596
+ self._advance()
597
+ # Is there a type annotation before the parameter name? A builtin type
598
+ # token always is; an IDENT is a type only if followed by another IDENT
599
+ # (``line ln``), by ``[`` (``line[] arr``), or by ``<`` (the generic
600
+ # collection syntax ``array<float> xs`` / ``matrix<float> m`` /
601
+ # ``map<string,float> mp``). Without the ``<`` case the generic type is
602
+ # mis-consumed as the parameter name and the whole function definition
603
+ # silently fails to parse (its body leaks to top-level scope).
604
+ has_type = False
605
+ if self._current().type in TYPE_TOKENS:
606
+ has_type = True
607
+ elif self._check(TokenType.IDENT):
608
+ nxt = self._peek().type
609
+ if nxt in (TokenType.IDENT, TokenType.LBRACKET, TokenType.LT):
610
+ has_type = True
611
+ if not has_type:
612
+ return None
613
+ return self._parse_type_hint_string()
614
+
555
615
  def _parse_func_def(self) -> FuncDef:
556
616
  """Parse: name(param1, param2) => expr_or_block"""
557
617
  start_tok = self._current()
558
618
  name = self._consume(TokenType.IDENT).value
559
619
  self._consume(TokenType.LPAREN)
560
- TYPE_TOKENS = {TokenType.TYPE_INT, TokenType.TYPE_FLOAT,
561
- TokenType.TYPE_BOOL, TokenType.TYPE_STRING}
562
620
  params = []
621
+ param_type_hints: list = []
622
+ param_defaults: list = []
563
623
  while not self._check(TokenType.RPAREN):
564
- # Pine: series float x / series int x — one parameter (not "series" + "x")
565
- if self._check(TokenType.IDENT) and self._current().value == "series":
566
- self._advance() # consume 'series'
567
- if self._current().type in TYPE_TOKENS:
568
- self._advance() # float, int, ...
569
- param_name = self._consume(TokenType.IDENT).value
570
- if self._check(TokenType.EQUALS):
571
- self._advance()
572
- self._parse_expression()
573
- params.append(param_name)
574
- self._match(TokenType.COMMA)
575
- continue
576
- # Handle optional type annotation: type param (e.g., int len, float src)
577
- if self._current().type in TYPE_TOKENS:
578
- self._advance() # skip the type annotation
624
+ # Consume the optional type annotation (builtin / user / drawing /
625
+ # ``T[]``), returning the canonical hint string. Handles ``float[] arr``,
626
+ # ``line[] ln``, ``color c``, ``SDZone z``, ``string tf``, as well as
627
+ # the untyped bare-name case.
628
+ hint = self._parse_param_type_annotation()
579
629
  param_name = self._consume(TokenType.IDENT).value
580
- if self._check(TokenType.IDENT):
581
- # 'param_name' was actually a type name parsed as IDENT, next is real name
582
- param_name = self._consume(TokenType.IDENT).value
583
- # Skip default value: param = expr
630
+ pdefault = None
584
631
  if self._check(TokenType.EQUALS):
585
632
  self._advance() # consume '='
586
- self._parse_expression() # consume default value (discarded)
633
+ pdefault = self._parse_expression() # default value
587
634
  params.append(param_name)
635
+ param_type_hints.append(hint)
636
+ param_defaults.append(pdefault)
588
637
  self._match(TokenType.COMMA)
589
638
  self._consume(TokenType.RPAREN)
590
639
  self._skip_newlines()
@@ -601,6 +650,13 @@ class Parser:
601
650
  expr = self._parse_expression()
602
651
  node = FuncDef(name=name, params=params, body=[ExprStmt(expr=expr)], is_single_expr=True)
603
652
 
653
+ # Record per-param type hints + defaults (mirrors _parse_method_def) so
654
+ # the analyzer can type UDT/string/array params and the codegen can emit
655
+ # them with the correct C++ type (``pivot hi``, ``std::string s``).
656
+ node.annotations = {
657
+ "param_type_hints": param_type_hints,
658
+ "param_defaults": param_defaults,
659
+ }
604
660
  return self._set_loc(node, start_tok)
605
661
 
606
662
  def _parse_type_or_enum_decl(self):
@@ -677,7 +733,8 @@ class Parser:
677
733
  if self._current().type in TYPE_KEYWORDS:
678
734
  param_type = self._parse_type_hint_string()
679
735
  elif (self._current().type == TokenType.IDENT
680
- and self._peek().type == TokenType.IDENT):
736
+ and self._peek().type in (TokenType.IDENT, TokenType.LBRACKET, TokenType.LT)):
737
+ # ``line ln`` / ``float[] arr`` / ``array<float> xs`` typed param.
681
738
  param_type = self._parse_type_hint_string()
682
739
  p = self._consume(TokenType.IDENT).value
683
740
  pdefault = None