@pineforge/codegen-pyodide 0.8.0 → 0.9.0

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.
@@ -111,6 +111,8 @@ from .tables import (
111
111
  COLOR_CONST_MAP,
112
112
  DAYOFWEEK_MAP,
113
113
  DISPLAY_MAP,
114
+ DRAWING_STYLE_NS,
115
+ DRAWING_TYPE_TO_CPP,
114
116
  ON_OFF_INHERIT_MAP,
115
117
  ORDER_DIRECTION_MAP,
116
118
  SKIP_NAMESPACES,
@@ -209,6 +211,44 @@ class ExprVisitor:
209
211
  return f"std::make_tuple({elems})"
210
212
  return "/* unknown */"
211
213
 
214
+ # ------------------------------------------------------------------
215
+ # Target-typed RHS lowering (drawing handles are C++ structs, not doubles)
216
+ # ------------------------------------------------------------------
217
+ def _is_na_expr(self, node) -> bool:
218
+ """True for a bare ``na`` (keyword NaLiteral or ``na`` identifier)."""
219
+ return (isinstance(node, NaLiteral)
220
+ or (isinstance(node, Identifier) and node.name == "na"))
221
+
222
+ def _drawing_na_default(self, target_name: str | None) -> str | None:
223
+ """If ``target_name`` is a drawing-handle variable (line/box/label/
224
+ linefill/chart.point), return its na default literal (e.g. ``Box{}`` —
225
+ a default-constructed handle whose id == -1 == na); otherwise None."""
226
+ if not target_name:
227
+ return None
228
+ udt = self._udt_var_types.get(target_name)
229
+ if udt in DRAWING_TYPE_TO_CPP:
230
+ return f"{DRAWING_TYPE_TO_CPP[udt]}{{}}"
231
+ return None
232
+
233
+ def _visit_rhs_value(self, value_node, target_name: str | None = None,
234
+ target_cpp_type: str | None = None) -> str:
235
+ """Visit an assignment / declaration RHS.
236
+
237
+ A bare ``na`` lowers to a type-appropriate initializer for the target
238
+ instead of ``na<double>()``: drawing handles brace-init to their na
239
+ handle (``Box{}`` / ``Line{}`` / …); ``std::string``/``int``/``int64_t``/
240
+ ``bool`` use ``na<T>()``. Without this, ``Box b = na;`` and
241
+ ``string s = na;`` would both emit ``na<double>()`` and fail to compile
242
+ (no viable ``operator=`` / conversion). Every other RHS lowers unchanged.
243
+ """
244
+ if target_name and self._is_na_expr(value_node):
245
+ draw_default = self._drawing_na_default(target_name)
246
+ if draw_default is not None:
247
+ return draw_default
248
+ if target_cpp_type in ("std::string", "int", "int64_t", "bool"):
249
+ return f"na<{target_cpp_type}>()"
250
+ return self._visit_expr(value_node)
251
+
212
252
  def _visit_ident(self, node: Identifier) -> str:
213
253
  name = node.name
214
254
  # Bare 'na' identifier → na<double>()
@@ -235,6 +275,16 @@ class ExprVisitor:
235
275
  return str(val)
236
276
  if isinstance(val, str):
237
277
  return f'std::string("{val}")'
278
+ # TA runtime-reset lowering: an input-backed var renders as its
279
+ # override-aware getter (not the member name), because the reset may
280
+ # run before the input members are initialised (evaluate_security path).
281
+ if (self._reset_input_getter_mode
282
+ and name in self._input_backed_vars
283
+ and name in self._input_var_to_call):
284
+ call_node = self._input_var_to_call[name]
285
+ func_name_i, namespace_i = self._resolve_callee(call_node.callee)
286
+ title = self._get_input_title(call_node, var_name=name)
287
+ return self._render_input_value(call_node, func_name_i, namespace_i, title)
238
288
  # Pine type name `color` used as a value (no variable) → int64 color constant.
239
289
  # Params handled above; symbol table does not retain function locals after analysis.
240
290
  if name == "color":
@@ -333,7 +383,12 @@ class ExprVisitor:
333
383
  if node.member == "position_size":
334
384
  return "signed_position_size()"
335
385
  if node.member == "position_avg_price":
336
- return "position_entry_price_"
386
+ # Pine returns `na` when the position is flat
387
+ # (position_size == 0); the engine field is 0.0 when flat.
388
+ # Guard so the common `na(strategy.position_avg_price)`
389
+ # flat/in-position idiom is not silently defeated.
390
+ return ("(signed_position_size() == 0.0 ? na<double>() "
391
+ ": position_entry_price_)")
337
392
  if node.member == "position_entry_name":
338
393
  return "position_entry_name()"
339
394
  # Trade counts
@@ -438,13 +493,34 @@ class ExprVisitor:
438
493
  (node.member in TA_IMPLICIT_COMPUTE_FULL and node.member in TA_COMPUTE_ARGS and TA_COMPUTE_ARGS[node.member] == [])
439
494
  or node.member == "vwap"
440
495
  ):
