@pineforge/codegen-pyodide 0.10.0 → 0.10.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.
@@ -37,8 +37,6 @@ Mixin contract — host class must provide the following attributes
37
37
  ``_visit_var_decl`` populates it from inferred specs.
38
38
  - ``self._active_var_remap`` (``dict[str, str]``): per-call-site
39
39
  rename map for cloned function-local var/series names.
40
- - ``self._in_ta_func_variant`` (``bool``): set during per-call-site
41
- function emission; gates the TA-hoist branch in ``_visit_if``.
42
40
  - ``self._current_loop_vars`` (``set[str]``): for-in iterator names;
43
41
  saved/restored around ``_visit_for_in`` bodies so member-access
44
42
  resolution can distinguish iterators from enum constants.
@@ -58,7 +56,7 @@ Sibling-mixin methods consumed via ``self``:
58
56
  ``_map_spec_for_name``.
59
57
  - ``TaSiteHelper`` (``codegen/ta.py``): ``_get_ta_site``,
60
58
  ``_ta_member_name``, ``_ta_compute_args_for_site``,
61
- ``_ta_name_from_site``, ``_if_body_has_ta``, ``_hoist_if_body``.
59
+ ``_ta_name_from_site``.
62
60
  - ``InputHelper`` (``codegen/input.py``): ``_is_input_call``,
63
61
  ``_get_input_default``, ``_get_input_title``,
64
62
  ``_input_type_to_getter``,
@@ -93,21 +91,24 @@ from ..ast_nodes import (
93
91
  MethodDef,
94
92
  StrategyDecl,
95
93
  SwitchStmt,
94
+ Ternary,
96
95
  TupleAssign,
96
+ TupleLiteral,
97
97
  TypeDecl,
98
98
  VarDecl,
99
99
  WhileStmt,
100
100
  )
101
- from ..symbols import TypeSpec
101
+ from ..symbols import PineType, TypeSpec, method_receiver_type_name
102
102
  from .tables import (
103
103
  ARRAY_NEW_CTORS,
104
+ DRAWING_TYPE_TO_CPP,
104
105
  TA_RETURNS_BOOL,
105
106
  TA_TUPLE_FIELDS,
106
107
  MATRIX_RETURNING_METHODS,
107
108
  )
108
109
 
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.
110
+ # Sentinel for "no block-scoped overlay was activated" so an empty saved map
111
+ # is still distinguishable from the no-op case.
111
112
  _NO_BLOCK_REMAP = object()
112
113
 
113
114
 
@@ -137,11 +138,191 @@ class StmtVisitor:
137
138
  if isinstance(node, MethodDef):
138
139
  return # handled as class method via FuncInfo
139
140
  if isinstance(node, VarDecl):
140
- self._visit_var_decl(node, lines, pad)
141
+ is_callable_binding = (
142
+ getattr(self, "_active_func_name", None) is not None
143
+ and id(node) in self._callable_collection_bindings
144
+ )
145
+ activation_spec = (
146
+ self._callable_collection_bindings.get(id(node))
147
+ if is_callable_binding
148
+ else None
149
+ )
150
+ if is_callable_binding and activation_spec is None:
151
+ inferred_spec = self._type_spec_from_expr(node.value)
152
+ if (inferred_spec is not None
153
+ and inferred_spec.kind in {"array", "map", "matrix"}):
154
+ activation_spec = inferred_spec
155
+ previous_pending_alias = self._pending_decl_outer_alias
156
+ if (is_callable_binding
157
+ and not node.is_var
158
+ and not node.is_varip
159
+ and self._collection_spec_for_name(node.name) is not None
160
+ and any(
161
+ isinstance(part, Identifier) and part.name == node.name
162
+ for part in self._walk_ast(node.value)
163
+ )):
164
+ safe_name = self._safe_name(node.name)
165
+ if (node.name in self._global_collection_types
166
+ and node.name not in self._current_func_param_specs
167
+ and node.name not in self._current_func_collection_specs
168
+ and node.name not in self._current_loop_var_specs):
169
+ outer_expr = f"this->{safe_name}"
170
+ else:
171
+ outer_expr = self._active_var_remap.get(safe_name, safe_name)
172
+ occupied_alias_names = (
173
+ set(self._all_bound_names)
174
+ | set(self._collection_shadow_tmp_names)
175
+ | set(self._current_func_param_types)
176
+ | {
177
+ self._safe_name(param)
178
+ for param in self._current_func_param_types
179
+ }
180
+ )
181
+ while True:
182
+ alias = (
183
+ f"_pf_outer_{safe_name}_"
184
+ f"{self._collection_shadow_tmp_counter}"
185
+ )
186
+ self._collection_shadow_tmp_counter += 1
187
+ if alias not in occupied_alias_names:
188
+ break
189
+ self._collection_shadow_tmp_names.add(alias)
190
+ lines.append(f"{pad}auto& {alias} = {outer_expr};")
191
+ self._pending_decl_outer_alias = {
192
+ **previous_pending_alias,
193
+ node.name: alias,
194
+ }
195
+ try:
196
+ self._visit_var_decl(node, lines, pad)
197
+ finally:
198
+ self._pending_decl_outer_alias = previous_pending_alias
199
+ if is_callable_binding:
200
+ self._activate_callable_collection_binding(
201
+ node.name,
202
+ activation_spec,
203
+ )
204
+ metadata = getattr(
205
+ self.ctx, "var_member_metadata_by_node", {}
206
+ ).get(id(node))
207
+ if metadata is not None:
208
+ member_name = metadata[1]
209
+ if member_name != node.name:
210
+ # The declaration RHS was emitted against the inherited
211
+ # binding. Only now activate the collision-safe member for
212
+ # later statements in this exact lexical block.
213
+ exact_storage = self._safe_name(member_name)
214
+ # A callable variant may already map the exact member to a
215
+ # per-call-site clone (``x__blk1`` -> ``x__blk1_cs1``).
216
+ # Preserve that outer clone mapping when exposing the raw
217
+ # lexical spelling after the declaration.
218
+ exact_storage = self._active_var_remap.get(
219
+ exact_storage, exact_storage
220
+ )
221
+ self._active_var_remap = dict(self._active_var_remap)
222
+ self._active_var_remap[self._safe_name(node.name)] = (
223
+ exact_storage
224
+ )
225
+ drawing_info = self._drawing_var_decl_info_by_node.get(id(node))
226
+ if (drawing_info is not None
227
+ and drawing_info.get("is_callable_scoped")):
228
+ raw_safe = self._safe_name(node.name)
229
+ storage = self._active_var_remap.get(
230
+ raw_safe, self._safe_name(member_name)
231
+ )
232
+ if storage.startswith("this->"):
233
+ storage = storage[len("this->"):]
234
+ self._active_var_remap = dict(self._active_var_remap)
235
+ self._active_var_remap[raw_safe] = f"this->{storage}"
236
+ self._lexical_series_bindings[node.name] = (
237
+ self._safe_name(member_name)
238
+ in self._series_var_member_names
239
+ )
240
+ else:
241
+ self._lexical_series_bindings[node.name] = (
242
+ self._decl_binding_is_series(id(node), node.name)
243
+ )
244
+ if id(node) not in self._direct_program_var_decl_nodes:
245
+ self._lexical_known_var_tombstones.add(node.name)
246
+ decl_spec = getattr(
247
+ self.ctx, "var_member_type_specs_by_node", {}
248
+ ).get(id(node))
249
+ if decl_spec is None:
250
+ decl_spec = (
251
+ self._type_spec_from_hint_name(node.type_hint)
252
+ if node.type_hint
253
+ else self._type_spec_from_expr(node.value)
254
+ )
255
+ self._lexical_drawing_types[node.name] = (
256
+ DRAWING_TYPE_TO_CPP.get(decl_spec.name)
257
+ if decl_spec is not None and decl_spec.kind == "udt"
258
+ else None
259
+ )
260
+ self._lexical_udt_types[node.name] = (
261
+ decl_spec.name
262
+ if (decl_spec is not None
263
+ and decl_spec.kind == "udt"
264
+ and decl_spec.name in self._udt_defs)
265
+ else None
266
+ )
267
+ if (
268
+ node.name == "map"
269
+ and getattr(self, "_block_map_visibility_depth", 0) > 0
270
+ ):
271
+ # The declaration RHS was emitted against the outer lexical
272
+ # state. Only subsequent statements in this exact block see
273
+ # the new ``map`` value; block pop restores sibling/outside
274
+ # visibility.
275
+ self._block_map_binding_visible = True
141
276
  elif isinstance(node, Assignment):
142
277
  self._visit_assignment(node, lines, pad)
143
278
  elif isinstance(node, TupleAssign):
144
279
  self._visit_tuple_assign(node, lines, pad)
