@pineforge/codegen-pyodide 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +25 -0
  2. package/index.mjs +20 -0
  3. package/package.json +32 -0
  4. package/pineforge_codegen/__init__.py +53 -0
  5. package/pineforge_codegen/analyzer/__init__.py +60 -0
  6. package/pineforge_codegen/analyzer/base.py +1566 -0
  7. package/pineforge_codegen/analyzer/call_handlers.py +895 -0
  8. package/pineforge_codegen/analyzer/contracts.py +163 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +118 -0
  10. package/pineforge_codegen/analyzer/tables.py +204 -0
  11. package/pineforge_codegen/analyzer/types.py +261 -0
  12. package/pineforge_codegen/ast_nodes.py +293 -0
  13. package/pineforge_codegen/codegen/__init__.py +78 -0
  14. package/pineforge_codegen/codegen/base.py +1381 -0
  15. package/pineforge_codegen/codegen/emit_top.py +875 -0
  16. package/pineforge_codegen/codegen/helpers.py +163 -0
  17. package/pineforge_codegen/codegen/helpers_syminfo.py +132 -0
  18. package/pineforge_codegen/codegen/input.py +189 -0
  19. package/pineforge_codegen/codegen/security.py +1564 -0
  20. package/pineforge_codegen/codegen/ta.py +298 -0
  21. package/pineforge_codegen/codegen/tables.py +683 -0
  22. package/pineforge_codegen/codegen/types.py +592 -0
  23. package/pineforge_codegen/codegen/visit_call.py +1387 -0
  24. package/pineforge_codegen/codegen/visit_expr.py +729 -0
  25. package/pineforge_codegen/codegen/visit_stmt.py +766 -0
  26. package/pineforge_codegen/errors.py +98 -0
  27. package/pineforge_codegen/lexer.py +531 -0
  28. package/pineforge_codegen/parser.py +1198 -0
  29. package/pineforge_codegen/pragmas.py +117 -0
  30. package/pineforge_codegen/signatures.py +808 -0
  31. package/pineforge_codegen/support_checker.py +1223 -0
  32. package/pineforge_codegen/symbols.py +118 -0
  33. package/pineforge_codegen/tokens.py +406 -0
  34. package/pineforge_codegen/tv_input_choices.py +86 -0
  35. package/pineforge_codegen-0.7.0.tar.gz +0 -0
  36. package/release.json +7 -0
  37. package/tables.json +1653 -0