441
- # Find the matching call site
442
- for site in self.ctx.ta_call_sites:
496
+ # Find the matching call site. Skip sites pruned as dead
497
+ # code (their owner function is never called): a dead site's
498
+ # member declaration is never emitted (see base.py
499
+ # ``_dead_ta_indices`` / emit_top.py member-decl guard), so
500
+ # binding a LIVE bare property read to one references an
501
+ # undeclared member. Regression: nightowlxtrader-azt — a live
502
+ # ``ta.vwap`` (top-level + inside request.security) resolved
503
+ # to dead ``f5``'s first-in-order ``_ta_vwap_10`` and emitted
504
+ # ``use of undeclared identifier '_ta_vwap_10'``. A live read
505
+ # must bind to a live site.
506
+ for _i, site in enumerate(self.ctx.ta_call_sites):
507
+ if _i in self._dead_ta_indices:
508
+ continue
443
509
  ta_short = site.class_name.split("::")[-1].lower()
444
510
  if site.member_name.startswith(f"_ta_{node.member}_"):
445
511
  if node.member == "vwap":
446
- return f"(is_first_tick_ ? {site.member_name}.compute(current_bar_.close, current_bar_.volume, current_bar_.timestamp) : {site.member_name}.recompute(current_bar_.close, current_bar_.volume, current_bar_.timestamp))"
447
- return f"(is_first_tick_ ? {site.member_name}.compute({TA_IMPLICIT_COMPUTE_FULL[node.member]}) : {site.member_name}.recompute({TA_IMPLICIT_COMPUTE_FULL[node.member]}))"
512
+ return (
513
+ f"(history_advances_new_bar() ? {site.member_name}.compute("
514
+ "current_bar_.close, current_bar_.volume, current_bar_.timestamp) "
515
+ f": {site.member_name}.recompute(current_bar_.close, "
516
+ "current_bar_.volume, current_bar_.timestamp))"
517
+ )
518
+ return (
519
+ f"(history_advances_new_bar() ? {site.member_name}.compute("
520
+ f"{TA_IMPLICIT_COMPUTE_FULL[node.member]}) : "
521
+ f"{site.member_name}.recompute("
522
+ f"{TA_IMPLICIT_COMPUTE_FULL[node.member]}))"
523
+ )
448
524
  # No registered call site for this TA property read —
449
525
  # the old fallback emitted std::string("<name>"), a
450
526
  # silent type mismatch. Reject loudly instead.
@@ -589,6 +665,14 @@ class ExprVisitor:
589
665
  if node.member in COLOR_CONST_MAP:
590
666
  return COLOR_CONST_MAP[node.member]
591
667
  return "0"
668
+ # Drawing style/visual CONSTANT member reads (line.style_solid,
669
+ # box.style_dashed, label.style_label_left, ...). These namespaces
670
+ # left SKIP_NAMESPACES (their FuncCall forms now route to the arena),
671
+ # but their constant members only ever feed dropped visual kwargs, so
672
+ # they still lower to "0". The function names arrive as FuncCalls
673
+ # (visit_call), never here. See spec §4.4.
674
+ if ns in DRAWING_STYLE_NS:
675
+ return "0"
592
676
  if ns in SKIP_NAMESPACES:
593
677
  return "0"