280
+ tuple_cpp_types = self._tuple_binding_cpp_types(node)
281
+ if getattr(self, "_active_func_name", None) is not None:
282
+ for name in node.names:
283
+ if name and name != "_":
284
+ self._activate_callable_collection_binding(name, None)
285
+ scalar_names = [
286
+ name
287
+ for name in node.names
288
+ if name
289
+ and name != "_"
290
+ and not self._decl_binding_is_series(id(node), name)
291
+ ]
292
+ if any(
293
+ self._safe_name(name) in self._active_var_remap
294
+ for name in scalar_names
295
+ ):
296
+ # Tuple declarations are lexical bindings. A scalar element
297
+ # must shadow any same-spelled Series storage inherited from a
298
+ # broad callable call-site remap; otherwise a later read can
299
+ # silently resolve to an unrelated ``x_csN`` member even
300
+ # though the structured binding declared a local ``x``.
301
+ self._active_var_remap = dict(self._active_var_remap)
302
+ for name in scalar_names:
303
+ self._active_var_remap.pop(self._safe_name(name), None)
304
+ for index, name in enumerate(node.names):
305
+ if name and name != "_":
306
+ # A supported tuple destructure creates fresh lexical
307
+ # bindings. Tuple elements are primitive on the current
308
+ # supported surface, so install an explicit tombstone: an
309
+ # unrelated same-named global/callable UDT must not target-
310
+ # type a later ``name := na`` as ``State{}``.
311
+ self._lexical_udt_types[name] = None
312
+ if (getattr(self, "_active_func_name", None) is not None
313
+ and not self._decl_binding_is_series(
314
+ id(node), name
315
+ )):
316
+ self._current_func_local_types[name] = (
317
+ tuple_cpp_types[index]
318
+ if index < len(tuple_cpp_types)
319
+ else "double"
320
+ )
321
+ self._lexical_series_bindings[name] = (
322
+ self._decl_binding_is_series(id(node), name)
323
+ )
324
+ if id(node) not in self._direct_program_tuple_decl_nodes:
325
+ self._lexical_known_var_tombstones.add(name)
145
326
  elif isinstance(node, IfStmt):
146
327
  self._visit_if(node, lines, indent)
147
328
  elif isinstance(node, ForStmt):
@@ -234,7 +415,8 @@ class StmtVisitor:
234
415
  # m.concat(other, ...) — receiver is callee.object
235
416
  if isinstance(callee.object, Identifier):
236
417
  recv = callee.object.name
237
- if recv in getattr(self, "_matrix_specs", {}):
418
+ recv_spec = self._collection_spec_for_name(recv)
419
+ if recv_spec is not None and recv_spec.kind == "matrix":
238
420
  return recv
239
421
  # matrix.concat(m, other, ...) — receiver is first arg
240
422
  if (isinstance(callee.object, Identifier)
@@ -242,21 +424,155 @@ class StmtVisitor:
242
424
  and expr.args
243
425
  and isinstance(expr.args[0], Identifier)):
244
426
  recv = expr.args[0].name
245
- if recv in getattr(self, "_matrix_specs", {}):
427
+ recv_spec = self._collection_spec_for_name(recv)
428
+ if recv_spec is not None and recv_spec.kind == "matrix":
246
429
  return recv
247
430
  return None
248
431
 
432
+ def _nullable_collection_target_cpp_type(
433
+ self,
434
+ *,
435
+ name: str | None = None,
436
+ target_node=None,
437
+ type_hint: str | None = None,
438
+ ) -> str | None:
439
+ """Return the exact nullable collection type for an RHS target.
440
+
441
+ Declarations need the explicit hint before their lexical collection
442
+ binding is activated; reassignments can use the active name registry,
443
+ while UDT fields are resolved from the target expression itself.
444
+ Arrays are deliberately excluded until their runtime representation
445
+ can distinguish ``na`` from a valid empty ID.
446
+ """
447
+ spec = self._type_spec_from_hint_name(type_hint) if type_hint else None
448
+ if spec is None and target_node is not None:
449
+ spec = self._type_spec_from_expr(target_node)
450
+ if spec is None and name is not None:
451
+ spec = self._collection_spec_for_name(name)
452
+ if spec is None or spec.kind not in {"map", "matrix"}:
453
+ return None
454
+ return self._type_spec_to_cpp(spec)
455
+
249
456
  def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None:
250
- # var/varip — handled as members in on_bar preamble
457
+ member_meta = getattr(
458
+ self.ctx, "var_member_metadata_by_node", {}
459
+ ).get(id(node))
460
+ if member_meta is not None:
461
+ declaration_is_series = (
462
+ self._safe_name(member_meta[1])
463
+ in self._series_var_member_names
464
+ )
465
+ else:
466
+ declaration_is_series = (
467
+ self._decl_binding_is_series(id(node), node.name)
468
+ )
469
+ # Primitive runtime var/varip initializers execute at the declaration
470
+ # site under a dedicated once flag. This preserves source order (a
471
+ # preceding input/plain/TA declaration is already available) and Pine's
472
+ # first-entry semantics for declarations nested in conditional blocks.
473
+ # Series, aggregates, constructor constants, and function-local vars
474
+ # retain their specialized initialization paths.
251
475
  if node.is_var or node.is_varip:
476
+ info = self._runtime_scalar_var_init_by_node.get(id(node))
477
+ if info is not None:
478
+ member_name = info["member_name"]
479
+ target = self._safe_name(member_name)
480
+ if self._active_var_remap and target in self._active_var_remap:
481
+ target = self._active_var_remap[target]
482
+ flag = self._runtime_var_init_flags.get(
483
+ (info["node_id"], target),
484
+ info["flag"],
485
+ )
486
+ target_expr = (
487
+ f"this->{target}"
488
+ if info.get("is_callable_scoped")
489
+ else target
490
+ )
491
+ flag_expr = (
492
+ f"this->{flag}"
493
+ if info.get("is_callable_scoped")
494
+ else flag
495
+ )
496
+ previous_input_name = self._current_input_var_name
497
+ self._current_input_var_name = node.name
498
+ try:
499
+ type_spec = info.get("type_spec")
500
+ target_cpp_type = info.get("drawing_cpp")
501
+ if (
502
+ target_cpp_type is None
503
+ and type_spec is not None
504
+ and type_spec.kind in {"map", "matrix"}
505
+ ):
506
+ target_cpp_type = self._type_spec_to_cpp(type_spec)
507
+ if (
508
+ target_cpp_type is not None
509
+ and type_spec is not None
510
+ and type_spec.kind in {"map", "matrix"}
511
+ and isinstance(node.value, (IfStmt, SwitchStmt))
512
+ ):
513
+ lines.append(f"{pad}if (!{flag_expr}) {{")
514
+ self._visit_if_switch_expr(
515
+ node.value,
516
+ target_expr,
517
+ lines,
518
+ len(pad) // 4 + 1,
519
+ target_cpp_type=target_cpp_type,
520
+ )
521
+ # The flag belongs after the complete selection. If it
522
+ # were emitted in an individual branch, unmatched paths
523
+ # or later arms could retry a Pine ``var`` initializer,
524
+ # violating first-reach / one-time semantics.
525
+ lines.append(f"{pad} {flag_expr} = true;")
526
+ lines.append(f"{pad}}}")
527
+ return
528
+ if target_cpp_type is not None:
529
+ init_cpp = self._visit_rhs_value(
530
+ node.value,
531
+ member_name,
532
+ target_cpp_type=target_cpp_type,
533
+ )
534
+ else:
535
+ init_cpp = self._visit_expr(node.value)
536
+ finally:
537
+ self._current_input_var_name = previous_input_name
538
+ if info.get("drawing_cpp") is None:
539
+ init_cpp = self._typed_na_init(
540
+ init_cpp, member_name, info["ptype"]
541
+ )
542
+ lines.append(f"{pad}if (!{flag_expr}) {{")
543
+ # A history-referenced persistent primitive is a Series<T> too,
544
+ # not just a drawing handle. The per-bar carry has already
545
+ # advanced it before this declaration-site one-shot runs, so
546
+ # replace the current slot rather than assigning a scalar to
547
+ # the Series object (or pushing a duplicate bar).
548
+ if info.get("is_series"):
549
+ lines.append(f"{pad} {target_expr}.update({init_cpp});")
550
+ else:
551
+ lines.append(f"{pad} {target_expr} = {init_cpp};")
552
+ lines.append(f"{pad} {flag_expr} = true;")
553
+ lines.append(f"{pad}}}")
252
554
  return
253
555
 
254
556
  safe = self._safe_name(node.name)
255
557
  # Apply per-call-site var remap (for function-local vars)
256
558
  if self._active_var_remap and safe in self._active_var_remap:
257
559
  safe = self._active_var_remap[safe]
258
- # Global-scope non-var vars are class members — emit assignment, not declaration
259
- is_global_member = node.name in self._global_member_vars
560
+ # Only a declaration in the script body can bind a hoisted global
561
+ # member. A callable-local declaration may legally shadow that global
562
+ # with the same raw name; treating it as the member emits an assignment
563
+ # into unrelated storage (and can assign a scalar into Series<T> when
564
+ # the global was promoted for an indirect history call).
565
+ is_global_member = (
566
+ getattr(self, "_active_func_name", None) is None
567
+ and node.name in self._global_member_vars
568
+ and (
569
+ id(node) in self._ordinary_global_var_decl_nodes
570
+ or (
571
+ node.name not in self._direct_program_binding_names
572
+ and node.name not in self._callable_state_raw_names
573
+ )
574
+ )
575
+ )
260
576
 
261
577
  def remember_local_type(cpp_type: str | None) -> None:
262
578
  if cpp_type and not is_global_member:
@@ -287,7 +603,7 @@ class StmtVisitor:
287
603
  self._enforce_enum_declared_before_input_enum(node.value)
288
604
  title = self._get_input_title(node.value, var_name=node.name)
289
605
  cpp_val = self._render_input_value(node.value, func_name_i, namespace_i, title)
290
- if node.name in self.ctx.series_vars:
606
+ if declaration_is_series:
291
607
  self._emit_history_series_write(lines, pad, safe, cpp_val)
292
608
  elif is_global_member:
293
609
  lines.append(f"{pad}{safe} = {cpp_val};")
@@ -301,10 +617,16 @@ class StmtVisitor:
301
617
  if isinstance(node.value, FuncCall):
302
618
  func_name, namespace = self._resolve_callee(node.value.callee)
303
619
  if namespace == "array" and func_name in ARRAY_NEW_CTORS | {"new", "from", "copy", "slice"}:
620
+ captured = self._callable_collection_bindings.get(id(node))
621
+ spec = (
622
+ captured
623
+ if captured is not None and captured.kind == "array"
624
+ else self._type_spec_from_expr(node.value)
625
+ or self._array_spec_for_name(node.name)
626
+ )
627
+ init = self._visit_expr(node.value)
304
628
  self._array_vars.add(node.name)
