@pineforge/codegen-pyodide 0.8.0 → 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.
@@ -141,10 +141,14 @@ from ..ast_nodes import (
141
141
  )
142
142
  from ..symbols import TypeSpec
143
143
  from .. import signatures as sigs
144
+ from .drawing import ALL_DRAWING_METHODS
144
145
  from .tables import (
146
+ ARRAY_DRAWING_NEW_CTORS,
145
147
  ARRAY_METHODS,
146
148
  BAR_FIELDS,
147
149
  BAR_SERIES_PUSH,
150
+ DRAWING_NS,
151
+ DRAWING_TYPE_TO_CPP,
148
152
  MAP_METHODS,
149
153
  MATH_FUNC_MAP,
150
154
  MATRIX_METHODS,
@@ -226,6 +230,32 @@ class CallVisitor:
226
230
  )
227
231
  rest = [self._visit_expr(a) for a in rest_nodes]
228
232
  return f"{fn_cpp}({', '.join([recv_e] + rest)})"
233
+
234
+ # Drawing method dispatch (spec §4.3 / L.1). A KNOWN drawing method on a
235
+ # receiver that resolves to a drawing udt — gated on the METHOD NAME
236
+ # FIRST so a user method (egoigor's ``ln.slope()``, already routed by the
237
+ # block above) is never captured here. This single check covers all
238
+ # receiver shapes (identifier ``ln.set_x2(v)``, obj.field
239
+ # ``d.fld.set_y2(v)``, and arbitrary-expr ``d.upln.get(0).delete()``), so
240
+ # it precedes the obj.field.method / identifier branches below AND the
241
+ # generic ``delete`` -> ``_delete_`` rewrites + _resolve_callee.
242
+ if isinstance(callee, MemberAccess) and callee.member in ALL_DRAWING_METHODS:
243
+ recv_spec = self._type_spec_from_expr(callee.object)
244
+ if (recv_spec is not None and recv_spec.kind == "udt"
245
+ and recv_spec.name in DRAWING_TYPE_TO_CPP):
246
+ return self._emit_drawing_method(
247
+ recv_spec.name, callee.member, callee.object,
248
+ list(node.args), node,
249
+ )
250
+
251
+ # chart.point.now/new/from_index/from_time/copy — REAL data (a ChartPoint
252
+ # aggregate). Routed here BEFORE the obj.field.method receiver logic,
253
+ # which would otherwise mis-treat ``chart.point`` as a receiver object
254
+ # and raise on the ``chart.point`` member read (chart ∈ SKIP_NAMESPACES).
255
+ if self._is_chart_point_callee(callee):
256
+ cp_func, _cp_ns = self._resolve_callee(callee)
257
+ return self._emit_chart_point(cp_func, node)
258
+
229
259
  # obj.field.method(args) — must not lower to namespace::method (loses receiver chain).
230
260
  if isinstance(callee, MemberAccess):
231
261
  obj = callee.object
@@ -321,10 +351,22 @@ class CallVisitor:
321
351
  return f"is_na({args})"
322
352
 
323
353
  # nz(x) / nz(x, y)
354
+ #
355
+ # x's emitted C++ source is substituted into the surrounding
356
+ # expression, so it must be evaluated EXACTLY ONCE: when x is a
357
+ # stateful call (e.g. a ta.* site lowered to `.compute()`/
358
+ # `.recompute()`), naively embedding {x} twice — once for the
359
+ # is_na() check, once for the non-na branch — invokes that call
360
+ # twice per bar, silently corrupting the indicator's internal state
361
+ # (e.g. nz(ta.sma(v, 50), v) becomes an effective 25-bar SMA: every
362
+ # bar is pushed into the ring buffer twice). An immediately-invoked
363
+ # lambda hoists x into a local `auto` so it is computed once and
364
+ # both branches read the same value; `[&]` is safe here since the
365
+ # lambda is called synchronously and discarded, never escaping.
324
366
  if func_name == "nz" and namespace is None:
325
367
  x = self._visit_expr(node.args[0])
326
368
  y = self._visit_expr(node.args[1]) if len(node.args) > 1 else "0.0"
327
- return f"(is_na({x}) ? {y} : {x})"
369
+ return f"([&]{{ auto _nz_v = ({x}); return is_na(_nz_v) ? ({y}) : _nz_v; }}())"
328
370
 
329
371
  # fixnan(x) -> persistent state
330
372
  if func_name == "fixnan" and namespace is None:
@@ -414,7 +456,7 @@ class CallVisitor:
414
456
 
415
457
  # Array operations — emit proper C++ vector operations
416
458
  if namespace == "array":
417
- if func_name in ("new", "new_float", "new_int", "new_bool", "new_string"):
459
+ if func_name in ("new", "new_float", "new_int", "new_bool", "new_string") or func_name in ARRAY_DRAWING_NEW_CTORS:
418
460
  spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float"))
419
461
  cpp_type = self._type_spec_to_cpp(spec)
420
462
  init_default = self._default_for_spec(spec.element if spec.element is not None else TypeSpec.primitive("float"))
@@ -446,6 +488,16 @@ class CallVisitor:
446
488
  if namespace is None and func_name == "color" and func_name not in self._func_names:
447
489
  return "0"
448
490
 
491
+ # Drawing-objects-as-data namespace-functional form (spec §4.3 form 1):
492
+ # line.new(...) / line.get_y2(ln) / box.set_top(b, v) / linefill.new(...)
493
+ # MUST precede the SKIP_NAMESPACES early-return (these namespaces were
494
+ # removed from SKIP_NAMESPACES). chart.point.* resolves to namespace
495
+ # "chart", so it is matched by callee shape instead.
496
+ if namespace in DRAWING_NS:
497
+ return self._emit_drawing_namespace_call(namespace, func_name, node)
498
+ if self._is_chart_point_callee(callee):
499
+ return self._emit_chart_point(func_name, node)
500
+
449
501
  # Skip visual/unsupported namespace calls
450
502
  if namespace in SKIP_NAMESPACES or namespace in SKIP_VAR_TYPES:
451
503
  return "0"
@@ -468,19 +520,34 @@ class CallVisitor:
468
520
  all_args.append(None)
469
521
  all_args[i] = node.kwargs[pname]
470
522
 
471
- # Find matching security call ID
472
- sec_id = None
473
- tf_node = None
474
- expr_node = None
475
- for item in self._security_calls:
476
- sid, tfn, exprn = item["sec_id"], item["tf_node"], item["expr_node"]
477
- if item.get("is_lower_tf_array"):
478
- continue
479
- if exprn is all_args[2] if len(all_args) > 2 else False:
480
- sec_id = sid
481
- tf_node = tfn
482
- expr_node = exprn
483
- break
523
+ # Find matching security call ID. A request.security whose
524
+ # timeframe is a UDF parameter called from multiple sites with
525
+ # multiple distinct literal timeframes is registered as N
526
+ # CLONES (one SecurityCallInfo per call site, same source
527
+ # expr_node identity, distinct sec_id/callsite_idx — see
528
+ # Analyzer._check_mixed_callsite_security_tf). All clones
529
+ # match the identity check below identically, so when more
530
+ # than one matches, disambiguate by which call-site clone's
531
+ # function body is currently being emitted
532
+ # (self._active_call_site_idx, set by _emit_func_def while
533
+ # walking that exact clone's body).
534
+ candidates = [
535
+ item for item in self._security_calls
536
+ if not item.get("is_lower_tf_array")
537
+ and (exprn := item["expr_node"]) is not None
538
+ and (len(all_args) > 2 and exprn is all_args[2])
539
+ ]
540
+ chosen = None
541
+ if len(candidates) == 1:
542
+ chosen = candidates[0]
543
+ elif len(candidates) > 1:
544
+ chosen = next(
545
+ (c for c in candidates
546
+ if c.get("callsite_idx") == self._active_call_site_idx),
547
+ candidates[0],
548
+ )
549
+ sec_id = chosen["sec_id"] if chosen else None
550
+ expr_node = chosen["expr_node"] if chosen else None
484
551
 
485
552
  if sec_id is not None and expr_node is not None:
486
553
  if isinstance(expr_node, TupleLiteral):
@@ -522,9 +589,12 @@ class CallVisitor:
522
589
 
523
590
  # ticker.* calls
524
591
  if namespace == "ticker":
525
- # ticker.inherit(symbol, ...) and ticker.standard(symbol) — passthrough:
526
- # emit the symbol argument unchanged (same-symbol passthrough).
527
- if func_name in ("inherit", "standard"):
592
+ # ticker.inherit(symbol, ...) / ticker.standard(symbol) — passthrough,
593
+ # and ticker.heikinashi(symbol) — same-symbol HA: emit the symbol
594
+ # argument unchanged. The runtime HA candle transform is applied by
595
+ # the engine via register_security_eval's heikinashi flag, so the
596
+ # ticker value itself just needs to be the (string) chart symbol.
597
+ if func_name in ("inherit", "standard", "heikinashi"):
528
598
  if node.args:
529
599
  return self._visit_expr(node.args[0])
530
600
  if "symbol" in node.kwargs:
@@ -915,10 +985,19 @@ class CallVisitor:
915
985
  elif f.default:
916
986
  val = self._visit_expr(f.default)
917
987
  if val is not None:
918
- # Fix narrowing: cast na<double>() to correct type for int fields
988
+ # Fix narrowing: brace-init (``T{.field = v}``) disallows
989
+ # narrowing. Pine ``int`` UDT fields are emitted as
990
+ # ``int64_t`` (see base.py) but are initialised from
991
+ # ``na<double>()`` / doubles in places, so cast to the
992
+ # field's type. ``na<double>()`` for an int field → 0.
919
993
  f_cpp_type = self._type_spec_to_cpp(field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name))
920
- if f_cpp_type == "int" and "na<double>" in val:
921
- val = val.replace("na<double>()", "0")
994
+ if f_cpp_type == "int":
995
+ f_cpp_type = "int64_t"
996
+ if f_cpp_type == "int64_t":
997
+ if "na<double>" in val:
998
+ val = val.replace("na<double>()", "na<int64_t>()")
999
+ else:
1000
+ val = f"(int64_t)({val})"
922
1001
  field_inits.append(f".{f.name} = {val}")
923
1002
  # Mark the constructed object non-na (the struct's ``__pf_na`` is the
924
1003
  # last declared field, so this designator stays in declaration order).
@@ -1012,6 +1091,17 @@ class CallVisitor:
1012
1091
  all_args.extend(self._visit_expr(v) for v in node.kwargs.values())
1013
1092
  else:
1014
1093
  all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)]
1094
+ # Drawing-style/visual CONSTANT passed positionally into a user function's
1095
+ # ``string`` parameter: ``label.style_*`` / ``size.*`` / other
1096
+ # DRAWING_STYLE_NS members lower to the bare token ``"0"`` (they only ever
1097
+ # feed dropped visual kwargs). Bound to a ``std::string`` parameter, that
1098
+ # ``0`` constructs ``std::string((char const*)0)`` at the call site -> a
1099
+ # null-pointer ``strlen`` crash at runtime. Coerce such args to
1100
+ # ``std::string("")`` so the (inert, visual-only) value is a valid empty
1101
+ # string. Only touches user functions with a known string param and an
1102
+ # arg that is exactly such a drawing-style constant read.
1103
+ if namespace is None and func_name in self._func_names:
1104
+ self._coerce_drawing_style_string_args(func_name, node.args, all_args)
1015
1105
  # Default args (parser does not store defaults): isInSession(sess, res = timeframe.period)
1016
1106
  if namespace is None and func_name in self._func_names:
1017
1107
  fi = self._func_info_map.get(func_name)
@@ -1023,7 +1113,16 @@ class CallVisitor:
1023
1113
  emit_name = self._func_safe_name(func_name) if func_name in self._func_names else func_name
1024
1114
  # Per-call-site variant: if this function has TA/series calls, call the correct variant
1025
1115
  cs_info = self.ctx.func_call_cs_map.get(id(node))
1026
- if self._active_call_site_idx is not None and cs_info is not None:
1116
+ dispatch_key = (self._current_instance_name, id(node))
1117
+ if dispatch_key in self._instance_dispatch:
1118
+ # Context-sensitive (call-path) dispatch: the instance pre-pass
1119
+ # resolved this nested stateful-helper call to the clone bound to
1120
+ # THIS enclosing path's members (see _build_func_instances). This
1121
+ # is authoritative — it supersedes the textual-cs threading below,
1122
+ # which conflates a callee's own call sites with the enclosing
1123
+ # function's call sites for helpers reached through >1 path.
1124
+ emit_name = self._instance_dispatch[dispatch_key]
1125
+ elif self._active_call_site_idx is not None and cs_info is not None:
1027
1126
  # Inside a per-call-site variant: override the cs_map index with
1028
1127
  # the parent's active call-site index. This ensures sub-functions
1029
1128
  # called from ma_cs6() use their _cs6 variant, not _cs0.
@@ -1040,6 +1139,32 @@ class CallVisitor:
1040
1139
  emit_name = f"{self._func_safe_name(func_name)}_cs{self._active_call_site_idx}"