594
678
  if ns == "currency":
@@ -660,10 +744,16 @@ class ExprVisitor:
660
744
  or name in self._var_names
661
745
  or name in self._current_loop_vars
662
746
  or name in self._current_func_param_types
747
+ or name in self._current_func_locals
748
+ or name in self._udt_var_types
663
749
  ):
664
750
  safe = self._safe_name(name)
665
751
  if self._active_var_remap and safe in self._active_var_remap:
666
752
  safe = self._active_var_remap[safe]
753
+ # Pointer-aliased UDT local (BUG C, rebinding case): field access
754
+ # goes through ``->`` since the local holds ``UDT*``.
755
+ if name in self._udt_ptr_alias_locals:
756
+ return f"{safe}->{node.member}"
667
757
  return f"{safe}.{node.member}"
668
758
  if name not in self.ctx.series_vars:
669
759
  # Unknown identifier — likely an enum value
@@ -675,6 +765,18 @@ class ExprVisitor:
675
765
  right = self._visit_expr(node.right)
676
766
  cpp_ops = {"and": "&&", "or": "||"}
677
767
  op = cpp_ops.get(node.op, node.op)
768
+ if node.op == "+":
769
+ lt = self._infer_type(node.left)
770
+ rt = self._infer_type(node.right)
771
+ if lt == "std::string" or rt == "std::string":
772
+ def _as_string(rendered, inferred):
773
+ if inferred == "std::string":
774
+ return rendered
775
+ if inferred == "bool":
776
+ return f'(({rendered}) ? std::string("true") : std::string("false"))'
777
+ return f"std::to_string({rendered})"
778
+
779
+ return f"({_as_string(left, lt)} + {_as_string(right, rt)})"
678
780
  # PineScript % works on floats — use std::fmod in C++
679
781
  if node.op == "%":
680
782
  return f"std::fmod((double)({left}), (double)({right}))"
@@ -730,26 +832,26 @@ class ExprVisitor:
730
832
  # ``ta.highest(high, 10)[1]`` or ``f()[2]``. In Pine the call yields a
731
833
  # series, so ``[k]`` reads its value k bars ago — but the call lowers to
732
834
  # a freshly-computed C++ scalar, and ``scalar[k]`` is not subscriptable.
733
- # Materialize the result into a self-contained history buffer: a static
835
+ # Materialize the result into a checkpoint-owned class-member
734
836
  # ``Series<T>`` that pushes (new bar) / updates (intrabar) the value
735
837
  # exactly once per evaluation — same semantics as every other series in
736
838
  # the strategy — and read ``[k]`` off it. The inner call is emitted once
737
- # so its own stateful indicator is not double-stepped, and the buffer
738
- # clears itself on run-start (``is_first_tick_ && bar_index_ == 0``) so a
739
- # reused strategy handle (parameter sweep) does not leak prior-run history.
839
+ # so its own stateful indicator is not double-stepped. The deterministic
840
+ # member identity includes the emitted UDF variant; separate Pine call
841
+ # sites never share history, and on_bar clears every synthetic member at
842
+ # run-start even when this particular expression is conditional.
740
843
  if isinstance(node.object, FuncCall):
741
844
  inner = self._visit_expr(node.object)
742
845
  cpp_t = self._infer_type(node.object)
743
846
  if cpp_t not in ("double", "int", "bool"):
744
847
  cpp_t = "double"
848
+ member = self._inline_history_member("hist_call", node)
745
849
  return (
746
850
  f"([&]() -> {cpp_t} {{ "
747
- f"static thread_local Series<{cpp_t}> _hist_call; "
748
- f"if (is_first_tick_ && bar_index_ == 0) _hist_call.clear(); "
749
851
  f"{cpp_t} _hv = ({inner}); "
750
- f"if (is_first_tick_) _hist_call.push(_hv); "
751
- f"else _hist_call.update(_hv); "
752
- f"return _hist_call[(int)({idx})]; }}())"
852
+ f"if (history_advances_new_bar()) {member}.push(_hv); "
853
+ f"else {member}.update(_hv); "
854
+ f"return {member}[(int)({idx})]; }}())"
753
855
  )