305
- spec = self._type_spec_from_expr(node.value) or self._array_spec_for_name(node.name)
306
629
  self._collection_types.setdefault(node.name, spec)
307
- init = self._visit_expr(node.value)
308
630
  cpp_type = self._type_spec_to_cpp(spec)
309
631
  if is_global_member:
310
632
  lines.append(f"{pad}{safe} = {init};")
@@ -318,9 +640,13 @@ class StmtVisitor:
318
640
  if namespace == "matrix" and func_name == "new":
319
641
  targs = self._template_args_from_call(node.value) if hasattr(node.value, "annotations") else []
320
642
  elem_spec = self._type_spec_from_hint_name(targs[0]) if targs else TypeSpec.primitive("float")
321
- spec = TypeSpec.matrix(elem_spec)
322
- self._matrix_specs[node.name] = spec
323
- self._collection_types[node.name] = spec
643
+ captured = self._callable_collection_bindings.get(id(node))
644
+ spec = (
645
+ captured
646
+ if captured is not None and captured.kind == "matrix"
647
+ else TypeSpec.matrix(elem_spec)
648
+ )
649
+ elem_spec = spec.element or elem_spec
324
650
  cpp_type = self._type_spec_to_cpp(spec)
325
651
  if len(node.value.args) >= 2:
326
652
  r = self._visit_expr(node.value.args[0])
@@ -329,6 +655,8 @@ class StmtVisitor:
329
655
  init = f"{cpp_type}::new_({r}, {c}, {v})"
330
656
  else:
331
657
  init = f"{cpp_type}::new_(0, 0, {self._default_for_spec(elem_spec)})"
658
+ self._matrix_specs[node.name] = spec
659
+ self._collection_types[node.name] = spec
332
660
  if is_global_member:
333
661
  lines.append(f"{pad}{safe} = {init};")
334
662
  else:
@@ -341,12 +669,20 @@ class StmtVisitor:
341
669
  # rejects ``double = PineMatrix``. The RHS expression itself is
342
670
  # already lowered to the right ``m.inv()`` form by visit_call.
343
671
  if namespace == "matrix" and func_name in MATRIX_RETURNING_METHODS:
344
- recv_spec = self._matrix_specs.get(self._extract_receiver_name(node.value))
345
- if recv_spec is None:
672
+ recv_name = self._extract_receiver_name(node.value)
673
+ recv_spec = (
674
+ self._collection_spec_for_name(recv_name)
675
+ if recv_name is not None
676
+ else None
677
+ )
678
+ if recv_spec is None or recv_spec.kind != "matrix":
346
679
  recv_spec = TypeSpec.matrix(TypeSpec.primitive("float"))
680
+ captured = self._callable_collection_bindings.get(id(node))
681
+ if captured is not None and captured.kind == "matrix":
682
+ recv_spec = captured
683
+ init = self._visit_expr(node.value)
347
684
  self._matrix_specs[node.name] = recv_spec
348
685
  self._collection_types[node.name] = recv_spec
349
- init = self._visit_expr(node.value)
350
686
  cpp_type = self._type_spec_to_cpp(recv_spec)
351
687
  if is_global_member:
352
688
  lines.append(f"{pad}{safe} = {init};")
@@ -354,14 +690,21 @@ class StmtVisitor:
354
690
  lines.append(f"{pad}{cpp_type} {safe} = {init};")
355
691
  return
356
692
  if namespace == "map" and func_name == "new":
693
+ captured = self._callable_collection_bindings.get(id(node))
694
+ spec = (
695
+ captured
696
+ if captured is not None and captured.kind == "map"
697
+ else self._type_spec_from_expr(node.value)
698
+ or self._map_spec_for_name(node.name)
699
+ )
357
700
  self._map_vars.add(node.name)
358
- spec = self._type_spec_from_expr(node.value) or self._map_spec_for_name(node.name)
359
701
  self._collection_types.setdefault(node.name, spec)
360
702
  cpp_type = self._type_spec_to_cpp(spec)
703
+ init = f"{cpp_type}::new_()"
361
704
  if is_global_member:
362
- lines.append(f"{pad}{safe} = {cpp_type}();")
705
+ lines.append(f"{pad}{safe} = {init};")
363
706
  else:
364
- lines.append(f"{pad}{cpp_type} {safe};")
707
+ lines.append(f"{pad}{cpp_type} {safe} = {init};")
365
708
  return
366
709
 
367
710
  # Visual/drawing function assignments (line.new, label.new, box.new,
@@ -389,7 +732,7 @@ class StmtVisitor:
389
732
  f"(history_advances_new_bar() ? {ta_name}.compute({compute_args}) "
390
733
  f": {ta_name}.recompute({compute_args}))"
391
734
  )
392
- if node.name in self.ctx.series_vars:
735
+ if declaration_is_series:
393
736
  self._emit_history_series_write(lines, pad, safe, ta_expr)
394
737
  elif is_global_member:
395
738
  lines.append(f"{pad}{safe} = {ta_expr};")
@@ -398,41 +741,58 @@ class StmtVisitor:
398
741
  return
399
742
 
400
743
  # Non-var series variable — push instead of declare
401
- if node.name in self.ctx.series_vars:
402
- cpp_val = self._visit_expr(node.value)
744
+ if declaration_is_series:
745
+ target_cpp_type = self._type_for_decl(node)
746
+ cpp_val = self._visit_rhs_value(
747
+ node.value,
748
+ node.name,
749
+ target_cpp_type=(
750
+ target_cpp_type
751
+ if target_cpp_type in DRAWING_TYPE_TO_CPP.values()
752
+ else None
753
+ ),
754
+ )
403
755
  self._emit_history_series_write(lines, pad, safe, cpp_val)
404
756
  return
405
757
 
406
758
  # If/switch expression: x = if cond ... else ...
407
759
  if isinstance(node.value, (IfStmt, SwitchStmt)):
760
+ cpp_type = self._type_for_decl(node) if not is_global_member else None
761
+ selection_cpp_type = (
762
+ cpp_type
763
+ if (cpp_type is not None
764
+ and (self._is_nullable_collection_cpp_type(cpp_type)
765
+ or cpp_type in DRAWING_TYPE_TO_CPP.values()
766
+ or cpp_type in self._udt_defs))
767
+ else self._nullable_collection_target_cpp_type(
768
+ name=node.name,
769
+ type_hint=node.type_hint,
770
+ )
771
+ )
772
+ if selection_cpp_type is None:
773
+ selection_cpp_type = self._udt_target_cpp_type(
774
+ target_name=node.name,
775
+ type_hint=node.type_hint,
776
+ )
777
+ if selection_cpp_type is None:
778
+ selection_cpp_type = self._drawing_target_cpp_type(
779
+ node.name,
780
+ cpp_type,
781
+ )
408
782
  if not is_global_member:
409
- cpp_type = self._type_for_decl(node)
410
783
  default = self._default_for_type(cpp_type)
411
784
  lines.append(f"{pad}{cpp_type} {safe} = {default};")
785
+ remember_local_type(cpp_type)
412
786
  indent = len(pad) // 4
413
- self._visit_if_switch_expr(node.value, safe, lines, indent)
787
+ self._visit_if_switch_expr(
788
+ node.value,
789
+ safe,
790
+ lines,
791
+ indent,
792
+ target_cpp_type=selection_cpp_type,
793
+ )
414
794
  return
415
795
 
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
796
  # Collection lvalue alias (BUG 2): a local bound to an existing array /
437
797
  # map / matrix lvalue (or a ternary/switch selecting same-typed ones)
438
798
  # and later MUTATED through must ALIAS the member, not value-copy — Pine
@@ -444,6 +804,8 @@ class StmtVisitor:
444
804
  if not is_global_member:
445
805
  coll_spec = self._collection_lvalue_selection_spec(node.value)
446
806
  if coll_spec is not None and self._collection_local_must_alias(node):
807
+ cpp_type = self._type_spec_to_cpp(coll_spec)
808
+ cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type)
447
809
  # Register the local's collection kind so subsequent
448
810
  # ``.size()/.get()/.unshift()`` dispatch resolves correctly.
449
811
  self._collection_types[node.name] = coll_spec
@@ -453,30 +815,30 @@ class StmtVisitor:
453
815
  self._map_vars.add(node.name)
454
816
  elif coll_spec.kind == "matrix":
455
817
  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};")
818
+ # PineMap is itself a shared-ID handle. Copying it by value is
819
+ # the correct alias operation and, unlike a C++ reference,
820
+ # keeps a later local rebind local. Arrays/matrices still need
821
+ # their established reference alias route.
822
+ ref = "" if coll_spec.kind == "map" else "&"
823
+ lines.append(f"{pad}{cpp_type}{ref} {safe} = {cpp_val};")
459
824
  return
460
825
 
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
-
477
826
  # General declaration
478
827
  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)
828
+ target_cpp_type = cpp_type or self._nullable_collection_target_cpp_type(
829
+ name=node.name,
830
+ type_hint=node.type_hint,
831
+ )
832
+ if target_cpp_type is None:
833
+ target_cpp_type = self._udt_target_cpp_type(
834
+ target_name=node.name,
835
+ type_hint=node.type_hint,
836
+ )
837
+ cpp_val = self._visit_rhs_value(
838
+ node.value,
839
+ node.name,
840
+ target_cpp_type=target_cpp_type,
841
+ )
480
842
  if is_global_member:
481
843
  lines.append(f"{pad}{safe} = {cpp_val};")
482
844
  else:
@@ -502,20 +864,124 @@ class StmtVisitor:
502
864
  return f"std::fmod((double)({target_read}), (double)({val_cpp}))"