@@ -0,0 +1,766 @@
1
+ """Statement-level visitors for the codegen.
2
+
3
+ ``StmtVisitor`` holds the statement-level visitors that recurse into a
4
+ function body or top-level program. ``_visit_stmt`` is the central
5
+ dispatcher: it inspects the AST node kind and delegates to one of the
6
+ statement-kind handlers (``_visit_var_decl``, ``_visit_assignment``,
7
+ ``_visit_tuple_assign``, ``_visit_if``, ``_visit_for``,
8
+ ``_visit_for_in``, ``_visit_while``, ``_visit_switch``) or emits a
9
+ trivial ``break;`` / ``continue;`` / expression statement directly.
10
+ The two if/switch-as-expression helpers (``_emit_body_with_assign``
11
+ and ``_visit_if_switch_expr``) live alongside the other visitors
12
+ because they recurse back into ``_visit_stmt`` for nested control
13
+ flow.
14
+
15
+ These visitors were extracted from ``base.py``'s ``CodeGen`` class as
16
+ step 8 of the codegen package refactor; behaviour is preserved
17
+ verbatim. The mixin owns no state of its own — it reads/writes only
18
+ attributes already established on the host class (``CodeGen``).
19
+
20
+ Mixin contract — host class must provide the following attributes
21
+ (all set by ``CodeGen.__init__`` or other mixins):
22
+
23
+ - ``self.ctx`` (``AnalyzerContext``): symbol table source. Reads
24
+ ``ctx.series_vars`` to decide between ``Series<T>::push`` /
25
+ ``Series<T>::update`` and a plain assignment.
26
+ - ``self._var_names`` (``set[str]``): names declared at module scope
27
+ (used to drive the assignment lowering).
28
+ - ``self._global_member_vars`` (``set[str]``): non-``var`` global
29
+ declarations emitted as class members (assignment-only path in
30
+ ``_visit_var_decl``).
31
+ - ``self._array_vars`` / ``self._map_vars`` (``set[str]``) and
32
+ ``self._matrix_specs`` (``dict[str, TypeSpec]``): collection-typed
33
+ variables; ``_visit_var_decl``
34
+ registers new entries when it sees ``array.new`` / ``map.new`` /
35
+ ``matrix.new``.
36
+ - ``self._collection_types`` (``dict[str, TypeSpec]``):
37
+ ``_visit_var_decl`` populates it from inferred specs.
38
+ - ``self._active_var_remap`` (``dict[str, str]``): per-call-site
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
+ - ``self._current_loop_vars`` (``set[str]``): for-in iterator names;
43
+ saved/restored around ``_visit_for_in`` bodies so member-access
44
+ resolution can distinguish iterators from enum constants.
45
+ - ``self._switch_counter`` (``int``): monotonically incremented by
46
+ ``_visit_switch`` / ``_visit_if_switch_expr`` to mint fresh
47
+ ``__switch_val_<n>`` temporary names.
48
+ - ``self._func_names`` (``set[str]``): user-defined function names;
49
+ consulted by ``_visit_tuple_assign`` to spot tuple-returning calls.
50
+
51
+ Sibling-mixin methods consumed via ``self``:
52
+
53
+ - ``NamingHelper`` (``codegen/helpers.py``): ``_safe_name``,
54
+ ``_resolve_callee``, ``_get_target_name``.
55
+ - ``TypeInferer`` (``codegen/types.py``): ``_type_for_decl``,
56
+ ``_type_spec_to_cpp``, ``_default_for_type``,
57
+ ``_type_spec_from_expr``, ``_array_spec_for_name``,
58
+ ``_map_spec_for_name``.
59
+ - ``TaSiteHelper`` (``codegen/ta.py``): ``_get_ta_site``,
60
+ ``_ta_member_name``, ``_ta_compute_args_for_site``,
61
+ ``_ta_name_from_site``, ``_if_body_has_ta``, ``_hoist_if_body``.
62
+ - ``InputHelper`` (``codegen/input.py``): ``_is_input_call``,
63
+ ``_get_input_default``, ``_get_input_title``,
64
+ ``_input_type_to_getter``,
65
+ ``_enforce_enum_declared_before_input_enum``.
66
+ - ``CodeGen.base``: ``_visit_expr``, ``_visit_func_call``,
67
+ ``_is_skip_expr`` (still on the host class — the expression
68
+ visitors and the skip-expression predicate are extracted in later
69
+ refactor steps).
70
+
71
+ The mixin avoids importing from ``base.py`` to stay free of cycles;
72
+ all tables it needs come from ``codegen/tables.py`` and all AST
73
+ classes from ``..ast_nodes``.
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ from ..ast_nodes import (
79
+ ASTNode,
80
+ Assignment,
81
+ BreakStmt,
82
+ ContinueStmt,
83
+ EnumDecl,
84
+ ExprStmt,
85
+ ForInStmt,
86
+ ForStmt,
87
+ FuncCall,
88
+ FuncDef,
89
+ Identifier,
90
+ IfStmt,
91
+ ImportStmt,
92
+ MemberAccess,
93
+ MethodDef,
94
+ StrategyDecl,
95
+ SwitchStmt,
96
+ TupleAssign,
97
+ TypeDecl,
98
+ VarDecl,
99
+ WhileStmt,
100
+ )
101
+ from ..symbols import TypeSpec
102
+ from .tables import (
103
+ SKIP_VAR_TYPES,
104
+ TA_RETURNS_BOOL,
105
+ TA_TUPLE_FIELDS,
106
+ MATRIX_RETURNING_METHODS,
107
+ )
108
+
109
+
110
+ class StmtVisitor:
111
+ """Statement-level visitor methods shared across the codegen.
112
+
113
+ Mixed into ``CodeGen``; not intended to be instantiated standalone.
114
+ See the module docstring for the full host-class state contract."""
115
+
116
+ # ------------------------------------------------------------------
117
+ # Statement visitors
118
+ # ------------------------------------------------------------------
119
+
120
+ def _visit_stmt(self, node: ASTNode, lines: list[str], indent: int) -> None:
121
+ pad = " " * indent
122
+
123
+ if isinstance(node, StrategyDecl):
124
+ return
125
+ if isinstance(node, ImportStmt):
126
+ return
127
+ if isinstance(node, FuncDef):
128
+ return # handled separately as class methods
129
+ if isinstance(node, TypeDecl):
130
+ return # handled in struct emission
131
+ if isinstance(node, EnumDecl):
132
+ return # handled in enum constant emission
133
+ if isinstance(node, MethodDef):
134
+ return # handled as class method via FuncInfo
135
+ if isinstance(node, VarDecl):
136
+ self._visit_var_decl(node, lines, pad)
137
+ elif isinstance(node, Assignment):
138
+ self._visit_assignment(node, lines, pad)
139
+ elif isinstance(node, TupleAssign):
140
+ self._visit_tuple_assign(node, lines, pad)
141
+ elif isinstance(node, IfStmt):
142
+ self._visit_if(node, lines, indent)
143
+ elif isinstance(node, ForStmt):
144
+ self._visit_for(node, lines, indent)
145
+ elif isinstance(node, ForInStmt):
146
+ self._visit_for_in(node, lines, indent)
147
+ elif isinstance(node, WhileStmt):
148
+ self._visit_while(node, lines, indent)
149
+ elif isinstance(node, SwitchStmt):
150
+ self._visit_switch(node, lines, indent)
151
+ elif isinstance(node, BreakStmt):
152
+ lines.append(f"{pad}break;")
153
+ elif isinstance(node, ContinueStmt):
154
+ lines.append(f"{pad}continue;")
155
+ elif isinstance(node, ExprStmt):
156
+ # Intercept strategy.risk.* calls
157
+ if isinstance(node.expr, FuncCall) and isinstance(node.expr.callee, MemberAccess):
158
+ c = node.expr.callee
159
+ if (isinstance(c.object, MemberAccess) and isinstance(c.object.object, Identifier)
160
+ and c.object.object.name == "strategy" and c.object.member == "risk"
161
+ and node.expr.args):
162
+ risk_func = c.member
163
+ _RISK_MEMBER_MAP = {
164
+ "max_intraday_filled_orders": ("max_intraday_filled_orders_", "int"),
165
+ "max_drawdown": ("risk_max_drawdown_", "double"),
166
+ "max_intraday_loss": ("risk_max_intraday_loss_", "double"),
167
+ "max_position_size": ("risk_max_position_size_", "double"),
168
+ "max_cons_loss_days": ("risk_max_cons_loss_days_", "int"),
169
+ }
170
+ if risk_func == "allow_entry_in":
171
+ val = self._visit_expr(node.expr.args[0])
172
+ if val == "1":
173
+ lines.append(f"{pad}risk_direction_ = RiskDirection::LONG_ONLY;")
174
+ elif val == "-1":
175
+ lines.append(f"{pad}risk_direction_ = RiskDirection::SHORT_ONLY;")
176
+ else:
177
+ lines.append(f"{pad}risk_direction_ = RiskDirection::BOTH;")
178
+ return
179
+ if risk_func in _RISK_MEMBER_MAP:
180
+ member, cast_type = _RISK_MEMBER_MAP[risk_func]
181
+ val = self._visit_expr(node.expr.args[0])
182
+ lines.append(f"{pad}{member} = ({cast_type})({val});")
183
+ # Handle percent_of_equity flag for max_drawdown / max_intraday_loss
184
+ if risk_func in ("max_drawdown", "max_intraday_loss") and len(node.expr.args) >= 2:
185
+ arg2 = node.expr.args[1]
186
+ is_pct = (isinstance(arg2, MemberAccess)
187
+ and isinstance(arg2.object, Identifier)
188
+ and arg2.object.name == "strategy"
189
+ and arg2.member == "percent_of_equity")
190
+ if is_pct:
191
+ pct_flag = "risk_max_drawdown_is_pct_" if risk_func == "max_drawdown" else "risk_max_intraday_loss_is_pct_"
192
+ lines.append(f"{pad}{pct_flag} = true;")
193
+ return
194
+ if self._is_skip_expr(node.expr):
195
+ return
196
+ # matrix.concat / m.concat as a statement: engine concat returns a
197
+ # new matrix and is marked [[nodiscard]]. Pine semantics is mutate
198
+ # the first argument. Capture the result back into the receiver so
199
+ # we get the mutation AND avoid the warning.
200
+ recv_for_concat = self._concat_receiver_name(node.expr)
201
+ if recv_for_concat is not None:
202
+ cpp = self._visit_expr(node.expr)
203
+ target = self._safe_name(recv_for_concat)
204
+ lines.append(f"{pad}{target} = {cpp};")
205
+ return
206
+ cpp = self._visit_expr(node.expr)
207
+ if cpp.startswith("/* "):
208
+ return
209
+ # Never emit a bare invalid C++ token (e.g. type names leaked as statements).
210
+ stripped = cpp.strip()
211
+ if stripped == "color" or stripped.startswith("(int64_t)pine_color::"):
212
+ return
213
+ lines.append(f"{pad}{cpp};")
214
+
215
+ def _concat_receiver_name(self, expr) -> str | None:
216
+ """If ``expr`` is a Pine ``matrix.concat`` call (in either method
217
+ form ``m.concat(other, ...)`` or namespaced form
218
+ ``matrix.concat(m, other, ...)``) on a known matrix variable,
219
+ return the receiver variable name. Otherwise return None.
220
+
221
+ Engine ``PineGenericMatrix::concat`` is ``[[nodiscard]]`` and Pine
222
+ semantics is mutate-receiver, so the statement form must be lowered
223
+ to ``recv = recv.concat(...);``.
224
+ """
225
+ if not isinstance(expr, FuncCall) or not isinstance(expr.callee, MemberAccess):
226
+ return None
227
+ callee = expr.callee
228
+ if callee.member != "concat":
229
+ return None
230
+ # m.concat(other, ...) — receiver is callee.object
231
+ if isinstance(callee.object, Identifier):
232
+ recv = callee.object.name
233
+ if recv in getattr(self, "_matrix_specs", {}):
234
+ return recv
235
+ # matrix.concat(m, other, ...) — receiver is first arg
236
+ if (isinstance(callee.object, Identifier)
237
+ and callee.object.name == "matrix"
238
+ and expr.args
239
+ and isinstance(expr.args[0], Identifier)):
240
+ recv = expr.args[0].name
241
+ if recv in getattr(self, "_matrix_specs", {}):
242
+ return recv
243
+ return None
244
+
245
+ def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None:
246
+ # var/varip — handled as members in on_bar preamble
247
+ if node.is_var or node.is_varip:
248
+ return
249
+
250
+ safe = self._safe_name(node.name)
251
+ # Apply per-call-site var remap (for function-local vars)
252
+ if self._active_var_remap and safe in self._active_var_remap:
253
+ safe = self._active_var_remap[safe]
254
+ # Global-scope non-var vars are class members — emit assignment, not declaration
255
+ is_global_member = node.name in self._global_member_vars
256
+
257
+ # Check if it is a static (non-series) global member variable already evaluated inside _inputs_initialized_ block
258
+ is_static_global_input = False
259
+ if is_global_member and isinstance(node.value, FuncCall) and self._is_input_call(node.value):
260
+ func_name_i, namespace_i = self._resolve_callee(node.value.callee)
261
+ is_static_global_input = (
262
+ func_name_i != "source"
263
+ and node.name not in self._array_vars
264
+ and node.name not in getattr(self, "_matrix_specs", {})
265
+ and node.name not in getattr(self, "_map_vars", {})
266
+ and not node.is_var
267
+ and not node.is_varip
268
+ )
269
+
270
+ if is_static_global_input:
271
+ # Skip, already evaluated in _inputs_initialized_ block!
272
+ return
273
+
274
+ # input() call — emit runtime get_input_*() lookup
275
+ if isinstance(node.value, FuncCall) and self._is_input_call(node.value):
276
+ func_name_i, namespace_i = self._resolve_callee(node.value.callee)
277
+
278
+ if namespace_i == "input" and func_name_i == "enum":
279
+ self._enforce_enum_declared_before_input_enum(node.value)
280
+ title = self._get_input_title(node.value, var_name=node.name)
281
+ cpp_val = self._render_input_value(node.value, func_name_i, namespace_i, title)
282
+ if node.name in self.ctx.series_vars:
283
+ lines.append(f"{pad}{safe}.push({cpp_val});")
284
+ elif is_global_member:
285
+ lines.append(f"{pad}{safe} = {cpp_val};")
286
+ else:
287
+ cpp_type = self._type_for_decl(node)
288
+ lines.append(f"{pad}{cpp_type} {safe} = {cpp_val};")
289
+ return
290
+
291
+ # Array variable declarations: array.new<T>(), array.from(),
292
+ # array.new_float() etc., plus array-returning copy/slice.
293
+ if isinstance(node.value, FuncCall):
294
+ func_name, namespace = self._resolve_callee(node.value.callee)
295
+ if namespace == "array" and func_name in ("new", "new_float", "new_int", "new_bool", "new_string", "from", "copy", "slice"):
296
+ self._array_vars.add(node.name)
297
+ spec = self._type_spec_from_expr(node.value) or self._array_spec_for_name(node.name)
298
+ self._collection_types.setdefault(node.name, spec)
299
+ init = self._visit_expr(node.value)
300
+ cpp_type = self._type_spec_to_cpp(spec)
301
+ if is_global_member:
302
+ lines.append(f"{pad}{safe} = {init};")
303
+ else:
304
+ lines.append(f"{pad}{cpp_type} {safe} = {init};")
305
+ return
306
+
307
+ # Map variable declarations: map.new<K,V>()
308
+ if isinstance(node.value, FuncCall):
309
+ func_name, namespace = self._resolve_callee(node.value.callee)
310
+ if namespace == "matrix" and func_name == "new":
311
+ targs = self._template_args_from_call(node.value) if hasattr(node.value, "annotations") else []
312
+ elem_spec = self._type_spec_from_hint_name(targs[0]) if targs else TypeSpec.primitive("float")
313
+ spec = TypeSpec.matrix(elem_spec)
314
+ self._matrix_specs[node.name] = spec
315
+ self._collection_types[node.name] = spec
316
+ cpp_type = self._type_spec_to_cpp(spec)
317
+ if len(node.value.args) >= 2:
318
+ r = self._visit_expr(node.value.args[0])
319
+ c = self._visit_expr(node.value.args[1])
320
+ v = self._visit_expr(node.value.args[2]) if len(node.value.args) > 2 else self._default_for_spec(elem_spec)
321
+ init = f"{cpp_type}::new_({r}, {c}, {v})"
322
+ else:
323
+ init = f"{cpp_type}::new_(0, 0, {self._default_for_spec(elem_spec)})"
324
+ if is_global_member:
325
+ lines.append(f"{pad}{safe} = {init};")
326
+ else:
327
+ lines.append(f"{pad}{cpp_type} {safe} = {init};")
328
+ return
329
+ # ``var inv = matrix.inv(m)`` — RHS is a matrix-returning method
330
+ # (inv / pinv / transpose / copy / submatrix / concat / diff /
331
+ # mult / pow / eigenvectors / kron). Without this branch the LHS
332
+ # falls through to the analyzer's default ``double`` and clang
333
+ # rejects ``double = PineMatrix``. The RHS expression itself is
334
+ # already lowered to the right ``m.inv()`` form by visit_call.
335
+ if namespace == "matrix" and func_name in MATRIX_RETURNING_METHODS:
336
+ recv_spec = self._matrix_specs.get(self._extract_receiver_name(node.value))
337
+ if recv_spec is None:
338
+ recv_spec = TypeSpec.matrix(TypeSpec.primitive("float"))
339
+ self._matrix_specs[node.name] = recv_spec
340
+ self._collection_types[node.name] = recv_spec
341
+ init = self._visit_expr(node.value)
342
+ cpp_type = self._type_spec_to_cpp(recv_spec)
343
+ if is_global_member:
344
+ lines.append(f"{pad}{safe} = {init};")
345
+ else:
346
+ lines.append(f"{pad}{cpp_type} {safe} = {init};")
347
+ return
348
+ if namespace == "map" and func_name == "new":
349
+ self._map_vars.add(node.name)
350
+ spec = self._type_spec_from_expr(node.value) or self._map_spec_for_name(node.name)
351
+ self._collection_types.setdefault(node.name, spec)
352
+ cpp_type = self._type_spec_to_cpp(spec)
353
+ if is_global_member:
354
+ lines.append(f"{pad}{safe} = {cpp_type}();")
355
+ else:
356
+ lines.append(f"{pad}{cpp_type} {safe};")
357
+ return
358
+
359
+ # Skip visual function assignments — but still emit declaration for
360
+ # table function results since the var may be used later
361
+ if isinstance(node.value, FuncCall) and self._is_skip_expr(node.value):
362
+ func_name, namespace = self._resolve_callee(node.value.callee)
363
+ if namespace in SKIP_VAR_TYPES:
364
+ # Emit var with default value so references don't fail
365
+ if not is_global_member:
366
+ cpp_type = self._type_for_decl(node)
367
+ default = "0" if cpp_type in ("int", "double") else ('std::string("")' if cpp_type == "std::string" else "false")
368
+ lines.append(f"{pad}{cpp_type} {safe} = {default};")
369
+ return
370
+
371
+ # TA call
372
+ site = self._get_ta_site(node.value)
373
+ if site is not None:
374
+ compute_args = self._ta_compute_args_for_site(site)
375
+ ret_type = "bool" if self._ta_name_from_site(site) in TA_RETURNS_BOOL else "double"
376
+ ta_name = self._ta_member_name(site)
377
+ ta_expr = f"(is_first_tick_ ? {ta_name}.compute({compute_args}) : {ta_name}.recompute({compute_args}))"
378
+ if node.name in self.ctx.series_vars:
379
+ lines.append(f"{pad}{safe}.push({ta_expr});")
380
+ elif is_global_member:
381
+ lines.append(f"{pad}{safe} = {ta_expr};")
382
+ else:
383
+ lines.append(f"{pad}{ret_type} {safe} = {ta_expr};")
384
+ return
385
+
386
+ # Non-var series variable — push instead of declare
387
+ if node.name in self.ctx.series_vars:
388
+ cpp_val = self._visit_expr(node.value)
389
+ lines.append(f"{pad}{safe}.push({cpp_val});")
390
+ return
391
+
392
+ # If/switch expression: x = if cond ... else ...
393
+ if isinstance(node.value, (IfStmt, SwitchStmt)):
394
+ if not is_global_member:
395
+ cpp_type = self._type_for_decl(node)
396
+ default = self._default_for_type(cpp_type)
397
+ lines.append(f"{pad}{cpp_type} {safe} = {default};")
398
+ indent = len(pad) // 4
399
+ self._visit_if_switch_expr(node.value, safe, lines, indent)
400
+ return
401
+
402
+ # General declaration
403
+ cpp_val = self._visit_expr(node.value)
404
+ if is_global_member:
405
+ lines.append(f"{pad}{safe} = {cpp_val};")
406
+ else:
407
+ cpp_type = self._type_for_decl(node)
408
+ lines.append(f"{pad}{cpp_type} {safe} = {cpp_val};")
409
+
410
+ @staticmethod
411
+ def _compound_assign_rhs(target_read: str, op: str, val_cpp: str) -> str | None:
412
+ """RHS for a compound assignment that must NOT lower to the C++
413
+ compound operator.
414
+
415
+ Pine v6 ``/`` is always-float (int/int included) and ``%`` is
416
+ fmod-like — the binary-operator lowering in visit_expr casts both
417
+ sides to double / uses std::fmod. ``a /= b`` and ``a %= b`` must
418
+ match those semantics (C++ ``/=`` would do integer division on int
419
+ operands; ``%=`` does not even compile for doubles). Returns None
420
+ for operators where the native C++ compound form is correct
421
+ (+=, -=, *=).
422
+ """
423
+ if op == "/=":
424
+ return f"((double)({target_read}) / (double)({val_cpp}))"
425
+ if op == "%=":
426
+ return f"std::fmod((double)({target_read}), (double)({val_cpp}))"
427
+ return None
428
+
429
+ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> None:
430
+ if isinstance(node.value, FuncCall) and self._is_skip_expr(node.value):
431
+ return
432
+
433
+ # If/switch expression in assignment: x := if cond ...
434
+ if isinstance(node.value, (IfStmt, SwitchStmt)):
435
+ target_name = self._get_target_name(node.target)
436
+ safe = self._safe_name(target_name) if target_name else self._visit_expr(node.target)
437
+ indent = len(pad) // 4
438
+ self._visit_if_switch_expr(node.value, safe, lines, indent)
439
+ return
440
+
441
+ # Get target name
442
+ target_name = self._get_target_name(node.target)
443
+ if target_name is None:
444
+ # Assignment to a UDT field that was dropped from the emitted
445
+ # struct because it had a drawing-only type (label/line/box/
446
+ # linefill/polyline/table/chart.point). The struct has no such
447
+ # member, so emit a placeholder comment instead of a real C++
448
+ # assignment. We intentionally do NOT visit the RHS here: drawing
449
+ # constructors (label.new / line.new / ...) live in
450
+ # SKIP_NAMESPACES, so they have no observable side effects in
451
+ # the backtest runtime. See: pineforge-codegen issue #10.
452
+ if self._is_omitted_udt_field(node.target):
453
+ recv = self._visit_expr(node.target.object)
454
+ lines.append(
455
+ f"{pad}/* drawing field assignment omitted: "
456
+ f"{recv}.{node.target.member} {node.op} ... */"
457
+ )
458
+ return
459
+ # General expression target (e.g., member access)
460
+ target_cpp = self._visit_expr(node.target)
461
+ val_cpp = self._visit_expr(node.value)
462
+ if node.op == ":=":
463
+ lines.append(f"{pad}{target_cpp} = {val_cpp};")
464
+ else:
465
+ rhs = self._compound_assign_rhs(target_cpp, node.op, val_cpp)
466
+ if rhs is not None:
467
+ lines.append(f"{pad}{target_cpp} = {rhs};")
468
+ else:
469
+ lines.append(f"{pad}{target_cpp} {node.op} {val_cpp};")
470
+ return
471
+
472
+ safe = self._safe_name(target_name)
473
+ # Apply per-call-site var remap (for function-local vars)
474
+ if self._active_var_remap and safe in self._active_var_remap:
475
+ safe = self._active_var_remap[safe]
476
+
477
+ if target_name in self.ctx.series_vars:
478
+ val_cpp = self._visit_expr(node.value)
479
+ if node.op == ":=":
480
+ lines.append(f"{pad}{safe}.update({val_cpp});")
481
+ else:
482
+ rhs = self._compound_assign_rhs(f"{safe}[0]", node.op, val_cpp)
483
+ if rhs is not None:
484
+ # x /= y → x.update((double)x[0] / (double)y); x %= y → fmod
485
+ lines.append(f"{pad}{safe}.update({rhs});")
486
+ else:
487
+ # Compound assignment: x += y → x.update(x[0] + y)
488
+ op_char = node.op[0] # e.g., "+" from "+="
489
+ lines.append(f"{pad}{safe}.update({safe}[0] {op_char} {val_cpp});")
490
+ elif target_name in self._var_names:
491
+ if node.op == ":=" and target_name in self._matrix_specs and isinstance(node.value, FuncCall):
492
+ rhs_fn, rhs_ns = self._resolve_callee(node.value.callee)
493
+ rhs_spec = None
494
+ if rhs_ns == "matrix" and rhs_fn == "new":
495
+ targs = self._template_args_from_call(node.value) if hasattr(node.value, "annotations") else []
496
+ elem = self._type_spec_from_hint_name(targs[0]) if targs else TypeSpec.primitive("float")
497
+ rhs_spec = TypeSpec.matrix(elem)
498
+ elif rhs_ns == "matrix" and rhs_fn in MATRIX_RETURNING_METHODS:
499
+ rcv = self._extract_receiver_name(node.value)
500
+ rhs_spec = self._matrix_specs.get(rcv)
501
+ if rhs_spec is not None:
502
+ lhs_spec = self._matrix_specs[target_name]
503
+ if rhs_spec.element != lhs_spec.element:
504
+ self._codegen_error(
505
+ node,
506
+ f"matrix '{target_name}' element type mismatch on reassignment: "
507
+ f"expected {self._type_spec_to_cpp(lhs_spec)}, "
508
+ f"got {self._type_spec_to_cpp(rhs_spec)}",
509
+ )
510
+ val_cpp = self._visit_expr(node.value)
511
+ if node.op == ":=":
512
+ lines.append(f"{pad}{safe} = {val_cpp};")
513
+ else:
514
+ rhs = self._compound_assign_rhs(safe, node.op, val_cpp)
515
+ if rhs is not None:
516
+ lines.append(f"{pad}{safe} = {rhs};")
517
+ else:
518
+ lines.append(f"{pad}{safe} {node.op} {val_cpp};")
519
+ else:
520
+ val_cpp = self._visit_expr(node.value)
521
+ if node.op == ":=":
522
+ lines.append(f"{pad}{safe} = {val_cpp};")
523
+ else:
524
+ rhs = self._compound_assign_rhs(safe, node.op, val_cpp)
525
+ if rhs is not None:
526
+ lines.append(f"{pad}{safe} = {rhs};")
527
+ else:
528
+ lines.append(f"{pad}{safe} {node.op} {val_cpp};")
529
+
530
+ def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> None:
531
+ site = self._get_ta_site(node.value)
532
+ if site is not None:
533
+ compute_args = self._ta_compute_args_for_site(site)
534
+ ta_mem = self._ta_member_name(site)
535
+ result_var = f"_result_{ta_mem}"
536
+ lines.append(f"{pad}auto {result_var} = (is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args}));")
537
+
538
+ ta_name = self._ta_name_from_site(site)
539
+ fields = TA_TUPLE_FIELDS.get(ta_name, [f"field{i}" for i in range(len(node.names))])
540
+
541
+ for i, name in enumerate(node.names):
542
+ if name == "_":
543
+ continue
544
+ if i < len(fields):
545
+ lines.append(f"{pad}double {name} = {result_var}.{fields[i]};")
546
+ return
547
+
548
+ # User-defined function returning a tuple: use C++17 structured bindings
549
+ if isinstance(node.value, FuncCall):
550
+ func_name, namespace = self._resolve_callee(node.value.callee)
551
+ if namespace == "request" and func_name == "security":
552
+ binding_names = ", ".join(n for n in node.names if n != "_")
553
+ call_expr = self._visit_func_call(node.value)
554
+ lines.append(f"{pad}auto [{binding_names}] = {call_expr};")
555
+ return
556
+ if func_name and namespace is None and func_name in self._func_names:
557
+ binding_names = ", ".join(node.names)
558
+ call_expr = self._visit_func_call(node.value)
559
+ lines.append(f"{pad}auto [{binding_names}] = {call_expr};")
560
+ return
561
+
562
+ # UDT instance method returning a tuple: ``[a, b, c] = receiver.method(...)``.
563
+ # The plain-function branch above misses this because _resolve_callee
564
+ # returns ``("method", "receiver")`` for ``recv.method(...)``, not
565
+ # ``(key, None)``. We resolve the receiver's UDT type and look up
566
+ # the method-key ``TypeName.methodName`` in the FuncInfo map; when
567
+ # its FuncInfo carries ``returns_tuple=True`` we know
568
+ # ``_visit_func_call`` has already lowered the call as
569
+ # ``_udt_TypeName_method(receiver, ...)`` returning
570
+ # ``std::tuple<...>``, so structured bindings drop in.
571
+ # Probe: data/validation/udt-method-probe-17-tuple-return-destructure.
572
+ callee = node.value.callee
573
+ if isinstance(callee, MemberAccess):
574
+ recv_spec = self._type_spec_from_expr(callee.object)
575
+ if recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name:
576
+ method_key = f"{recv_spec.name}.{callee.member}"
577
+ fi_u = self._func_info_map.get(method_key)
578
+ if (fi_u is not None
579
+ and getattr(fi_u, "is_udt_method", False)
580
+ and getattr(fi_u, "returns_tuple", False)):
581
+ binding_names = ", ".join(node.names)
582
+ call_expr = self._visit_func_call(node.value)
583
+ lines.append(f"{pad}auto [{binding_names}] = {call_expr};")
584
+ return
585
+
586
+ lines.append(f"{pad}/* unsupported tuple assignment */")
587
+
588
+ def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None:
589
+ pad = " " * indent
590
+
591
+ # TA hoisting: inside per-call-site function variants, execute ALL
592
+ # statements unconditionally (PineScript execution model) but wrap
593
+ # the result assignment in the condition.
594
+ if self._in_ta_func_variant and self._if_body_has_ta(node.body):
595
+ cond = self._visit_expr(node.condition)
596
+ self._hoist_if_body(node.body, cond, lines, pad, indent)
597
+ # Handle else_body similarly
598
+ if node.else_body:
599
+ if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt):
600
+ self._visit_if(node.else_body[0], lines, indent)
601
+ else:
602
+ neg_cond = f"!({cond})"
603
+ self._hoist_if_body(node.else_body, neg_cond, lines, pad, indent)
604
+ return
605
+
606
+ cond = self._visit_expr(node.condition)
607
+ lines.append(f"{pad}if ({cond}) {{")
608
+ for s in node.body:
609
+ self._visit_stmt(s, lines, indent + 1)
610
+ lines.append(f"{pad}}}")
611
+ if node.else_body:
612
+ if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt):
613
+ lines[-1] = f"{pad}}} else"
614
+ self._visit_if(node.else_body[0], lines, indent)
615
+ else:
616
+ lines[-1] = f"{pad}}} else {{"
617
+ for s in node.else_body:
618
+ self._visit_stmt(s, lines, indent + 1)
619
+ lines.append(f"{pad}}}")
620
+
621
+ def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None:
622
+ pad = " " * indent
623
+ start = self._visit_expr(node.start)
624
+ end = self._visit_expr(node.end)
625
+ step = self._visit_expr(node.step) if node.step else "1"
626
+ var = node.var # new AST uses .var instead of .var_name
627
+ lines.append(f"{pad}for (int {var} = {start}; {var} <= {end}; {var} += {step}) {{")
628
+ # Register the loop counter so reads of it inside the body resolve (the
629
+ # unknown-identifier guard in _visit_ident would otherwise flag it).
630
+ saved_loop = self._current_loop_vars
631
+ self._current_loop_vars = set(self._current_loop_vars)
632
+ if var:
633
+ self._current_loop_vars.add(var)
634
+ for s in node.body:
635
+ self._visit_stmt(s, lines, indent + 1)
636
+ self._current_loop_vars = saved_loop
637
+ lines.append(f"{pad}}}")
638
+
639
+ def _visit_for_in(self, node, lines: list[str], indent: int) -> None:
640
+ pad = " " * indent
641
+ iterable = self._visit_expr(node.iterable)
642
+ saved_loop = self._current_loop_vars
643
+ self._current_loop_vars = set(self._current_loop_vars)
644
+ if node.var:
645
+ self._current_loop_vars.add(node.var)
646
+ if node.vars:
647
+ for v in node.vars:
648
+ if v != "_":
649
+ self._current_loop_vars.add(v)
650
+ if node.var:
651
+ v_cpp = self._safe_name(node.var)
652
+ lines.append(f"{pad}for (auto {v_cpp} : {iterable}) {{")
653
+ elif node.vars:
654
+ bindings = ", ".join(node.vars)
655
+ lines.append(f"{pad}for (auto [{bindings}] : {iterable}) {{")
656
+ for s in node.body:
657
+ self._visit_stmt(s, lines, indent + 1)
658
+ lines.append(f"{pad}}}")
659
+ self._current_loop_vars = saved_loop
660
+
661
+ def _visit_while(self, node: WhileStmt, lines: list[str], indent: int) -> None:
662
+ pad = " " * indent
663
+ cond = self._visit_expr(node.condition)
664
+ lines.append(f"{pad}while ({cond}) {{")
665
+ for s in node.body:
666
+ self._visit_stmt(s, lines, indent + 1)
667
+ lines.append(f"{pad}}}")
668
+
669
+ def _visit_switch(self, node: SwitchStmt, lines: list[str], indent: int) -> None:
670
+ pad = " " * indent
671
+ if node.expr:
672
+ expr_var = f"__switch_val_{self._switch_counter}"
673
+ self._switch_counter += 1
674
+ lines.append(f"{pad}auto {expr_var} = {self._visit_expr(node.expr)};")
675
+ for i, (case_expr, case_body) in enumerate(node.cases):
676
+ prefix = "if" if i == 0 else "else if"
677
+ case_val = self._visit_expr(case_expr)
678
+ lines.append(f"{pad}{prefix} ({expr_var} == {case_val}) {{")
679
+ for s in case_body:
680
+ self._visit_stmt(s, lines, indent + 1)
681
+ lines.append(f"{pad}}}")
682
+ else:
683
+ for i, (case_expr, case_body) in enumerate(node.cases):
684
+ prefix = "if" if i == 0 else "else if"
685
+ cond = self._visit_expr(case_expr)
686
+ lines.append(f"{pad}{prefix} ({cond}) {{")
687
+ for s in case_body:
688
+ self._visit_stmt(s, lines, indent + 1)
689
+ lines.append(f"{pad}}}")
690
+
691
+ if node.default_body:
692
+ lines.append(f"{pad}else {{")
693
+ for s in node.default_body:
694
+ self._visit_stmt(s, lines, indent + 1)
695
+ lines.append(f"{pad}}}")
696
+
697
+ # ------------------------------------------------------------------
698
+ # If/switch expression helpers
699
+ # ------------------------------------------------------------------
700
+
701
+ # _default_for_type lives on TypeInferer — see codegen/types.py.
702
+
703
+ def _emit_body_with_assign(self, body: list, target: str,
704
+ lines: list[str], indent: int) -> None:
705
+ """Emit a body block where the last expression becomes an assignment."""
706
+ if not body:
707
+ return
708
+ for i, stmt in enumerate(body):
709
+ if i == len(body) - 1:
710
+ # Last statement — try to turn into assignment
711
+ if isinstance(stmt, ExprStmt):
712
+ # Check if it's a skip expr
713
+ if self._is_skip_expr(stmt.expr):
714
+ return
715
+ cpp = self._visit_expr(stmt.expr)
716
+ pad = " " * indent
717
+ lines.append(f"{pad}{target} = {cpp};")
718
+ elif isinstance(stmt, IfStmt):
719
+ # Nested if expression
720
+ self._visit_if_switch_expr(stmt, target, lines, indent)
721
+ elif isinstance(stmt, SwitchStmt):
722
+ self._visit_if_switch_expr(stmt, target, lines, indent)
723
+ else:
724
+ self._visit_stmt(stmt, lines, indent)
725
+ else:
726
+ self._visit_stmt(stmt, lines, indent)
727
+
728
+ def _visit_if_switch_expr(self, node, target: str,
729
+ lines: list[str], indent: int) -> None:
730
+ """Emit an if/switch used as an expression, assigning to target."""
731
+ pad = " " * indent
732
+ if isinstance(node, IfStmt):
733
+ cond = self._visit_expr(node.condition)
734
+ lines.append(f"{pad}if ({cond}) {{")
735
+ self._emit_body_with_assign(node.body, target, lines, indent + 1)
736
+ lines.append(f"{pad}}}")
737
+ if node.else_body:
738
+ if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt):
739
+ lines[-1] = f"{pad}}} else"
740
+ self._visit_if_switch_expr(node.else_body[0], target, lines, indent)
741
+ else:
742
+ lines[-1] = f"{pad}}} else {{"
743
+ self._emit_body_with_assign(node.else_body, target, lines, indent + 1)
744
+ lines.append(f"{pad}}}")
745
+ elif isinstance(node, SwitchStmt):
746
+ if node.expr:
747
+ expr_var = f"__switch_val_{self._switch_counter}"
748
+ self._switch_counter += 1
749
+ lines.append(f"{pad}auto {expr_var} = {self._visit_expr(node.expr)};")
750
+ for i, (case_expr, case_body) in enumerate(node.cases):
751
+ prefix = "if" if i == 0 else "else if"
752
+ case_val = self._visit_expr(case_expr)
753
+ lines.append(f"{pad}{prefix} ({expr_var} == {case_val}) {{")
754
+ self._emit_body_with_assign(case_body, target, lines, indent + 1)
755
+ lines.append(f"{pad}}}")
756
+ else:
757
+ for i, (case_expr, case_body) in enumerate(node.cases):
758
+ prefix = "if" if i == 0 else "else if"
759
+ cond = self._visit_expr(case_expr)
760
+ lines.append(f"{pad}{prefix} ({cond}) {{")
761
+ self._emit_body_with_assign(case_body, target, lines, indent + 1)
762
+ lines.append(f"{pad}}}")
763
+ if node.default_body:
764
+ lines.append(f"{pad}else {{")
765
+ self._emit_body_with_assign(node.default_body, target, lines, indent + 1)
766
+ lines.append(f"{pad}}}")