1041
1140
  return f"{prefix}{emit_name}({', '.join(all_args)})"
1042
1141
 
1142
+ def _coerce_drawing_style_string_args(self, func_name, arg_nodes, all_args) -> None:
1143
+ """In-place coerce positional args bound to a ``std::string`` user-function
1144
+ parameter that lowered to the bare token ``"0"`` from a drawing-style /
1145
+ visual constant (``label.style_*`` etc.). Such a literal ``0`` binds as
1146
+ ``std::string((char const*)0)`` and segfaults on first use. Replace with
1147
+ ``std::string("")`` (the value is visual-only and inert in a backtest)."""
1148
+ from .tables import DRAWING_STYLE_NS
1149
+ fi = self._func_info_map.get(func_name)
1150
+ if not fi or not getattr(fi, "node", None) or not fi.node.params:
1151
+ return
1152
+ specs = getattr(fi, "param_type_specs", []) or []
1153
+ for i, arg in enumerate(arg_nodes):
1154
+ if i >= len(all_args) or all_args[i] != "0":
1155
+ continue
1156
+ # Only when the destination parameter is a string.
1157
+ spec = specs[i] if i < len(specs) else None
1158
+ is_string_param = spec is not None and getattr(spec, "kind", None) == "primitive" \
1159
+ and getattr(spec, "name", None) == "string"
1160
+ if not is_string_param:
1161
+ continue
1162
+ # Only when the source really is a drawing-style/visual constant read
1163
+ # (so we never silently turn a numeric ``0`` into an empty string).
1164
+ if (isinstance(arg, MemberAccess) and isinstance(arg.object, Identifier)
1165
+ and arg.object.name in DRAWING_STYLE_NS):
1166
+ all_args[i] = 'std::string("")'
1167
+
1043
1168
  def _visit_fixnan(self, node: FuncCall) -> str:
1044
1169
  """Emit fixnan with persistent state member."""
1045
1170
  self._fixnan_counter += 1
@@ -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>()
@@ -589,6 +629,14 @@ class ExprVisitor:
589
629
  if node.member in COLOR_CONST_MAP:
590
630
  return COLOR_CONST_MAP[node.member]
591
631
  return "0"
632
+ # Drawing style/visual CONSTANT member reads (line.style_solid,
633
+ # box.style_dashed, label.style_label_left, ...). These namespaces
634
+ # left SKIP_NAMESPACES (their FuncCall forms now route to the arena),
635
+ # but their constant members only ever feed dropped visual kwargs, so
636
+ # they still lower to "0". The function names arrive as FuncCalls
637
+ # (visit_call), never here. See spec §4.4.
638
+ if ns in DRAWING_STYLE_NS:
639
+ return "0"
592
640
  if ns in SKIP_NAMESPACES:
593
641
  return "0"
594
642
  if ns == "currency":
@@ -660,10 +708,16 @@ class ExprVisitor:
660
708
  or name in self._var_names
661
709
  or name in self._current_loop_vars
662
710
  or name in self._current_func_param_types
711
+ or name in self._current_func_locals
712
+ or name in self._udt_var_types
663
713
  ):
664
714
  safe = self._safe_name(name)
665
715
  if self._active_var_remap and safe in self._active_var_remap:
666
716
  safe = self._active_var_remap[safe]
717
+ # Pointer-aliased UDT local (BUG C, rebinding case): field access
718
+ # goes through ``->`` since the local holds ``UDT*``.
719
+ if name in self._udt_ptr_alias_locals:
720
+ return f"{safe}->{node.member}"
667
721
  return f"{safe}.{node.member}"
668
722
  if name not in self.ctx.series_vars:
669
723
  # Unknown identifier — likely an enum value
@@ -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.
@@ -291,7 +296,7 @@ class StmtVisitor:
291
296
  # array.new_float() etc., plus array-returning copy/slice.
292
297
  if isinstance(node.value, FuncCall):
293
298
  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"):
299
+ if namespace == "array" and func_name in ARRAY_NEW_CTORS | {"new", "from", "copy", "slice"}:
295
300
  self._array_vars.add(node.name)
296
301
  spec = self._type_spec_from_expr(node.value) or self._array_spec_for_name(node.name)
297
302
  self._collection_types.setdefault(node.name, spec)
@@ -401,12 +406,57 @@ class StmtVisitor:
401
406
  self._visit_if_switch_expr(node.value, safe, lines, indent)