503
865
  return None
504
866
 
867
+ def _matrix_rhs_specs(self, value: ASTNode | None) -> list[TypeSpec]:
868
+ """Return every known concrete matrix type in an RHS selection.
869
+
870
+ ``_type_spec_from_expr`` intentionally returns ``None`` for a
871
+ selection whose concrete arms disagree. Reassignment validation must
872
+ still inspect those arms individually: otherwise
873
+ ``matrix<int> := cond ? matrix<int> : matrix<float>`` evades the type
874
+ check precisely because the complete selection has no unified type.
875
+ Explicit and implicit ``na`` arms contribute no concrete type.
876
+ """
877
+ if self._selection_node_is_na(value):
878
+ return []
879
+ if isinstance(value, Ternary):
880
+ return (
881
+ self._matrix_rhs_specs(value.true_val)
882
+ + self._matrix_rhs_specs(value.false_val)
883
+ )
884
+ if isinstance(value, IfStmt):
885
+ return (
886
+ self._matrix_rhs_specs(
887
+ self._selection_terminal_expr(value.body)
888
+ )
889
+ + self._matrix_rhs_specs(
890
+ self._selection_terminal_expr(value.else_body)
891
+ )
892
+ )
893
+ if isinstance(value, SwitchStmt):
894
+ specs: list[TypeSpec] = []
895
+ for _case_expr, case_body in value.cases:
896
+ specs.extend(
897
+ self._matrix_rhs_specs(
898
+ self._selection_terminal_expr(case_body)
899
+ )
900
+ )
901
+ specs.extend(
902
+ self._matrix_rhs_specs(
903
+ self._selection_terminal_expr(value.default_body)
904
+ )
905
+ )
906
+ return specs
907
+ spec = self._type_spec_from_expr(value)
908
+ return [spec] if spec is not None and spec.kind == "matrix" else []
909
+
910
+ def _validate_matrix_reassignment(
911
+ self,
912
+ node: Assignment,
913
+ target_name: str | None,
914
+ ) -> None:
915
+ """Reject any known matrix RHS whose element type changes the LHS.
916
+
917
+ This check belongs to the lexical target rather than any particular
918
+ storage representation. A matrix may be a persistent ``var``, a plain
919
+ global member, a callable local, or a UDT field, but Pine forbids
920
+ changing its declared element type in every case.
921
+ """
922
+ if node.op != ":=":
923
+ return
924
+ target_spec = self._type_spec_from_expr(node.target)
925
+ if target_spec is None and target_name is not None:
926
+ target_spec = self._collection_spec_for_name(target_name)
927
+ if target_spec is None or target_spec.kind != "matrix":
928
+ return
929
+ target_label = target_name
930
+ if target_label is None and isinstance(node.target, MemberAccess):
931
+ target_label = node.target.member
932
+ target_label = target_label or "<expression>"
933
+ for rhs_spec in self._matrix_rhs_specs(node.value):
934
+ if rhs_spec.element == target_spec.element:
935
+ continue
936
+ self._codegen_error(
937
+ node,
938
+ f"matrix '{target_label}' element type mismatch on reassignment: "
939
+ f"expected {self._type_spec_to_cpp(target_spec)}, "
940
+ f"got {self._type_spec_to_cpp(rhs_spec)}",
941
+ )
942
+
505
943
  def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> None:
506
944
  if isinstance(node.value, FuncCall) and self._is_skip_expr(node.value):
507
945
  return
508
946
 
947
+ # Validate against the active lexical target before any syntax-specific
948
+ # early return. In particular, if/switch expressions and UDT-field
949
+ # targets otherwise bypass the ordinary identifier assignment path.
950
+ target_name = self._get_target_name(node.target)
951
+ self._validate_matrix_reassignment(node, target_name)
952
+
509
953
  # If/switch expression in assignment: x := if cond ...
510
954
  if isinstance(node.value, (IfStmt, SwitchStmt)):
511
- target_name = self._get_target_name(node.target)
512
- safe = self._safe_name(target_name) if target_name else self._visit_expr(node.target)
955
+ safe = (
956
+ self._safe_name(target_name)
957
+ if target_name
958
+ else self._visit_mutable_expr(node.target)
959
+ )
960
+ selection_cpp_type = self._nullable_collection_target_cpp_type(
961
+ name=target_name,
962
+ target_node=node.target if target_name is None else None,
963
+ )
964
+ if selection_cpp_type is None:
965
+ selection_cpp_type = self._udt_target_cpp_type(
966
+ target_name=target_name,
967
+ target_node=node.target if target_name is None else None,
968
+ )
969
+ if selection_cpp_type is None:
970
+ selection_cpp_type = self._drawing_target_cpp_type(
971
+ target_name,
972
+ None,
973
+ )
513
974
  indent = len(pad) // 4
514
- self._visit_if_switch_expr(node.value, safe, lines, indent)
975
+ self._visit_if_switch_expr(
976
+ node.value,
977
+ safe,
978
+ lines,
979
+ indent,
980
+ target_cpp_type=selection_cpp_type,
981
+ )
515
982
  return
516
983
 
517
984
  # Get target name
518
- target_name = self._get_target_name(node.target)
519
985
  if target_name is None:
520
986
  # Assignment to a UDT field that was dropped from the emitted
521
987
  # struct because it had a drawing-only type (label/line/box/
@@ -533,8 +999,17 @@ class StmtVisitor:
533
999
  )
534
1000
  return
535
1001
  # General expression target (e.g., member access)
536
- target_cpp = self._visit_expr(node.target)
537
- val_cpp = self._visit_expr(node.value)
1002
+ target_cpp = self._visit_mutable_expr(node.target)
1003
+ target_cpp_type = self._nullable_collection_target_cpp_type(
1004
+ target_node=node.target,
1005
+ )
1006
+ if target_cpp_type is None:
1007
+ target_cpp_type = self._udt_target_cpp_type(
1008
+ target_node=node.target,
1009
+ )
1010
+ val_cpp = self._visit_rhs_value(
1011
+ node.value, target_cpp_type=target_cpp_type
1012
+ )
538
1013
  if node.op == ":=":
539
1014
  lines.append(f"{pad}{target_cpp} = {val_cpp};")
540
1015
  else:
@@ -550,14 +1025,15 @@ class StmtVisitor:
550
1025
  if self._active_var_remap and safe in self._active_var_remap:
551
1026
  safe = self._active_var_remap[safe]
552
1027
 
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
-
559
- if target_name in self.ctx.series_vars:
560
- val_cpp = self._visit_expr(node.value)
1028
+ if self._binding_is_series(target_name, safe):
1029
+ val_cpp = self._visit_rhs_value(
1030
+ node.value,
1031
+ target_name,
1032
+ target_cpp_type=self._drawing_target_cpp_type(
1033
+ target_name,
1034
+ None,
1035
+ ),
1036
+ )
561
1037
  if node.op == ":=":
562
1038
  lines.append(f"{pad}{safe}.update({val_cpp});")
563
1039
  else:
@@ -570,32 +1046,16 @@ class StmtVisitor:
570
1046
  op_char = node.op[0] # e.g., "+" from "+="
571
1047
  lines.append(f"{pad}{safe}.update({safe}[0] {op_char} {val_cpp});")
572
1048
  elif target_name in self._var_names:
573
- if node.op == ":=" and target_name in self._matrix_specs and isinstance(node.value, FuncCall):
574
- rhs_fn, rhs_ns = self._resolve_callee(node.value.callee)
575
- rhs_spec = None
576
- if rhs_ns == "matrix" and rhs_fn == "new":
577
- targs = self._template_args_from_call(node.value) if hasattr(node.value, "annotations") else []
578
- elem = self._type_spec_from_hint_name(targs[0]) if targs else TypeSpec.primitive("float")
579
- rhs_spec = TypeSpec.matrix(elem)
580
- elif rhs_ns == "matrix" and rhs_fn in MATRIX_RETURNING_METHODS:
581
- rcv = self._extract_receiver_name(node.value)
582
- rhs_spec = self._matrix_specs.get(rcv)
583
- if rhs_spec is not None:
584
- lhs_spec = self._matrix_specs[target_name]
585
- if rhs_spec.element != lhs_spec.element:
586
- self._codegen_error(
587
- node,
588
- f"matrix '{target_name}' element type mismatch on reassignment: "
589
- f"expected {self._type_spec_to_cpp(lhs_spec)}, "
590
- f"got {self._type_spec_to_cpp(rhs_spec)}",
591
- )
592
1049
  # A bare-``na`` reassignment must adopt the target's declared scalar
593
1050
  # type (``x := na`` -> ``na<int>()`` not ``na<double>()``); otherwise
594
1051
  # a double NaN is stored into an int/int64_t/bool member (UB, defeats
595
1052
  # is_na<T>()). Only computed for bare na — every other RHS is
596
1053
  # unaffected.
597
- tct = (self._na_reassign_cpp_type(target_name)
598
- if self._is_na_expr(node.value) else None)
1054
+ tct = self._nullable_collection_target_cpp_type(name=target_name)
1055
+ if tct is None:
1056
+ tct = self._udt_target_cpp_type(target_name=target_name)
1057
+ if tct is None and self._is_na_expr(node.value):
1058
+ tct = self._na_reassign_cpp_type(target_name)
599
1059
  val_cpp = self._visit_rhs_value(node.value, target_name, target_cpp_type=tct)
600
1060
  if node.op == ":=":
601
1061
  lines.append(f"{pad}{safe} = {val_cpp};")
@@ -606,8 +1066,11 @@ class StmtVisitor:
606
1066
  else:
607
1067
  lines.append(f"{pad}{safe} {node.op} {val_cpp};")
608
1068
  else:
609
- tct = (self._na_reassign_cpp_type(target_name)
610
- if self._is_na_expr(node.value) else None)
1069
+ tct = self._nullable_collection_target_cpp_type(name=target_name)
1070
+ if tct is None:
1071
+ tct = self._udt_target_cpp_type(target_name=target_name)
1072
+ if tct is None and self._is_na_expr(node.value):
1073
+ tct = self._na_reassign_cpp_type(target_name)
611
1074
  val_cpp = self._visit_rhs_value(node.value, target_name, target_cpp_type=tct)
612
1075
  if node.op == ":=":
613
1076
  lines.append(f"{pad}{safe} = {val_cpp};")
@@ -619,6 +1082,143 @@ class StmtVisitor:
619
1082
  lines.append(f"{pad}{safe} {node.op} {val_cpp};")
620
1083
 
621
1084
  def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> None:
1085
+ is_top_level = any(id(node) == id(stmt) for stmt in self.ctx.ast.body)
1086
+ global_targets = (
1087
+ set(getattr(self.ctx, "ordinary_global_binding_names", set()))
1088
+ & self._qualified_func_var_raw_names
1089
+ if is_top_level
1090
+ else set()
1091
+ )
1092
+ security_result_fields: list[str] | None = None
1093
+
1094
+ # Exact supported request.security tuples are authoritative Program
1095
+ # globals: a later UDF reads their class-member storage, so a local
1096
+ # structured binding would shadow the members with fresh values while
1097
+ # leaving the UDF-visible state stale. Keep this narrow to security
1098
+ # tuples whose analyzer metadata proves a supported helper family, an
1099
+ # exact direct literal, or a known TA tuple result. Ordinary
1100
+ # non-security heterogeneous tuples retain their established local
1101
+ # destructuring behavior.
1102
+ if is_top_level and isinstance(node.value, FuncCall):
1103
+ func_name, namespace = self._resolve_callee(node.value.callee)
1104
+ if namespace == "request" and func_name == "security":
1105
+ param_names = [
1106
+ "symbol", "timeframe", "expression", "gaps",
1107
+ "lookahead", "ignore_invalid_symbol", "currency",
1108
+ ]
1109
+ all_args = list(node.value.args)
1110
+ for idx, param_name in enumerate(param_names):
1111
+ if param_name in node.value.kwargs:
1112
+ while len(all_args) <= idx:
1113
+ all_args.append(None)
1114
+ all_args[idx] = node.value.kwargs[param_name]
1115
+ expr_node = all_args[2] if len(all_args) > 2 else None
1116
+ info = next(
1117
+ (
1118
+ item
1119
+ for item in self._security_calls
1120
+ if not item.get("is_lower_tf_array")
1121
+ and item.get("expr_node") is expr_node
1122
+ ),
1123
+ None,
1124
+ )
1125
+ if info is not None:
1126
+ tuple_size = info.get("tuple_size", 0)
1127
+ tuple_types = tuple(
1128
+ info.get("tuple_element_types", ()) or ()
1129
+ )
1130
+ numeric_tuple = (
1131
+ tuple_size >= 2
1132
+ and len(tuple_types) == tuple_size
1133
+ and all(
1134
+ item in (PineType.INT, PineType.FLOAT)
1135
+ for item in tuple_types
1136
+ )
1137
+ )
1138
+ bool_tuple = (
1139
+ tuple_size >= 2
1140
+ and len(tuple_types) == tuple_size
1141
+ and all(item == PineType.BOOL for item in tuple_types)
1142
+ )
1143
+ exact_direct_tuple = (
1144
+ isinstance(expr_node, TupleLiteral)
1145
+ and tuple_size >= 2
1146
+ and len(tuple_types) == tuple_size
1147
+ and all(
1148
+ item in (
1149
+ PineType.INT,
1150
+ PineType.FLOAT,
1151
+ PineType.BOOL,
1152
+ )
1153
+ for item in tuple_types
1154
+ )
1155
+ )
1156
+ ta_site = self._get_ta_site(expr_node)
1157
+ known_ta_tuple = (
1158
+ ta_site is not None
1159
+ and self._ta_name_from_site(ta_site) in TA_TUPLE_FIELDS
1160
+ )
1161
+ if known_ta_tuple:
1162
+ security_result_fields = TA_TUPLE_FIELDS[
1163
+ self._ta_name_from_site(ta_site)
1164
+ ]
1165
+ if (
1166
+ numeric_tuple
1167
+ or bool_tuple
1168
+ or exact_direct_tuple
1169
+ or known_ta_tuple
1170
+ ):
1171
+ global_targets.update(
1172
+ name for name in node.names if name != "_"
1173
+ )
1174
+
1175
+ def emit_call_tuple(call_expr: str) -> None:
1176
+ """Destructure once, routing exact history elements to Series.
1177
+
1178
+ A structured binding always creates scalar C++ locals, which would
1179
+ shadow the class Series storage required by a later ``x[n]`` read.
1180
+ It also cannot assign an existing top-level class member. Materialize
1181
+ the tuple once whenever either case applies; ordinary lexical scalar
1182
+ elements remain locals, Series elements advance their remapped
1183
+ members, and top-level scalars assign their class storage.
1184
+ """
1185
+ series_names = {
1186
+ name
1187
+ for name in node.names
1188
+ if name != "_"
1189
+ and self._decl_binding_is_series(id(node), name)
1190
+ }
1191
+ if not series_names and not global_targets.intersection(node.names):
1192
+ binding_names = ", ".join(node.names)
1193
+ lines.append(f"{pad}auto [{binding_names}] = {call_expr};")
1194
+ return
1195
+
1196
+ temp = f"_tuple_result_{self._tuple_assign_counter}"
1197
+ self._tuple_assign_counter += 1
1198
+ lines.append(f"{pad}auto {temp} = {call_expr};")
1199
+ for idx, name in enumerate(node.names):
1200
+ if name == "_":
1201
+ continue
1202
+ value = (
1203
+ f"{temp}.{security_result_fields[idx]}"
1204
+ if (
1205
+ security_result_fields is not None
1206
+ and idx < len(security_result_fields)
1207
+ )
1208
+ else f"std::get<{idx}>({temp})"
1209
+ )
1210
+ safe = self._safe_name(name)
1211
+ if name in series_names:
1212
+ if safe in self._active_var_remap:
1213
+ safe = self._active_var_remap[safe]
1214
+ self._emit_history_series_write(
1215
+ lines, pad, safe, value
1216
+ )
1217
+ elif name in global_targets:
1218
+ lines.append(f"{pad}{safe} = {value};")
1219
+ else:
1220
+ lines.append(f"{pad}auto {safe} = {value};")
1221
+
622
1222
  site = self._get_ta_site(node.value)
623
1223
  if site is not None:
624
1224
  compute_args = self._ta_compute_args_for_site(site)
@@ -646,11 +1246,17 @@ class StmtVisitor:
646
1246
  # fresh ``double`` local would shadow the member and make
647
1247
  # ``dir[n]`` a scalar subscript (clang error). Non-series
648
1248
  # destructured names keep the plain scalar declaration.
649
- if name in self.ctx.series_vars:
1249
+ if self._decl_binding_is_series(id(node), name):
650
1250
  safe = self._safe_name(name)
1251
+ if safe in self._active_var_remap:
1252
+ safe = self._active_var_remap[safe]
651
1253
  self._emit_history_series_write(
652
1254
  lines, pad, safe, field_expr
653
1255
  )
1256
+ elif name in global_targets:
1257
+ lines.append(
1258
+ f"{pad}{self._safe_name(name)} = {field_expr};"
1259
+ )
654
1260
  else:
655
1261
  lines.append(f"{pad}double {name} = {field_expr};")
656
1262
  return
@@ -659,14 +1265,12 @@ class StmtVisitor:
659
1265
  if isinstance(node.value, FuncCall):
660
1266
  func_name, namespace = self._resolve_callee(node.value.callee)
661
1267
  if namespace == "request" and func_name == "security":
662
- binding_names = ", ".join(n for n in node.names if n != "_")
663
1268
  call_expr = self._visit_func_call(node.value)
664
- lines.append(f"{pad}auto [{binding_names}] = {call_expr};")
1269
+ emit_call_tuple(call_expr)
665
1270
  return
666
1271
  if func_name and namespace is None and func_name in self._func_names:
667
- binding_names = ", ".join(node.names)
668
1272
  call_expr = self._visit_func_call(node.value)
669
- lines.append(f"{pad}auto [{binding_names}] = {call_expr};")
1273
+ emit_call_tuple(call_expr)
670
1274
  return
671
1275
 
672
1276
  # UDT instance method returning a tuple: ``[a, b, c] = receiver.method(...)``.
@@ -682,64 +1286,188 @@ class StmtVisitor:
682
1286
  callee = node.value.callee
683
1287
  if isinstance(callee, MemberAccess):
684
1288
  recv_spec = self._type_spec_from_expr(callee.object)
685
- if recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name:
686
- method_key = f"{recv_spec.name}.{callee.member}"
1289
+ receiver_name = method_receiver_type_name(recv_spec)
1290
+ if receiver_name is not None:
1291
+ method_key = f"{receiver_name}.{callee.member}"
687
1292
  fi_u = self._func_info_map.get(method_key)
688
1293
  if (fi_u is not None
689
1294
  and getattr(fi_u, "is_udt_method", False)
690
1295
  and getattr(fi_u, "returns_tuple", False)):
691
- binding_names = ", ".join(node.names)
692
1296
  call_expr = self._visit_func_call(node.value)
693
- lines.append(f"{pad}auto [{binding_names}] = {call_expr};")
1297
+ emit_call_tuple(call_expr)
694
1298
  return
695
1299
 
696
1300
  lines.append(f"{pad}/* unsupported tuple assignment */")
697
1301
 