754
856
  obj = self._visit_expr(node.object)
755
857
  # If subscripting a non-series variable (e.g., function parameter),
@@ -100,11 +100,16 @@ from ..ast_nodes import (
100
100
  )
101
101
  from ..symbols import TypeSpec
102
102
  from .tables import (
103
+ ARRAY_NEW_CTORS,
103
104
  TA_RETURNS_BOOL,
104
105
  TA_TUPLE_FIELDS,
105
106
  MATRIX_RETURNING_METHODS,
106
107
  )
107
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
+
108
113
 
109
114
  class StmtVisitor:
110
115
  """Statement-level visitor methods shared across the codegen.
@@ -253,6 +258,10 @@ class StmtVisitor:
253
258
  # Global-scope non-var vars are class members — emit assignment, not declaration
254
259
  is_global_member = node.name in self._global_member_vars
255
260
 
261
+ def remember_local_type(cpp_type: str | None) -> None:
262
+ if cpp_type and not is_global_member:
263
+ self._current_func_local_types[node.name] = cpp_type
264
+
256
265
  # Check if it is a static (non-series) global member variable already evaluated inside _inputs_initialized_ block
257
266
  is_static_global_input = False
258
267
  if is_global_member and isinstance(node.value, FuncCall) and self._is_input_call(node.value):
@@ -279,7 +288,7 @@ class StmtVisitor:
279
288
  title = self._get_input_title(node.value, var_name=node.name)
280
289
  cpp_val = self._render_input_value(node.value, func_name_i, namespace_i, title)
281
290
  if node.name in self.ctx.series_vars:
282
- lines.append(f"{pad}{safe}.push({cpp_val});")
291
+ self._emit_history_series_write(lines, pad, safe, cpp_val)
283
292
  elif is_global_member:
284
293
  lines.append(f"{pad}{safe} = {cpp_val};")
285
294
  else:
@@ -291,7 +300,7 @@ class StmtVisitor:
291
300
  # array.new_float() etc., plus array-returning copy/slice.
292
301
  if isinstance(node.value, FuncCall):
293
302
  func_name, namespace = self._resolve_callee(node.value.callee)
294
- if namespace == "array" and func_name in ("new", "new_float", "new_int", "new_bool", "new_string", "from", "copy", "slice"):
303
+ if namespace == "array" and func_name in ARRAY_NEW_CTORS | {"new", "from", "copy", "slice"}:
295
304
  self._array_vars.add(node.name)
296
305
  spec = self._type_spec_from_expr(node.value) or self._array_spec_for_name(node.name)
297
306
  self._collection_types.setdefault(node.name, spec)
@@ -376,9 +385,12 @@ class StmtVisitor:
376
385
  compute_args = self._ta_compute_args_for_site(site)
377
386
  ret_type = "bool" if self._ta_name_from_site(site) in TA_RETURNS_BOOL else "double"
378
387
  ta_name = self._ta_member_name(site)
379
- ta_expr = f"(is_first_tick_ ? {ta_name}.compute({compute_args}) : {ta_name}.recompute({compute_args}))"
388
+ ta_expr = (
389
+ f"(history_advances_new_bar() ? {ta_name}.compute({compute_args}) "
390
+ f": {ta_name}.recompute({compute_args}))"
391
+ )
380
392
  if node.name in self.ctx.series_vars:
381
- lines.append(f"{pad}{safe}.push({ta_expr});")
393
+ self._emit_history_series_write(lines, pad, safe, ta_expr)
382
394
  elif is_global_member:
383
395
  lines.append(f"{pad}{safe} = {ta_expr};")
384
396
  else:
@@ -388,7 +400,7 @@ class StmtVisitor:
388
400
  # Non-var series variable — push instead of declare
389
401
  if node.name in self.ctx.series_vars:
390
402
  cpp_val = self._visit_expr(node.value)
391
- lines.append(f"{pad}{safe}.push({cpp_val});")
403
+ self._emit_history_series_write(lines, pad, safe, cpp_val)
392
404
  return
393
405
 
394
406
  # If/switch expression: x = if cond ... else ...
@@ -401,12 +413,74 @@ class StmtVisitor:
401
413
  self._visit_if_switch_expr(node.value, safe, lines, indent)
402
414
  return
403
415
 
416
+ # UDT lvalue alias (BUG C): a local initialised from a user-defined-UDT
417
+ # var/global lvalue (or a ternary/switch of such lvalues) and then
418
+ # mutated through must ALIAS the global, not value-copy — Pine UDTs are
419
+ # reference types. Emit a C++ reference (non-rebinding) or pointer
420
+ # (rebinding) alias instead of the default copy.
421
+ if not is_global_member:
422
+ alias = self._udt_local_alias_kind(node)
423
+ if alias is not None:
424
+ kind, udt_t = alias
425
+ if kind == "ref":
426
+ cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=udt_t)
427
+ lines.append(f"{pad}{udt_t}& {safe} = {cpp_val};")
428
+ return
429
+ # Pointer alias: take address of each selected lvalue; subsequent
430
+ # field access lowers to ``->`` and rebinds to ``&(other)``.
431
+ self._udt_ptr_alias_locals.add(node.name)
432
+ cpp_val = self._addr_of_udt_selection(node.value, node.name)
433
+ lines.append(f"{pad}{udt_t}* {safe} = {cpp_val};")
434
+ return
435
+
436
+ # Collection lvalue alias (BUG 2): a local bound to an existing array /
437
+ # map / matrix lvalue (or a ternary/switch selecting same-typed ones)
438
+ # and later MUTATED through must ALIAS the member, not value-copy — Pine
439
+ # collections are reference types. Proven: jevondijefferson-big-breakout
440
+ # does ``array<orderBlock> orderBlocks = internal ? internalOrderBlocks
441
+ # : swingOrderBlocks`` then ``orderBlocks.unshift(ob)`` in three helpers;
442
+ # the value-copy left the member arrays empty. Emit a non-rebinding C++
443
+ # reference instead.
444
+ if not is_global_member:
445
+ coll_spec = self._collection_lvalue_selection_spec(node.value)
446
+ if coll_spec is not None and self._collection_local_must_alias(node):
447
+ # Register the local's collection kind so subsequent
448
+ # ``.size()/.get()/.unshift()`` dispatch resolves correctly.
449
+ self._collection_types[node.name] = coll_spec
450
+ if coll_spec.kind == "array":
451
+ self._array_vars.add(node.name)
452
+ elif coll_spec.kind == "map":
453
+ self._map_vars.add(node.name)
454
+ elif coll_spec.kind == "matrix":
455
+ self._matrix_specs[node.name] = coll_spec
456
+ cpp_type = self._type_spec_to_cpp(coll_spec)
457
+ cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type)
458
+ lines.append(f"{pad}{cpp_type}& {safe} = {cpp_val};")
459
+ return
460
+
461
+ # Global-scope UDT array-element alias (BUG: Pine array elements of a
462
+ # user-defined type are references). A global loop-local bound from
463
+ # ``arr.get(i)`` and later field-mutated must ALIAS the element so the
464
+ # mutation writes back into the array. Emit a fresh per-iteration
465
+ # ``UDT& z = arr[i];`` reference (any hoisted class member was
466
+ # suppressed) — identical to the non-hoisted function-local alias path.
467
+ # Gated by _register_udt_array_get_ref_locals (which only records global-
468
+ # scope names); the ``_current_func_body is None`` guard confines this to
469
+ # global scope, so a function-local sharing the name keeps its own path.
470
+ if (node.name in self._udt_array_get_ref_locals
471
+ and getattr(self, "_current_func_body", None) is None):
472
+ udt_t = self._udt_var_types.get(node.name)
473
+ cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=udt_t)
474
+ lines.append(f"{pad}{udt_t}& {safe} = {cpp_val};")
475
+ return
476
+
404
477
  # General declaration
405
- cpp_val = self._visit_expr(node.value)
478
+ cpp_type = self._type_for_decl(node) if not is_global_member else None
479
+ cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type)
406
480
  if is_global_member:
407
481
  lines.append(f"{pad}{safe} = {cpp_val};")
408
482
  else:
409
- cpp_type = self._type_for_decl(node)
483
+ remember_local_type(cpp_type)
410
484
  lines.append(f"{pad}{cpp_type} {safe} = {cpp_val};")
411
485
 
412
486
  @staticmethod
@@ -476,6 +550,12 @@ class StmtVisitor:
476
550
  if self._active_var_remap and safe in self._active_var_remap:
477
551
  safe = self._active_var_remap[safe]
478
552
 
553
+ # Pointer-aliased UDT local (BUG C, rebinding case): ``p := other``
554
+ # rebinds the pointer to the address of the newly selected UDT lvalue.
555
+ if target_name in self._udt_ptr_alias_locals and node.op == ":=":
556
+ lines.append(f"{pad}{safe} = {self._addr_of_udt_selection(node.value, target_name)};")
557
+ return
558
+
479
559
  if target_name in self.ctx.series_vars:
480
560
  val_cpp = self._visit_expr(node.value)
481
561
  if node.op == ":=":
@@ -509,7 +589,7 @@ class StmtVisitor:
509
589
  f"expected {self._type_spec_to_cpp(lhs_spec)}, "
510
590
  f"got {self._type_spec_to_cpp(rhs_spec)}",
511
591
  )
512
- val_cpp = self._visit_expr(node.value)
592
+ val_cpp = self._visit_rhs_value(node.value, target_name)
513
593
  if node.op == ":=":
514
594
  lines.append(f"{pad}{safe} = {val_cpp};")
515
595
  else:
@@ -519,7 +599,7 @@ class StmtVisitor:
519
599
  else:
520
600
  lines.append(f"{pad}{safe} {node.op} {val_cpp};")
521
601
  else:
522
- val_cpp = self._visit_expr(node.value)
602
+ val_cpp = self._visit_rhs_value(node.value, target_name)
523
603
  if node.op == ":=":
524
604
  lines.append(f"{pad}{safe} = {val_cpp};")
525
605
  else:
@@ -535,7 +615,11 @@ class StmtVisitor:
535
615
  compute_args = self._ta_compute_args_for_site(site)
536
616
  ta_mem = self._ta_member_name(site)
537
617
  result_var = f"_result_{ta_mem}"
538
- lines.append(f"{pad}auto {result_var} = (is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args}));")
618
+ lines.append(
619
+ f"{pad}auto {result_var} = (history_advances_new_bar() ? "
620
+ f"{ta_mem}.compute({compute_args}) : "
621
+ f"{ta_mem}.recompute({compute_args}));"
622
+ )
539
623
 
540
624
  ta_name = self._ta_name_from_site(site)
541
625
  fields = TA_TUPLE_FIELDS.get(ta_name, [f"field{i}" for i in range(len(node.names))])
@@ -555,7 +639,9 @@ class StmtVisitor:
555
639
  # destructured names keep the plain scalar declaration.
556
640
  if name in self.ctx.series_vars:
557
641
  safe = self._safe_name(name)
558
- lines.append(f"{pad}{safe}.push({field_expr});")
642
+ self._emit_history_series_write(
643
+ lines, pad, safe, field_expr
644
+ )
559
645
  else:
560
646
  lines.append(f"{pad}double {name} = {field_expr};")
561
647
  return
@@ -600,7 +686,30 @@ class StmtVisitor:
600
686
 
601
687
  lines.append(f"{pad}/* unsupported tuple assignment */")
602
688
 
689
+ def _push_block_var_remap(self, node):
690
+ """Activate block-scoped var renames for ``node`` (BUG 1). Returns the
691
+ previous ``_active_var_remap`` to restore (or ``_NO_BLOCK_REMAP`` if this
692
+ block owns no renames). Renames are MERGED over the inherited remap so
693
+ nested blocks keep any enclosing func-clone / outer-block mapping."""
694
+ renames = self._block_var_renames.get(id(node))
695
+ if not renames:
696
+ return _NO_BLOCK_REMAP
697
+ saved = self._active_var_remap
698
+ self._active_var_remap = {**saved, **renames}
699
+ return saved
700
+
701
+ def _pop_block_var_remap(self, saved) -> None:
702
+ if saved is not _NO_BLOCK_REMAP:
703
+ self._active_var_remap = saved
704
+
603
705
  def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None:
706
+ _blk_saved = self._push_block_var_remap(node)
707
+ try:
708
+ self._visit_if_body(node, lines, indent)
709
+ finally:
710
+ self._pop_block_var_remap(_blk_saved)
711
+
712
+ def _visit_if_body(self, node: IfStmt, lines: list[str], indent: int) -> None:
604
713
  pad = " " * indent
605
714
 
606
715
  # TA hoisting: inside per-call-site function variants, execute ALL
@@ -637,48 +746,118 @@ class StmtVisitor:
637
746
  pad = " " * indent
638
747
  start = self._visit_expr(node.start)
639
748
  end = self._visit_expr(node.end)
640
- step = self._visit_expr(node.step) if node.step else "1"
641
- var = node.var # new AST uses .var instead of .var_name
642
- lines.append(f"{pad}for (int {var} = {start}; {var} <= {end}; {var} += {step}) {{")
749
+ step = self._visit_expr(node.step) if node.step is not None else "1"
750
+ var = self._safe_name(node.var) # new AST uses .var instead of .var_name
751
+
752
+ # Pine infers loop direction from the initial ``from``/``to`` values for
753
+ # both implicit and explicit ``by`` loops. The ``by`` value is a positive
754
+ # magnitude; descending loops subtract it. ``to`` can change during the
755
+ # loop, so refresh the cached end expression after each iteration while
756
+ # keeping the initial direction and step fixed.
757
+ fid = self._for_counter
758
+ self._for_counter += 1
759
+ s_var = f"_for_start_{fid}"
760
+ e_var = f"_for_end_{fid}"
761
+ step_var = f"_for_step_{fid}"
762
+ down_var = f"_for_down_{fid}"
763
+ lines.append(f"{pad}int {s_var} = ({start});")
764
+ lines.append(f"{pad}int {e_var} = ({end});")
765
+ lines.append(f"{pad}int {step_var} = ({step});")
766
+ lines.append(f"{pad}if ({step_var} < 0) {step_var} = -{step_var};")
767
+ lines.append(f"{pad}if ({step_var} == 0) {step_var} = 1;")
768
+ lines.append(f"{pad}const bool {down_var} = ({s_var} > {e_var});")
769
+ lines.append(
770
+ f"{pad}for (int {var} = {s_var}; "
771
+ f"({down_var} ? ({var} >= {e_var}) : ({var} <= {e_var})); "
772
+ f"{var} += ({down_var} ? -{step_var} : {step_var}), {e_var} = ({end})) {{"
773
+ )
643
774
  # Register the loop counter so reads of it inside the body resolve (the
644
775
  # unknown-identifier guard in _visit_ident would otherwise flag it).
645
776
  saved_loop = self._current_loop_vars
646
777
  self._current_loop_vars = set(self._current_loop_vars)
647
- if var:
648
- self._current_loop_vars.add(var)
649
- for s in node.body:
650
- self._visit_stmt(s, lines, indent + 1)
778
+ if node.var:
779
+ self._current_loop_vars.add(node.var)
780
+ _blk_saved = self._push_block_var_remap(node)
781
+ try:
782
+ for s in node.body:
783
+ self._visit_stmt(s, lines, indent + 1)
784
+ finally:
785
+ self._pop_block_var_remap(_blk_saved)
651
786
  self._current_loop_vars = saved_loop
652
787
  lines.append(f"{pad}}}")
653
788
 
789
+ def _loop_elem_is_writeback_udt(self, iterable) -> bool:
790
+ """Whether a ``for x in coll`` loop variable must bind by reference.
791
+
792
+ In Pine a ``for x in arr`` loop variable over an array of *user-defined
793
+ objects* is a reference to the element — field writes (``x.f := v``)
794
+ mutate the array in place — whereas over a primitive array it is a
795
+ copy. So emit C++ ``auto&`` only for arrays whose element is a
796
+ user-defined UDT struct. Primitive elements keep ``auto`` (Pine copy
797
+ semantics: writing the loop var must NOT write back). Drawing handles
798
+ (line/box/label/linefill/...) also keep ``auto``: their element type
799
+ name is a builtin, not in ``_udt_defs``, and a handle copy already
800
+ mutates the shared engine object. (Reassigning the loop var itself —
801
+ ``x := ...`` — is not modelled by either form, but Pine forbids it for
802
+ objects in practice and it does not occur in the corpus.)
803
+ """
804
+ spec = self._type_spec_from_expr(iterable)
805
+ return (
806
+ spec is not None
807
+ and spec.kind == "array"
808
+ and spec.element is not None
809
+ and spec.element.kind == "udt"
810
+ and spec.element.name in self._udt_defs
811
+ )
812
+
654
813
  def _visit_for_in(self, node, lines: list[str], indent: int) -> None:
655
814
  pad = " " * indent
656
815
  iterable = self._visit_expr(node.iterable)
657
816
  saved_loop = self._current_loop_vars
817
+ saved_loop_specs = self._current_loop_var_specs
658
818
  self._current_loop_vars = set(self._current_loop_vars)
819
+ self._current_loop_var_specs = dict(self._current_loop_var_specs)
820
+ iterable_spec = self._type_spec_from_expr(node.iterable)
821
+ elem_spec = (
822
+ iterable_spec.element
823
+ if iterable_spec is not None and iterable_spec.kind == "array"
824
+ else None
825
+ )
659
826
  if node.var:
660
827
  self._current_loop_vars.add(node.var)
828
+ if elem_spec is not None:
829
+ self._current_loop_var_specs[node.var] = elem_spec
661
830
  if node.vars:
662
831
  for v in node.vars:
663
832
  if v != "_":
664
833
  self._current_loop_vars.add(v)
665
834
  if node.var:
666
835
  v_cpp = self._safe_name(node.var)
667
- lines.append(f"{pad}for (auto {v_cpp} : {iterable}) {{")
836
+ ref = "&" if self._loop_elem_is_writeback_udt(node.iterable) else ""
837
+ lines.append(f"{pad}for (auto{ref} {v_cpp} : {iterable}) {{")
668
838
  elif node.vars:
669
839
  bindings = ", ".join(node.vars)
670
840
  lines.append(f"{pad}for (auto [{bindings}] : {iterable}) {{")
671
- for s in node.body:
672
- self._visit_stmt(s, lines, indent + 1)
841
+ _blk_saved = self._push_block_var_remap(node)
842
+ try:
843
+ for s in node.body:
844
+ self._visit_stmt(s, lines, indent + 1)
845
+ finally:
846
+ self._pop_block_var_remap(_blk_saved)
673
847
  lines.append(f"{pad}}}")
674
848
  self._current_loop_vars = saved_loop
849
+ self._current_loop_var_specs = saved_loop_specs
675
850
 
676
851
  def _visit_while(self, node: WhileStmt, lines: list[str], indent: int) -> None:
677
852
  pad = " " * indent
678
853
  cond = self._visit_expr(node.condition)
679
854
  lines.append(f"{pad}while ({cond}) {{")
680
- for s in node.body:
681
- self._visit_stmt(s, lines, indent + 1)
855
+ _blk_saved = self._push_block_var_remap(node)
856
+ try:
857
+ for s in node.body:
858
+ self._visit_stmt(s, lines, indent + 1)
859
+ finally:
860
+ self._pop_block_var_remap(_blk_saved)
682
861
  lines.append(f"{pad}}}")
683
862
 
684
863
  def _visit_switch(self, node: SwitchStmt, lines: list[str], indent: int) -> None:
@@ -727,6 +906,12 @@ class StmtVisitor:
727
906
  # Check if it's a skip expr
728
907
  if self._is_skip_expr(stmt.expr):
729
908
  return
909
+ # A void drawing setter / delete / visual-noop cannot be the
910
+ # branch's value (it lowers to a void C++ call) — emit it as
911
+ # a statement and leave ``target`` at its default.
912
+ if self._call_is_void(stmt.expr):
913
+ self._visit_stmt(stmt, lines, indent)
914
+ return
730
915
  cpp = self._visit_expr(stmt.expr)
731
916
  pad = " " * indent
732
917
  lines.append(f"{pad}{target} = {cpp};")