402
407
  return
403
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
+
404
454
  # General declaration
405
- 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)
406
457
  if is_global_member:
407
458
  lines.append(f"{pad}{safe} = {cpp_val};")
408
459
  else:
409
- cpp_type = self._type_for_decl(node)
410
460
  lines.append(f"{pad}{cpp_type} {safe} = {cpp_val};")
411
461
 
412
462
  @staticmethod
@@ -476,6 +526,12 @@ class StmtVisitor:
476
526
  if self._active_var_remap and safe in self._active_var_remap:
477
527
  safe = self._active_var_remap[safe]
478
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
+
479
535
  if target_name in self.ctx.series_vars:
480
536
  val_cpp = self._visit_expr(node.value)
481
537
  if node.op == ":=":
@@ -509,7 +565,7 @@ class StmtVisitor:
509
565
  f"expected {self._type_spec_to_cpp(lhs_spec)}, "
510
566
  f"got {self._type_spec_to_cpp(rhs_spec)}",
511
567
  )
512
- val_cpp = self._visit_expr(node.value)
568
+ val_cpp = self._visit_rhs_value(node.value, target_name)
513
569
  if node.op == ":=":
514
570
  lines.append(f"{pad}{safe} = {val_cpp};")
515
571
  else:
@@ -519,7 +575,7 @@ class StmtVisitor:
519
575
  else:
520
576
  lines.append(f"{pad}{safe} {node.op} {val_cpp};")
521
577
  else:
522
- val_cpp = self._visit_expr(node.value)
578
+ val_cpp = self._visit_rhs_value(node.value, target_name)
523
579
  if node.op == ":=":
524
580
  lines.append(f"{pad}{safe} = {val_cpp};")
525
581
  else:
@@ -600,7 +656,30 @@ class StmtVisitor:
600
656
 
601
657
  lines.append(f"{pad}/* unsupported tuple assignment */")
602
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
+
603
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:
604
683
  pad = " " * indent
605
684
 
606
685
  # TA hoisting: inside per-call-site function variants, execute ALL
@@ -637,20 +716,73 @@ class StmtVisitor:
637
716
  pad = " " * indent
638
717
  start = self._visit_expr(node.start)
639
718
  end = self._visit_expr(node.end)
640
- step = self._visit_expr(node.step) if node.step else "1"
641
719
  var = node.var # new AST uses .var instead of .var_name
642
- 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
+ )
643
747
  # Register the loop counter so reads of it inside the body resolve (the
644
748
  # unknown-identifier guard in _visit_ident would otherwise flag it).
645
749
  saved_loop = self._current_loop_vars
646
750
  self._current_loop_vars = set(self._current_loop_vars)
647
751
  if var:
648
752
  self._current_loop_vars.add(var)
649
- for s in node.body:
650
- 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)
651
759
  self._current_loop_vars = saved_loop
652
760
  lines.append(f"{pad}}}")
653
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
+
654
786
  def _visit_for_in(self, node, lines: list[str], indent: int) -> None:
655
787
  pad = " " * indent
656
788
  iterable = self._visit_expr(node.iterable)
@@ -664,12 +796,17 @@ class StmtVisitor:
664
796
  self._current_loop_vars.add(v)
665
797
  if node.var:
666
798
  v_cpp = self._safe_name(node.var)
667
- 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}) {{")
668
801
  elif node.vars:
669
802
  bindings = ", ".join(node.vars)
670
803
  lines.append(f"{pad}for (auto [{bindings}] : {iterable}) {{")
671
- for s in node.body:
672
- 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)
673
810
  lines.append(f"{pad}}}")
674
811
  self._current_loop_vars = saved_loop
675
812
 
@@ -677,8 +814,12 @@ class StmtVisitor:
677
814
  pad = " " * indent
678
815
  cond = self._visit_expr(node.condition)
679
816
  lines.append(f"{pad}while ({cond}) {{")
680
- for s in node.body:
681
- 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)
682
823
  lines.append(f"{pad}}}")
683
824
 
684
825
  def _visit_switch(self, node: SwitchStmt, lines: list[str], indent: int) -> None:
@@ -727,6 +868,12 @@ class StmtVisitor:
727
868
  # Check if it's a skip expr
728
869
  if self._is_skip_expr(stmt.expr):
729
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
730
877
  cpp = self._visit_expr(stmt.expr)
731
878
  pad = " " * indent
732
879
  lines.append(f"{pad}{target} = {cpp};")