698
- def _push_block_var_remap(self, node):
699
- """Activate block-scoped var renames for ``node`` (BUG 1). Returns the
700
- previous ``_active_var_remap`` to restore (or ``_NO_BLOCK_REMAP`` if this
701
- block owns no renames). Renames are MERGED over the inherited remap so
702
- nested blocks keep any enclosing func-clone / outer-block mapping."""
703
- renames = self._block_var_renames.get(id(node))
704
- if not renames:
705
- return _NO_BLOCK_REMAP
706
- saved = self._active_var_remap
707
- self._active_var_remap = {**saved, **renames}
708
- return saved
1302
+ def _tuple_binding_cpp_types(self, node: TupleAssign) -> list[str]:
1303
+ """Exact supported tuple element types for later lexical operations."""
1304
+ count = len(node.names)
1305
+ if not isinstance(node.value, FuncCall):
1306
+ return ["double"] * count
1307
+ func_name, namespace = self._resolve_callee(node.value.callee)
1308
+ fi = None
1309
+ if namespace is None:
1310
+ fi = self._func_info_map.get(func_name)
1311
+ elif isinstance(node.value.callee, MemberAccess):
1312
+ recv_spec = self._type_spec_from_expr(node.value.callee.object)
1313
+ receiver_name = method_receiver_type_name(recv_spec)
1314
+ if receiver_name is not None:
1315
+ fi = self._func_info_map.get(
1316
+ f"{receiver_name}.{node.value.callee.member}"
1317
+ )
1318
+ if (fi is not None
1319
+ and fi.node is not None
1320
+ and getattr(fi, "returns_tuple", False)):
1321
+ return self._infer_tuple_types(fi.node, count)
1322
+ return ["double"] * count
1323
+
1324
+ def _push_block_var_remap(self, owner):
1325
+ """Activate exact lexical metadata for one branch/loop body.
1326
+
1327
+ Persistent-var renames use copy-on-write and activate only after their
1328
+ declarations. Collection registries likewise use copy-on-write and
1329
+ declarations activate their bindings in
1330
+ source order, and block exit restores the outer state. Thus a sibling
1331
+ block can reuse the same raw name without either pre-shadowing earlier
1332
+ statements or controlling dispatch in the other branch.
1333
+ """
1334
+ previous_map_visible = getattr(
1335
+ self, "_block_map_binding_visible", False
1336
+ )
1337
+ previous_map_depth = getattr(self, "_block_map_visibility_depth", 0)
1338
+ self._block_map_visibility_depth = previous_map_depth + 1
1339
+ saved_drawing_types = self._lexical_drawing_types
1340
+ self._lexical_drawing_types = dict(saved_drawing_types)
1341
+ saved_udt_types = self._lexical_udt_types
1342
+ self._lexical_udt_types = dict(saved_udt_types)
1343
+ saved_series_bindings = self._lexical_series_bindings
1344
+ self._lexical_series_bindings = dict(saved_series_bindings)
1345
+ saved_known_tombstones = self._lexical_known_var_tombstones
1346
+ self._lexical_known_var_tombstones = set(
1347
+ saved_known_tombstones
1348
+ )
709
1349
 
710
- def _pop_block_var_remap(self, saved) -> None:
711
- if saved is not _NO_BLOCK_REMAP:
712
- self._active_var_remap = saved
1350
+ renames = self._block_var_renames.get(id(owner))
1351
+ collection_specs = self._block_collection_types.get(id(owner))
1352
+ if not renames and collection_specs is None:
1353
+ return (
1354
+ _NO_BLOCK_REMAP,
1355
+ None,
1356
+ previous_map_visible,
1357
+ previous_map_depth,
1358
+ saved_drawing_types,
1359
+ saved_udt_types,
1360
+ saved_series_bindings,
1361
+ saved_known_tombstones,
1362
+ )
1363
+ saved_remap = self._active_var_remap
1364
+ if renames:
1365
+ # Do not pre-shadow an outer binding at block entry. _visit_stmt
1366
+ # installs each exact rename immediately after that VarDecl's RHS.
1367
+ self._active_var_remap = dict(saved_remap)
1368
+
1369
+ saved_collections = None
1370
+ if collection_specs is not None:
1371
+ saved_collections = (
1372
+ self._current_func_collection_specs,
1373
+ self._current_func_collection_shadows,
1374
+ self._collection_types,
1375
+ self._array_vars,
1376
+ self._map_vars,
1377
+ self._matrix_specs,
1378
+ )
1379
+ self._current_func_collection_specs = dict(
1380
+ self._current_func_collection_specs
1381
+ )
1382
+ self._current_func_collection_shadows = set(
1383
+ self._current_func_collection_shadows
1384
+ )
1385
+ self._collection_types = dict(self._collection_types)
1386
+ self._array_vars = set(self._array_vars)
1387
+ self._map_vars = set(self._map_vars)
1388
+ self._matrix_specs = dict(self._matrix_specs)
1389
+ return (
1390
+ saved_remap,
1391
+ saved_collections,
1392
+ previous_map_visible,
1393
+ previous_map_depth,
1394
+ saved_drawing_types,
1395
+ saved_udt_types,
1396
+ saved_series_bindings,
1397
+ saved_known_tombstones,
1398
+ )
713
1399
 
714
- def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None:
715
- _blk_saved = self._push_block_var_remap(node)
1400
+ def _pop_block_var_remap(self, saved) -> None:
1401
+ (
1402
+ saved_remap,
1403
+ saved_collections,
1404
+ previous_map_visible,
1405
+ previous_map_depth,
1406
+ saved_drawing_types,
1407
+ saved_udt_types,
1408
+ saved_series_bindings,
1409
+ saved_known_tombstones,
1410
+ ) = saved
716
1411
  try:
717
- self._visit_if_body(node, lines, indent)
1412
+ if saved_remap is not _NO_BLOCK_REMAP:
1413
+ self._active_var_remap = saved_remap
1414
+ if saved_collections is not None:
1415
+ (
1416
+ self._current_func_collection_specs,
1417
+ self._current_func_collection_shadows,
1418
+ self._collection_types,
1419
+ self._array_vars,
1420
+ self._map_vars,
1421
+ self._matrix_specs,
1422
+ ) = saved_collections
718
1423
  finally:
719
- self._pop_block_var_remap(_blk_saved)
1424
+ self._lexical_drawing_types = saved_drawing_types
1425
+ self._lexical_udt_types = saved_udt_types
1426
+ self._lexical_series_bindings = saved_series_bindings
1427
+ self._lexical_known_var_tombstones = saved_known_tombstones
1428
+ self._block_map_binding_visible = previous_map_visible
1429
+ self._block_map_visibility_depth = previous_map_depth
1430
+
1431
+ def _visit_block_statements(self, body: list, lines: list[str],
1432
+ indent: int) -> None:
1433
+ """Emit one lexical branch/loop body with its exact var remap."""
1434
+ saved = self._push_block_var_remap(body)
1435
+ try:
1436
+ for stmt in body:
1437
+ self._visit_stmt(stmt, lines, indent)
1438
+ finally:
1439
+ self._pop_block_var_remap(saved)
1440
+
1441
+ def _emit_block_with_assign(
1442
+ self,
1443
+ body: list,
1444
+ target: str,
1445
+ lines: list[str],
1446
+ indent: int,
1447
+ target_cpp_type: str | None = None,
1448
+ ) -> None:
1449
+ """Expression-body counterpart of :meth:`_visit_block_statements`."""
1450
+ saved = self._push_block_var_remap(body)
1451
+ try:
1452
+ self._emit_body_with_assign(
1453
+ body,
1454
+ target,
1455
+ lines,
1456
+ indent,
1457
+ target_cpp_type=target_cpp_type,
1458
+ )
1459
+ finally:
1460
+ self._pop_block_var_remap(saved)
1461
+
1462
+ def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None:
1463
+ self._visit_if_body(node, lines, indent)
720
1464
 
721
1465
  def _visit_if_body(self, node: IfStmt, lines: list[str], indent: int) -> None:
722
1466
  pad = " " * indent
723
1467
 
724
- # TA hoisting: inside per-call-site function variants, execute ALL
725
- # statements unconditionally (PineScript execution model) but wrap
726
- # the result assignment in the condition.
727
- if self._in_ta_func_variant and self._if_body_has_ta(node.body):
728
- cond = self._visit_expr(node.condition)
729
- self._hoist_if_body(node.body, cond, lines, pad, indent)
730
- # Handle else_body similarly
731
- if node.else_body:
732
- if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt):
733
- self._visit_if(node.else_body[0], lines, indent)
734
- else:
735
- neg_cond = f"!({cond})"
736
- self._hoist_if_body(node.else_body, neg_cond, lines, pad, indent)
737
- return
738
-
739
1468
  cond = self._visit_expr(node.condition)
740
1469
  lines.append(f"{pad}if ({cond}) {{")
741
- for s in node.body:
742
- self._visit_stmt(s, lines, indent + 1)
1470
+ self._visit_block_statements(node.body, lines, indent + 1)
743
1471
  lines.append(f"{pad}}}")
744
1472
  if node.else_body:
745
1473
  if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt):
@@ -747,8 +1475,9 @@ class StmtVisitor:
747
1475
  self._visit_if(node.else_body[0], lines, indent)
748
1476
  else:
749
1477
  lines[-1] = f"{pad}}} else {{"
750
- for s in node.else_body:
751
- self._visit_stmt(s, lines, indent + 1)
1478
+ self._visit_block_statements(
1479
+ node.else_body, lines, indent + 1
1480
+ )
752
1481
  lines.append(f"{pad}}}")
753
1482
 
754
1483
  def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None:
@@ -769,8 +1498,24 @@ class StmtVisitor:
769
1498
  e_var = f"_for_end_{fid}"
770
1499
  step_var = f"_for_step_{fid}"
771
1500
  down_var = f"_for_down_{fid}"
1501
+ end_mentions_binder = bool(
1502
+ node.var
1503
+ and any(
1504
+ isinstance(part, Identifier) and part.name == node.var
1505
+ for part in self._walk_ast(node.end)
1506
+ )
1507
+ )
1508
+ end_eval = f"_for_end_eval_{fid}" if end_mentions_binder else None
1509
+ if end_eval is not None:
1510
+ # The ``to`` expression is authored outside the loop-binder scope,
1511
+ # but its refresh executes inside the generated C++ ``for`` where
1512
+ # the binder would shadow a same-named outer member/parameter. A
1513
+ # pre-binder lambda preserves the authored lexical binding while
1514
+ # still reevaluating the expression after every iteration.
1515
+ lines.append(f"{pad}auto {end_eval} = [&]() {{ return ({end}); }};")
772
1516
  lines.append(f"{pad}int {s_var} = ({start});")
773
- lines.append(f"{pad}int {e_var} = ({end});")
1517
+ end_expr = f"{end_eval}()" if end_eval is not None else f"({end})"
1518
+ lines.append(f"{pad}int {e_var} = {end_expr};")
774
1519
  lines.append(f"{pad}int {step_var} = ({step});")
775
1520
  lines.append(f"{pad}if ({step_var} < 0) {step_var} = -{step_var};")
776
1521
  lines.append(f"{pad}if ({step_var} == 0) {step_var} = 1;")
@@ -778,47 +1523,35 @@ class StmtVisitor:
778
1523
  lines.append(
779
1524
  f"{pad}for (int {var} = {s_var}; "
780
1525
  f"({down_var} ? ({var} >= {e_var}) : ({var} <= {e_var})); "
781
- f"{var} += ({down_var} ? -{step_var} : {step_var}), {e_var} = ({end})) {{"
1526
+ f"{var} += ({down_var} ? -{step_var} : {step_var}), "
1527
+ f"{e_var} = {end_expr}) {{"
782
1528
  )
783
1529
  # Register the loop counter so reads of it inside the body resolve (the
784
1530
  # unknown-identifier guard in _visit_ident would otherwise flag it).
785
1531
  saved_loop = self._current_loop_vars
1532
+ saved_loop_specs = self._current_loop_var_specs
786
1533
  self._current_loop_vars = set(self._current_loop_vars)
1534
+ self._current_loop_var_specs = dict(self._current_loop_var_specs)
787
1535
  if node.var:
788
1536
  self._current_loop_vars.add(node.var)
1537
+ self._current_loop_var_specs[node.var] = TypeSpec.primitive("int")
789
1538
  _blk_saved = self._push_block_var_remap(node)
1539
+ if node.var:
1540
+ # The loop counter is a fresh primitive lexical binding. Keep it
1541
+ # from inheriting a same-spelled outer/raw UDT or drawing type.
1542
+ self._lexical_drawing_types[node.var] = None
1543
+ self._lexical_udt_types[node.var] = None
1544
+ self._lexical_series_bindings[node.var] = False
1545
+ self._lexical_known_var_tombstones.add(node.var)
790
1546
  try:
791
1547
  for s in node.body:
792
1548
  self._visit_stmt(s, lines, indent + 1)
793
1549
  finally:
794
1550
  self._pop_block_var_remap(_blk_saved)
795
1551
  self._current_loop_vars = saved_loop
1552
+ self._current_loop_var_specs = saved_loop_specs
796
1553
  lines.append(f"{pad}}}")
797
1554
 
798
- def _loop_elem_is_writeback_udt(self, iterable) -> bool:
799
- """Whether a ``for x in coll`` loop variable must bind by reference.
800
-
801
- In Pine a ``for x in arr`` loop variable over an array of *user-defined
802
- objects* is a reference to the element — field writes (``x.f := v``)
803
- mutate the array in place — whereas over a primitive array it is a
804
- copy. So emit C++ ``auto&`` only for arrays whose element is a
805
- user-defined UDT struct. Primitive elements keep ``auto`` (Pine copy
806
- semantics: writing the loop var must NOT write back). Drawing handles
807
- (line/box/label/linefill/...) also keep ``auto``: their element type
808
- name is a builtin, not in ``_udt_defs``, and a handle copy already
809
- mutates the shared engine object. (Reassigning the loop var itself —
810
- ``x := ...`` — is not modelled by either form, but Pine forbids it for
811
- objects in practice and it does not occur in the corpus.)
812
- """
813
- spec = self._type_spec_from_expr(iterable)
814
- return (
815
- spec is not None
816
- and spec.kind == "array"
817
- and spec.element is not None
818
- and spec.element.kind == "udt"
819
- and spec.element.name in self._udt_defs
820
- )
821
-
822
1555
  def _visit_for_in(self, node, lines: list[str], indent: int) -> None:
823
1556
  pad = " " * indent
824
1557
  iterable = self._visit_expr(node.iterable)
@@ -837,17 +1570,123 @@ class StmtVisitor:
837
1570
  if elem_spec is not None:
838
1571
  self._current_loop_var_specs[node.var] = elem_spec
839
1572
  if node.vars:
840
- for v in node.vars:
1573
+ tuple_specs: list[TypeSpec | None] = []
1574
+ if iterable_spec is not None and iterable_spec.kind == "map":
1575
+ tuple_specs = [iterable_spec.key, iterable_spec.value]
1576
+ for idx, v in enumerate(node.vars):
841
1577
  if v != "_":
842
1578
  self._current_loop_vars.add(v)
1579
+ if (idx < len(tuple_specs)
1580
+ and tuple_specs[idx] is not None):
1581
+ self._current_loop_var_specs[v] = tuple_specs[idx]
1582
+ map_pair_loop = (
1583
+ iterable_spec is not None
1584
+ and iterable_spec.kind == "map"
1585
+ and node.vars is not None
1586
+ and len(node.vars) == 2
1587
+ )
843
1588
  if node.var:
844
1589
  v_cpp = self._safe_name(node.var)
845
- ref = "&" if self._loop_elem_is_writeback_udt(node.iterable) else ""
846
- lines.append(f"{pad}for (auto{ref} {v_cpp} : {iterable}) {{")
1590
+ # User UDT elements are numeric object-ID handles. Copying the
1591
+ # loop binding preserves Pine semantics: field writes still reach
1592
+ # the shared arena record, while rebinding the loop variable does
1593
+ # not overwrite the array slot. Primitive elements use the same
1594
+ # value-binding rule.
1595
+ lines.append(f"{pad}for (auto {v_cpp} : {iterable}) {{")
1596
+ elif map_pair_loop:
1597
+ # Pine map iteration exposes insertion-ordered ``[key, value]``
1598
+ # pairs. PineMap intentionally keeps its storage private, so take
1599
+ # one handle copy (which aliases the same map ID), iterate keys(),
1600
+ # and obtain each value through the public API. Binding the
1601
+ # iterable once also preserves single-evaluation semantics for an
1602
+ # arbitrary map-producing expression.
1603
+ key_name, value_name = node.vars
1604
+ authored_names = (
1605
+ set(self._all_bound_names)
1606
+ | set(self._func_names)
1607
+ | set(self._udt_defs)
1608
+ )
1609
+ occupied_names = authored_names | {
1610
+ self._safe_name(name) for name in authored_names
1611
+ } | {
1612
+ self._func_safe_name(name) for name in self._func_names
1613
+ }
1614
+ # Parameters are not statement bindings, so they are absent from
1615
+ # ``_all_bound_names``. Include both their authored and C++-safe
1616
+ # spellings before minting loop temporaries; otherwise a legal UDF
1617
+ # or method parameter named ``__pf_map_iter_0`` (or, for ``_`` key
1618
+ # loops, ``__pf_map_key_0``) is redeclared and then shadowed by the
1619
+ # generated loop machinery.
1620
+ active_params = set(self._current_func_param_types)
1621
+ occupied_names.update(active_params)
1622
+ occupied_names.update(
1623
+ self._safe_name(name) for name in active_params
1624
+ )
1625
+ while True:
1626
+ fid = self._for_counter
1627
+ self._for_counter += 1
1628
+ map_token = f"__pf_map_iter_{fid}"
1629
+ internal_key = f"__pf_map_key_{fid}"
1630
+ generated_names = {map_token}
1631
+ if key_name == "_":
1632
+ generated_names.add(internal_key)
1633
+ if not (generated_names & occupied_names):
1634
+ break
1635
+ key_cpp = (
1636
+ self._safe_name(key_name)
1637
+ if key_name != "_"
1638
+ else internal_key
1639
+ )
1640
+ lines.append(f"{pad}auto {map_token} = {iterable};")
1641
+ lines.append(f"{pad}for (auto {key_cpp} : {map_token}.keys()) {{")
1642
+ if value_name != "_":
1643
+ value_cpp = self._safe_name(value_name)
1644
+ lines.append(
1645
+ f"{pad} auto {value_cpp} = {map_token}.get({key_cpp});"
1646
+ )
847
1647
  elif node.vars:
848
1648
  bindings = ", ".join(node.vars)
849
1649
  lines.append(f"{pad}for (auto [{bindings}] : {iterable}) {{")
850
1650
  _blk_saved = self._push_block_var_remap(node)
1651
+ loop_binding_names = (
1652
+ [node.var] if node.var else list(node.vars or [])
1653
+ )
1654
+ loop_binding_specs = (
1655
+ [elem_spec]
1656
+ if node.var
1657
+ else [
1658
+ tuple_specs[index] if index < len(tuple_specs) else None
1659
+ for index in range(len(loop_binding_names))
1660
+ ]
1661
+ )
1662
+ for index, name in enumerate(loop_binding_names):
1663
+ if name and name != "_":
1664
+ spec = (
1665
+ loop_binding_specs[index]
1666
+ if index < len(loop_binding_specs)
1667
+ else None
1668
+ )
1669
+ drawing_name = (
1670
+ spec.name
1671
+ if (spec is not None
1672
+ and spec.kind == "udt"
1673
+ and spec.name in DRAWING_TYPE_TO_CPP)
1674
+ else None
1675
+ )
1676
+ self._lexical_drawing_types[name] = (
1677
+ DRAWING_TYPE_TO_CPP[drawing_name]
1678
+ if drawing_name is not None
1679
+ else None
1680
+ )
1681
+ self._lexical_udt_types[name] = (
1682
+ spec.name
1683
+ if (spec is not None
1684
+ and spec.kind == "udt"
1685
+ and spec.name in self._udt_defs)
1686
+ else None
1687
+ )
1688
+ self._lexical_series_bindings[name] = False
1689
+ self._lexical_known_var_tombstones.add(name)
851
1690
  try:
852
1691
  for s in node.body:
853
1692
  self._visit_stmt(s, lines, indent + 1)
@@ -879,22 +1718,21 @@ class StmtVisitor:
879
1718
  prefix = "if" if i == 0 else "else if"
880
1719
  case_val = self._visit_expr(case_expr)
881
1720
  lines.append(f"{pad}{prefix} ({expr_var} == {case_val}) {{")
882
- for s in case_body:
883
- self._visit_stmt(s, lines, indent + 1)
1721
+ self._visit_block_statements(case_body, lines, indent + 1)
884
1722
  lines.append(f"{pad}}}")
885
1723
  else:
886
1724
  for i, (case_expr, case_body) in enumerate(node.cases):
887
1725
  prefix = "if" if i == 0 else "else if"
888
1726
  cond = self._visit_expr(case_expr)
889
1727
  lines.append(f"{pad}{prefix} ({cond}) {{")
890
- for s in case_body:
891
- self._visit_stmt(s, lines, indent + 1)
1728
+ self._visit_block_statements(case_body, lines, indent + 1)
892
1729
  lines.append(f"{pad}}}")
893
1730
 
894
1731
  if node.default_body:
895
1732
  lines.append(f"{pad}else {{")
896
- for s in node.default_body:
897
- self._visit_stmt(s, lines, indent + 1)
1733
+ self._visit_block_statements(
1734
+ node.default_body, lines, indent + 1
1735
+ )
898
1736
  lines.append(f"{pad}}}")
899
1737
 
900
1738
  # ------------------------------------------------------------------
@@ -903,8 +1741,14 @@ class StmtVisitor:
903
1741
 
904
1742
  # _default_for_type lives on TypeInferer — see codegen/types.py.
905
1743
 
906
- def _emit_body_with_assign(self, body: list, target: str,
907
- lines: list[str], indent: int) -> None:
1744
+ def _emit_body_with_assign(
1745
+ self,
1746
+ body: list,
1747
+ target: str,
1748
+ lines: list[str],
1749
+ indent: int,
1750
+ target_cpp_type: str | None = None,
1751
+ ) -> None:
908
1752
  """Emit a body block where the last expression becomes an assignment."""
909
1753
  if not body:
910
1754
  return
@@ -921,36 +1765,93 @@ class StmtVisitor:
921
1765
  if self._call_is_void(stmt.expr):
922
1766
  self._visit_stmt(stmt, lines, indent)
923
1767
  return
924
- cpp = self._visit_expr(stmt.expr)
1768
+ cpp = self._visit_rhs_value(
1769
+ stmt.expr,
1770
+ target_cpp_type=target_cpp_type,
1771
+ )
925
1772
  pad = " " * indent
926
1773
  lines.append(f"{pad}{target} = {cpp};")
927
1774
  elif isinstance(stmt, IfStmt):
928
1775
  # Nested if expression
929
- self._visit_if_switch_expr(stmt, target, lines, indent)
1776
+ self._visit_if_switch_expr(
1777
+ stmt,
1778
+ target,
1779
+ lines,
1780
+ indent,
1781
+ target_cpp_type=target_cpp_type,
1782
+ )
930
1783
  elif isinstance(stmt, SwitchStmt):
931
- self._visit_if_switch_expr(stmt, target, lines, indent)
1784
+ self._visit_if_switch_expr(
1785
+ stmt,
1786
+ target,
1787
+ lines,
1788
+ indent,
1789
+ target_cpp_type=target_cpp_type,
1790
+ )
932
1791
  else:
933
1792
  self._visit_stmt(stmt, lines, indent)
934
1793
  else:
935
1794
  self._visit_stmt(stmt, lines, indent)
936
1795
 
937
- def _visit_if_switch_expr(self, node, target: str,
938
- lines: list[str], indent: int) -> None:
1796
+ def _visit_if_switch_expr(
1797
+ self,
1798
+ node,
1799
+ target: str,
1800
+ lines: list[str],
1801
+ indent: int,
1802
+ target_cpp_type: str | None = None,
1803
+ ) -> None:
939
1804
  """Emit an if/switch used as an expression, assigning to target."""
940
1805
  pad = " " * indent
1806
+ nullable_collection_target = self._is_nullable_collection_cpp_type(
1807
+ target_cpp_type
1808
+ )
1809
+
1810
+ def emit_implicit_na_fallback() -> None:
1811
+ """Reset a nullable ID when an expression has no matching arm."""
1812
+ lines.append(f"{pad}else {{")
1813
+ lines.append(
1814
+ f"{pad} {target} = {target_cpp_type}{{}};"
1815
+ )
1816
+ lines.append(f"{pad}}}")
1817
+
941
1818
  if isinstance(node, IfStmt):
942
1819
  cond = self._visit_expr(node.condition)
943
1820
  lines.append(f"{pad}if ({cond}) {{")
944
- self._emit_body_with_assign(node.body, target, lines, indent + 1)
1821
+ self._emit_block_with_assign(
1822
+ node.body,
1823
+ target,
1824
+ lines,
1825
+ indent + 1,
1826
+ target_cpp_type=target_cpp_type,
1827
+ )
945
1828
  lines.append(f"{pad}}}")
946
1829
  if node.else_body:
947
1830
  if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt):
948
1831
  lines[-1] = f"{pad}}} else"
949
- self._visit_if_switch_expr(node.else_body[0], target, lines, indent)
1832
+ self._visit_if_switch_expr(
1833
+ node.else_body[0],
1834
+ target,
1835
+ lines,
1836
+ indent,
1837
+ target_cpp_type=target_cpp_type,
1838
+ )
950
1839
  else:
951
1840
  lines[-1] = f"{pad}}} else {{"
952
- self._emit_body_with_assign(node.else_body, target, lines, indent + 1)
1841
+ self._emit_block_with_assign(
1842
+ node.else_body,
1843
+ target,
1844
+ lines,
1845
+ indent + 1,
1846
+ target_cpp_type=target_cpp_type,
1847
+ )
953
1848
  lines.append(f"{pad}}}")
1849
+ elif nullable_collection_target:
1850
+ # A Pine if-expression without an else evaluates to ``na`` on
1851
+ # the unmatched path. This assignment is essential for
1852
+ # non-var globals and reassignments: retaining the prior bar's
1853
+ # map/matrix ID would turn the expression into implicit state.
1854
+ emit_implicit_na_fallback()
954
1855
  elif isinstance(node, SwitchStmt):
955
1856
  if node.expr:
956
1857
  expr_var = f"__switch_val_{self._switch_counter}"
@@ -960,16 +1861,50 @@ class StmtVisitor:
960
1861
  prefix = "if" if i == 0 else "else if"
961
1862
  case_val = self._visit_expr(case_expr)
962
1863
  lines.append(f"{pad}{prefix} ({expr_var} == {case_val}) {{")
963
- self._emit_body_with_assign(case_body, target, lines, indent + 1)
1864
+ self._emit_block_with_assign(
1865
+ case_body,
1866
+ target,
1867
+ lines,
1868
+ indent + 1,
1869
+ target_cpp_type=target_cpp_type,
1870
+ )
964
1871
  lines.append(f"{pad}}}")
965
1872
  else:
966
1873
  for i, (case_expr, case_body) in enumerate(node.cases):
967
1874
  prefix = "if" if i == 0 else "else if"
968
1875
  cond = self._visit_expr(case_expr)
969
1876
  lines.append(f"{pad}{prefix} ({cond}) {{")
970
- self._emit_body_with_assign(case_body, target, lines, indent + 1)
1877
+ self._emit_block_with_assign(
1878
+ case_body,
1879
+ target,
1880
+ lines,
1881
+ indent + 1,
1882
+ target_cpp_type=target_cpp_type,
1883
+ )
971
1884
  lines.append(f"{pad}}}")
972
1885
  if node.default_body:
973
- lines.append(f"{pad}else {{")
974
- self._emit_body_with_assign(node.default_body, target, lines, indent + 1)
975
- lines.append(f"{pad}}}")
1886
+ if node.cases:
1887
+ lines.append(f"{pad}else {{")
1888
+ self._emit_block_with_assign(
1889
+ node.default_body,
1890
+ target,
1891
+ lines,
1892
+ indent + 1,
1893
+ target_cpp_type=target_cpp_type,
1894
+ )
1895
+ lines.append(f"{pad}}}")
1896
+ else:
1897
+ self._emit_block_with_assign(
1898
+ node.default_body,
1899
+ target,
1900
+ lines,
1901
+ indent,
1902
+ target_cpp_type=target_cpp_type,
1903
+ )
1904
+ elif nullable_collection_target:
1905
+ if node.cases:
1906
+ emit_implicit_na_fallback()
1907
+ else:
1908
+ lines.append(
1909
+ f"{pad}{target} = {target_cpp_type}{{}};"
1910
+ )