pineforge-codegen 0.6.5__py3-none-any.whl

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 (35) hide show
  1. pineforge_codegen/__init__.py +53 -0
  2. pineforge_codegen/analyzer/__init__.py +60 -0
  3. pineforge_codegen/analyzer/base.py +1563 -0
  4. pineforge_codegen/analyzer/call_handlers.py +895 -0
  5. pineforge_codegen/analyzer/contracts.py +163 -0
  6. pineforge_codegen/analyzer/diagnostics.py +118 -0
  7. pineforge_codegen/analyzer/tables.py +204 -0
  8. pineforge_codegen/analyzer/types.py +250 -0
  9. pineforge_codegen/ast_nodes.py +293 -0
  10. pineforge_codegen/codegen/__init__.py +78 -0
  11. pineforge_codegen/codegen/base.py +1381 -0
  12. pineforge_codegen/codegen/emit_top.py +875 -0
  13. pineforge_codegen/codegen/helpers.py +163 -0
  14. pineforge_codegen/codegen/helpers_syminfo.py +134 -0
  15. pineforge_codegen/codegen/input.py +189 -0
  16. pineforge_codegen/codegen/security.py +1564 -0
  17. pineforge_codegen/codegen/ta.py +298 -0
  18. pineforge_codegen/codegen/tables.py +613 -0
  19. pineforge_codegen/codegen/types.py +573 -0
  20. pineforge_codegen/codegen/visit_call.py +1305 -0
  21. pineforge_codegen/codegen/visit_expr.py +701 -0
  22. pineforge_codegen/codegen/visit_stmt.py +729 -0
  23. pineforge_codegen/errors.py +98 -0
  24. pineforge_codegen/lexer.py +531 -0
  25. pineforge_codegen/parser.py +1198 -0
  26. pineforge_codegen/pragmas.py +117 -0
  27. pineforge_codegen/signatures.py +808 -0
  28. pineforge_codegen/support_checker.py +1111 -0
  29. pineforge_codegen/symbols.py +118 -0
  30. pineforge_codegen/tokens.py +406 -0
  31. pineforge_codegen/tv_input_choices.py +86 -0
  32. pineforge_codegen-0.6.5.dist-info/METADATA +462 -0
  33. pineforge_codegen-0.6.5.dist-info/RECORD +35 -0
  34. pineforge_codegen-0.6.5.dist-info/WHEEL +4 -0
  35. pineforge_codegen-0.6.5.dist-info/licenses/LICENSE +197 -0
@@ -0,0 +1,1563 @@
1
+ """Semantic analyzer for PineScript v6 AST.
2
+
3
+ Walks the AST produced by the parser, builds a symbol table, infers types,
4
+ detects series variables, collects TA call-sites, and outputs an
5
+ AnalyzerContext for the code generator.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ..ast_nodes import (
13
+ ASTNode,
14
+ Program, StrategyDecl, ImportStmt,
15
+ VarDecl, Assignment, TupleAssign,
16
+ IfStmt, ForStmt, ForInStmt, WhileStmt, SwitchStmt, BreakStmt, ContinueStmt,
17
+ FuncDef, ExprStmt,
18
+ BinOp, UnaryOp, Ternary, FuncCall, Subscript,
19
+ Identifier, MemberAccess, TypeAnnotation,
20
+ NumberLiteral, StringLiteral, BoolLiteral, NaLiteral, ColorLiteral,
21
+ TupleLiteral,
22
+ TypeDecl, EnumDecl, MethodDef, TypeField,
23
+ )
24
+ from ..symbols import PineType, Symbol, SymbolTable, TypeSpec
25
+ from ..errors import SourceLocation, Diagnostic, CompileError, Level, Phase
26
+ from .. import signatures as sigs
27
+ from .. import tv_input_choices as tv_in
28
+ # Output dataclasses (contract with the codegen) live in contracts.py so
29
+ # the import graph stays a strict DAG: contracts <- {base, call_handlers,
30
+ # __init__}.
31
+ from .contracts import (
32
+ AnalyzerContext,
33
+ FixnanCallSite,
34
+ FuncInfo,
35
+ MutableGlobalInfo,
36
+ SecurityCallInfo,
37
+ TACallSite,
38
+ )
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Output data structures (defined in contracts.py; re-imported above so
43
+ # this module's existing references like ``TACallSite(...)`` resolve
44
+ # without qualification).
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ # AnalyzerContext is defined in ``contracts.py`` (re-imported above).
49
+ # Adding new context fields: edit ``contracts.py``, not this file.
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Mapping tables — definitions live in ``tables.py``; re-imported here so
54
+ # inline references inside this module (TA_CLASS_MAP[name], BUILTIN_VARS,
55
+ # etc.) keep resolving without qualification. The package-level
56
+ # ``__init__.py`` re-exports the same names for external consumers
57
+ # (``codegen/base.py``, ``support_checker.py``, and external tests).
58
+ # ---------------------------------------------------------------------------
59
+
60
+ from .tables import (
61
+ TA_CLASS_MAP,
62
+ TA_PERIOD_ARG,
63
+ TA_TUPLE_RETURNS,
64
+ TA_MULTI_CTOR,
65
+ TA_NO_CTOR,
66
+ BUILTIN_VARS,
67
+ BAR_FIELDS,
68
+ SKIP_FUNCS,
69
+ )
70
+
71
+ # TypeHelper mixin owns the Pine type-hint / expression -> TypeSpec / PineType
72
+ # inference helpers previously inlined in this module; see
73
+ # ``compiler/transpiler/analyzer/types.py``.
74
+ from .types import TypeHelper
75
+
76
+ # DiagnosticsHelper mixin owns _error / _warn / _input_diag_loc /
77
+ # _expr_to_str / _warn_if_unknown_source_id; see
78
+ # ``compiler/transpiler/analyzer/diagnostics.py``.
79
+ from .diagnostics import DiagnosticsHelper
80
+
81
+ # CallHandlers mixin owns the ~14 _handle_*_call dispatchers plus
82
+ # input-validation / TA-arg-merge helpers; see
83
+ # ``compiler/transpiler/analyzer/call_handlers.py``. Largest analyzer
84
+ # mixin (~500 lines).
85
+ from .call_handlers import CallHandlers
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Analyzer
90
+ # ---------------------------------------------------------------------------
91
+
92
+ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
93
+ """Semantic analysis pass over PineScript v6 AST.
94
+
95
+ Mixin chain (Python MRO is left-to-right; method names are
96
+ intentionally kept disjoint across mixins so the order is mostly
97
+ cosmetic):
98
+ * ``CallHandlers`` -- per-callee dispatch and bookkeeping for
99
+ ``ta.*`` / ``request.*`` / ``strategy.*`` / ``input.*`` /
100
+ ``fixnan(...)`` / user-function calls (``_handle_*_call``,
101
+ ``_merge_ta_args``, ``_merge_input_params``,
102
+ ``_validate_input_member_tv``, ...)
103
+ * ``DiagnosticsHelper`` -- ``_error`` / ``_warn`` /
104
+ ``_input_diag_loc`` / ``_expr_to_str`` /
105
+ ``_warn_if_unknown_source_id``
106
+ * ``TypeHelper`` -- Pine type-hint / expression -> TypeSpec /
107
+ PineType inference helpers (``_type_spec_from_hint``,
108
+ ``_type_spec_from_expr``, ``_extract_literal_value``, ...)
109
+
110
+ Future steps may further peel visitor / top-level / UDT mixins
111
+ out of this class; see ``compiler/transpiler/analyzer/`` for
112
+ the package layout.
113
+ """
114
+
115
+ def __init__(self, ast: Program, filename: str = "<stdin>") -> None:
116
+ self._ast = ast
117
+ self._filename = filename
118
+ self._symbols = SymbolTable()
119
+ self._ta_call_sites: list[TACallSite] = []
120
+ self._series_vars: set[str] = set()
121
+ self._series_bar_fields: set[str] = set()
122
+ self._var_members: list[tuple[str, PineType, str]] = []
123
+ self._func_infos: list[FuncInfo] = []
124
+ self._fixnan_sites: list[FixnanCallSite] = []
125
+ self._strategy_params: dict = {}
126
+ self._diagnostics: list[Diagnostic] = []
127
+ self._global_var_decls: list[tuple[str, PineType]] = []
128
+ self._global_expr_map: dict[str, Any] = {}
129
+ self._var_member_init_exprs: dict[str, Any] = {}
130
+ self._ta_counter = 0
131
+ self._fixnan_counter = 0
132
+ # Track user-defined function nodes for deferred analysis
133
+ self._func_defs: dict[str, FuncDef] = {}
134
+ # Track user-defined function return types
135
+ self._func_return_types: dict[str, PineType] = {}
136
+ # Track user-defined function tuple returns
137
+ self._func_returns_tuple: dict[str, bool] = {}
138
+ self._func_tuple_element_count: dict[str, int] = {}
139
+ # Track user-defined functions whose body returns a UDT instance —
140
+ # maps func_name -> UDT type name. Detected from the body's final
141
+ # expression (``=> Sample.new(...)`` or last stmt ``Sample.new(...)``).
142
+ # Probe: data/validation/udt-method-probe-20-udt-return-from-func.
143
+ self._func_udt_return_types: dict[str, str] = {}
144
+ # Per-function var_members and series_vars (for call-site cloning)
145
+ self._func_var_members: dict[str, list] = {} # func_name -> [(name, PineType, init_str)]
146
+ self._func_series_vars: dict[str, set] = {} # func_name -> set[str]
147
+ # Per-call-site TA tracking for user functions
148
+ self._func_ta_ranges: dict[str, tuple[int, int]] = {} # func_name -> (start, end) indices
149
+ self._func_call_site_count: dict[str, int] = {} # func_name -> count
150
+ self._func_call_cs_map: dict[int, tuple[str, int]] = {} # call_node_id -> (func_name, cs_idx)
151
+ # UDT field definitions: type_name -> {field_name: PineType}
152
+ self._udt_fields: dict[str, dict[str, PineType]] = {}
153
+ # var_name -> UDT type for variables holding UDT instances
154
+ self._udt_var_types: dict[str, str] = {}
155
+ self._collection_types: dict[str, TypeSpec] = {}
156
+ self._udt_field_type_specs: dict[str, dict[str, TypeSpec]] = {}
157
+ # Enum definitions: enum_name -> list of member names
158
+ self._enum_defs: dict[str, list[str]] = {}
159
+ self._enum_member_strings: dict[str, list[str]] = {}
160
+ # request.security rebinding helpers for mutable globals
161
+ self._global_binding_infos: dict[str, MutableGlobalInfo] = {}
162
+ self._global_reassigned_names: set[str] = set()
163
+ self._current_top_level_stmt: ASTNode | None = None
164
+ self._global_scope = True
165
+ self._static_vars: set[str] = set()
166
+
167
+ # Pre-populate builtins
168
+ self._populate_builtins()
169
+
170
+ def _populate_builtins(self) -> None:
171
+ """Add built-in PineScript variables to the global scope."""
172
+ dummy_loc = SourceLocation(file=self._filename, line=0, col=0, end_col=0)
173
+ for name, ptype in BUILTIN_VARS.items():
174
+ sym = Symbol(
175
+ name=name,
176
+ pine_type=ptype,
177
+ is_series=name in BAR_FIELDS,
178
+ is_var=False,
179
+ is_const=False,
180
+ const_value=None,
181
+ scope="global",
182
+ loc=dummy_loc,
183
+ )
184
+ self._symbols.define(sym)
185
+
186
+ def _ensure_pine_v6(self) -> None:
187
+ """PineForge implements PineScript v6 only; reject other versions or missing directive."""
188
+ if self._ast.version is None:
189
+ loc = SourceLocation(file=self._filename, line=1, col=1, end_col=1)
190
+ raise CompileError(
191
+ [
192
+ Diagnostic(
193
+ level=Level.ERROR,
194
+ phase=Phase.ANALYZER,
195
+ location=loc,
196
+ message=(
197
+ "Missing PineScript version directive. "
198
+ "PineForge supports PineScript v6 only."
199
+ ),
200
+ hint='Add //@version=6 as the first line of your script.',
201
+ )
202
+ ]
203
+ )
204
+ if self._ast.version != 6:
205
+ loc = SourceLocation(file=self._filename, line=1, col=1, end_col=1)
206
+ raise CompileError(
207
+ [
208
+ Diagnostic(
209
+ level=Level.ERROR,
210
+ phase=Phase.ANALYZER,
211
+ location=loc,
212
+ message=(
213
+ f"PineForge supports PineScript v6 only (found //@version={self._ast.version})."
214
+ ),
215
+ hint="Migrate the script to //@version=6 using TradingView's editor.",
216
+ )
217
+ ]
218
+ )
219
+
220
+ # ------------------------------------------------------------------
221
+ # Public entry point
222
+ # ------------------------------------------------------------------
223
+
224
+ def analyze(self) -> AnalyzerContext:
225
+ """Run semantic analysis and return the analyzer context."""
226
+ self._ensure_pine_v6()
227
+ self._visit(self._ast)
228
+
229
+ # Propagate call-site counts to sub-functions called within
230
+ # multi-call-site functions. If f() has N call sites and calls g()
231
+ # internally, g() also needs N call-site variants so each f_csK
232
+ # can call g_csK with isolated state.
233
+ self._propagate_call_site_counts()
234
+
235
+ # Keep only truly pure global expressions for request.security rebinding.
236
+ # Globals later reassigned with := become series/stateful variables and
237
+ # must not be rebound to their declaration-time initializer.
238
+ for name, info in self._global_binding_infos.items():
239
+ info.is_series = name in self._series_vars
240
+
241
+ mutable_global_infos = {
242
+ name: info
243
+ for name, info in self._global_binding_infos.items()
244
+ if info.is_var or name in self._global_reassigned_names
245
+ }
246
+
247
+ pure_global_expr_map = {
248
+ k: v for k, v in self._global_expr_map.items() if k not in mutable_global_infos
249
+ }
250
+
251
+ return AnalyzerContext(
252
+ ast=self._ast,
253
+ symbols=self._symbols,
254
+ ta_call_sites=self._ta_call_sites,
255
+ series_vars=self._series_vars,
256
+ series_bar_fields=self._series_bar_fields,
257
+ var_members=self._var_members,
258
+ func_infos=self._func_infos,
259
+ fixnan_sites=self._fixnan_sites,
260
+ strategy_params=self._strategy_params,
261
+ diagnostics=self._diagnostics,
262
+ filename=self._filename,
263
+ global_var_decls=self._global_var_decls,
264
+ global_expr_map=pure_global_expr_map,
265
+ var_member_init_exprs=self._var_member_init_exprs,
266
+ func_ta_ranges=self._func_ta_ranges,
267
+ func_call_cs_map=self._func_call_cs_map,
268
+ func_call_site_counts=self._func_call_site_count,
269
+ udt_defs=self._udt_fields,
270
+ enum_defs=self._enum_defs,
271
+ enum_member_strings=self._enum_member_strings,
272
+ security_calls=getattr(self, "_security_calls", []),
273
+ global_mutable_infos=mutable_global_infos,
274
+ func_var_members=self._func_var_members,
275
+ func_series_vars=self._func_series_vars,
276
+ udt_var_types=dict(self._udt_var_types),
277
+ collection_types=dict(self._collection_types),
278
+ udt_field_type_specs=dict(self._udt_field_type_specs),
279
+ )
280
+
281
+ def _record_global_binding_stmt(self, name: str, pine_type: PineType,
282
+ is_var: bool, decl_node: ASTNode | None = None) -> None:
283
+ info = self._global_binding_infos.get(name)
284
+ if info is None:
285
+ info = MutableGlobalInfo(
286
+ name=name,
287
+ pine_type=pine_type,
288
+ is_var=is_var,
289
+ decl_node=decl_node,
290
+ )
291
+ self._global_binding_infos[name] = info
292
+ else:
293
+ info.pine_type = pine_type
294
+ info.is_var = info.is_var or is_var
295
+ if decl_node is not None and info.decl_node is None:
296
+ info.decl_node = decl_node
297
+
298
+ top_stmt = self._current_top_level_stmt or decl_node
299
+ if top_stmt is not None and (not info.source_stmts or info.source_stmts[-1] is not top_stmt):
300
+ info.source_stmts.append(top_stmt)
301
+
302
+ def _collect_security_mutable_globals(
303
+ self, node: ASTNode | None, resolving: set[str] | None = None
304
+ ) -> set[str]:
305
+ if node is None:
306
+ return set()
307
+ if resolving is None:
308
+ resolving = set()
309
+
310
+ out: set[str] = set()
311
+
312
+ if isinstance(node, Identifier):
313
+ name = node.name
314
+ if name in self._global_binding_infos:
315
+ info = self._global_binding_infos[name]
316
+ if info.is_var or name in self._global_reassigned_names:
317
+ out.add(name)
318
+ if name in resolving:
319
+ return out
320
+ resolving.add(name)
321
+ for stmt in info.source_stmts:
322
+ out |= self._collect_security_mutable_globals(stmt, resolving)
323
+ resolving.remove(name)
324
+ return out
325
+ if name in self._global_expr_map and name not in resolving:
326
+ resolving.add(name)
327
+ out |= self._collect_security_mutable_globals(self._global_expr_map[name], resolving)
328
+ resolving.remove(name)
329
+ return out
330
+
331
+ if isinstance(node, FuncCall) and isinstance(node.callee, Identifier):
332
+ func_name = node.callee.name
333
+ if func_name in self._func_defs:
334
+ call_key = f"func:{func_name}"
335
+ if call_key in resolving:
336
+ return out
337
+ resolving.add(call_key)
338
+ for arg in node.args:
339
+ out |= self._collect_security_mutable_globals(arg, resolving)
340
+ for value in node.kwargs.values():
341
+ out |= self._collect_security_mutable_globals(value, resolving)
342
+ for stmt in self._func_defs[func_name].body:
343
+ out |= self._collect_security_mutable_globals(stmt, resolving)
344
+ resolving.remove(call_key)
345
+ return out
346
+
347
+ def walk(value: Any) -> None:
348
+ nonlocal out
349
+ if value is None:
350
+ return
351
+ if hasattr(value, "__dict__"):
352
+ out |= self._collect_security_mutable_globals(value, resolving)
353
+ return
354
+ if isinstance(value, (list, tuple)):
355
+ for item in value:
356
+ walk(item)
357
+ return
358
+ if isinstance(value, dict):
359
+ for item in value.values():
360
+ walk(item)
361
+
362
+ for child in vars(node).values():
363
+ walk(child)
364
+
365
+ return out
366
+
367
+ def _propagate_call_site_counts(self) -> None:
368
+ """Propagate call-site counts from parent functions to sub-functions.
369
+
370
+ If function F has N call sites and calls sub-function G internally,
371
+ G also needs N variants so that F_csK can call G_csK with isolated state.
372
+ This ensures every stateful sub-function gets per-call-site isolation
373
+ inherited from its parent.
374
+ """
375
+ from pineforge_codegen.ast_nodes import FuncCall, FuncDef, Identifier
376
+
377
+ # Collect all user function definitions from AST
378
+ func_defs: dict[str, FuncDef] = {}
379
+ for stmt in self._ast.body:
380
+ if isinstance(stmt, FuncDef):
381
+ func_defs[stmt.name] = stmt
382
+
383
+ # Find which functions call which sub-functions (direct calls only)
384
+ def _find_calls(node, known_funcs: set[str]) -> set[str]:
385
+ calls: set[str] = set()
386
+ if isinstance(node, FuncCall) and isinstance(node.callee, Identifier):
387
+ if node.callee.name in known_funcs:
388
+ calls.add(node.callee.name)
389
+ for attr_val in vars(node).values():
390
+ if isinstance(attr_val, list):
391
+ for item in attr_val:
392
+ if hasattr(item, '__dict__'):
393
+ calls |= _find_calls(item, known_funcs)
394
+ elif hasattr(attr_val, '__dict__'):
395
+ calls |= _find_calls(attr_val, known_funcs)
396
+ return calls
397
+
398
+ known_func_names = set(func_defs.keys())
399
+
400
+ # For each multi-call-site function, propagate count to sub-functions
401
+ # that have stateful locals (series vars or var members)
402
+ changed = True
403
+ while changed:
404
+ changed = False
405
+ for fname, count in list(self._func_call_site_count.items()):
406
+ if count <= 1:
407
+ continue
408
+ if fname not in func_defs:
409
+ continue
410
+ sub_calls = _find_calls(func_defs[fname], known_func_names)
411
+ for sub in sub_calls:
412
+ has_state = (sub in self._func_series_vars or
413
+ sub in self._func_var_members)
414
+ if not has_state:
415
+ continue
416
+ current = self._func_call_site_count.get(sub, 0)
417
+ if current < count:
418
+ self._func_call_site_count[sub] = count
419
+ changed = True
420
+
421
+ def _is_static_expression(self, node: ASTNode | None) -> bool:
422
+ if node is None:
423
+ return True
424
+
425
+ if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral, NaLiteral, ColorLiteral)):
426
+ return True
427
+
428
+ if isinstance(node, Identifier):
429
+ if node.name in ("open", "high", "low", "close", "volume", "hl2", "hlc3", "ohlc4", "time", "time_close"):
430
+ return True
431
+ if node.name in self._static_vars:
432
+ return True
433
+ sym = self._symbols.resolve(node.name)
434
+ if sym is not None:
435
+ if sym.is_const or node.name.startswith("input"):
436
+ return True
437
+ if getattr(sym, "is_static_series", False):
438
+ return True
439
+ return False
440
+
441
+ if isinstance(node, MemberAccess):
442
+ if isinstance(node.object, Identifier):
443
+ if node.object.name.startswith("input") or node.object.name in self._enum_defs:
444
+ return True
445
+ return self._is_static_expression(node.object)
446
+
447
+ if isinstance(node, BinOp):
448
+ return self._is_static_expression(node.left) and self._is_static_expression(node.right)
449
+
450
+ if isinstance(node, UnaryOp):
451
+ return self._is_static_expression(node.operand)
452
+
453
+ if isinstance(node, Ternary):
454
+ return (self._is_static_expression(node.condition) and
455
+ self._is_static_expression(node.true_val) and
456
+ self._is_static_expression(node.false_val))
457
+
458
+ if isinstance(node, Subscript):
459
+ return self._is_static_expression(node.object) and self._is_static_expression(node.index)
460
+
461
+ if isinstance(node, TupleLiteral):
462
+ return all(self._is_static_expression(elem) for elem in node.elements)
463
+
464
+ if isinstance(node, FuncCall):
465
+ if isinstance(node.callee, MemberAccess) and isinstance(node.callee.object, Identifier):
466
+ ns = node.callee.object.name
467
+ if ns in ("math", "str", "color"):
468
+ return all(self._is_static_expression(arg) for arg in node.args)
469
+ return False
470
+
471
+ return False
472
+
473
+ def _get_target_base_name(self, target: ASTNode) -> str | None:
474
+ if isinstance(target, Identifier):
475
+ return target.name
476
+ if isinstance(target, MemberAccess):
477
+ return self._get_target_base_name(target.object)
478
+ if isinstance(target, Subscript):
479
+ return self._get_target_base_name(target.object)
480
+ return None
481
+
482
+ # ------------------------------------------------------------------
483
+ # Visitor dispatch
484
+ # ------------------------------------------------------------------
485
+
486
+ def _visit(self, node: ASTNode | None) -> PineType:
487
+ """Dispatch to the appropriate visitor and return the inferred type."""
488
+ if node is None:
489
+ return PineType.VOID
490
+
491
+ method_name = f"_visit_{type(node).__name__}"
492
+ # Convert CamelCase to snake_case for method lookup
493
+ visitor = getattr(self, method_name, None)
494
+ if visitor is not None:
495
+ return visitor(node)
496
+
497
+ # Fallback: try generic visit
498
+ return PineType.VOID
499
+
500
+ # ------------------------------------------------------------------
501
+ # Top-level visitors
502
+ # ------------------------------------------------------------------
503
+
504
+ def _visit_Program(self, node: Program) -> PineType:
505
+ for stmt in node.body:
506
+ prev_top = self._current_top_level_stmt
507
+ self._current_top_level_stmt = stmt
508
+ self._visit(stmt)
509
+ self._current_top_level_stmt = prev_top
510
+ return PineType.VOID
511
+
512
+ def _visit_StrategyDecl(self, node: StrategyDecl) -> PineType:
513
+ # Extract strategy parameters
514
+ if node.args:
515
+ # First arg is title
516
+ title_node = node.args[0]
517
+ if isinstance(title_node, StringLiteral):
518
+ self._strategy_params["title"] = title_node.value
519
+ for key, val_node in node.kwargs.items():
520
+ self._strategy_params[key] = self._extract_literal_value(val_node)
521
+ return PineType.VOID
522
+
523
+ def _visit_ImportStmt(self, node: ImportStmt) -> PineType:
524
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
525
+ diag = Diagnostic(
526
+ level=Level.ERROR,
527
+ phase=Phase.ANALYZER,
528
+ location=loc,
529
+ message=f"Import is not supported: '{node.path}'",
530
+ )
531
+ raise CompileError([diag])
532
+
533
+ # ------------------------------------------------------------------
534
+ # Declaration / assignment visitors
535
+ # ------------------------------------------------------------------
536
+
537
+ def _udt_name_from_ctor(self, value: ASTNode) -> str | None:
538
+ """If value is ``TypeName.new(...)`` for a user-defined type, return TypeName."""
539
+ if not isinstance(value, FuncCall):
540
+ return None
541
+ cal = value.callee
542
+ if not isinstance(cal, MemberAccess) or not isinstance(cal.object, Identifier):
543
+ return None
544
+ owner = cal.object.name
545
+ if owner not in self._udt_fields:
546
+ return None
547
+ m = cal.member
548
+ if m == "new" or (isinstance(m, str) and m.startswith("new")):
549
+ return owner
550
+ return None
551
+
552
+ def _visit_VarDecl(self, node: VarDecl) -> PineType:
553
+ # Infer type from the value expression
554
+ val_type = self._visit(node.value)
555
+ type_spec = self._type_spec_from_hint(node.type_hint) if node.type_hint else None
556
+ if type_spec is None:
557
+ type_spec = self._type_spec_from_expr(node.value)
558
+
559
+ # Check for type hint override
560
+ if node.type_hint:
561
+ hint_type = self._type_hint_to_pine(node.type_hint)
562
+ if hint_type != PineType.UNKNOWN:
563
+ val_type = hint_type
564
+
565
+ # Check for input calls
566
+ is_const = False
567
+ const_value = None
568
+ enum_type_name: str | None = None
569
+ if isinstance(node.value, FuncCall):
570
+ ic = self._check_input_call(node.value)
571
+ if ic is not None:
572
+ val_type, is_const, const_value = ic
573
+ enum_type_name = self._input_enum_type_name(node.value)
574
+
575
+ udt_ctor = self._udt_name_from_ctor(node.value)
576
+ # User function return propagation: if the value is a call to a user
577
+ # function whose body returns ``T.new(...)``, the local picks up
578
+ # type ``T``. Without this the caller's symbol would track as
579
+ # ``double``, breaking ``s.score()`` dispatch downstream. Probe:
580
+ # data/validation/udt-method-probe-20-udt-return-from-func.
581
+ if udt_ctor is None and isinstance(node.value, FuncCall):
582
+ cal = node.value.callee
583
+ if isinstance(cal, Identifier):
584
+ udt_ctor = self._func_udt_return_types.get(cal.name)
585
+ if udt_ctor is not None and type_spec is None:
586
+ type_spec = TypeSpec.udt(udt_ctor)
587
+
588
+ if self._global_scope:
589
+ if self._is_static_expression(node.value):
590
+ self._static_vars.add(node.name)
591
+ else:
592
+ self._static_vars.discard(node.name)
593
+ else:
594
+ self._static_vars.discard(node.name)
595
+
596
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
597
+ sym = Symbol(
598
+ name=node.name,
599
+ pine_type=val_type,
600
+ is_series=False,
601
+ is_var=node.is_var or node.is_varip,
602
+ is_const=is_const,
603
+ const_value=const_value,
604
+ scope=self._symbols.current_scope.name,
605
+ loc=loc,
606
+ enum_type_name=enum_type_name,
607
+ udt_type_name=udt_ctor,
608
+ type_spec=type_spec,
609
+ )
610
+ if node.name in self._static_vars:
611
+ setattr(sym, "is_static_series", True)
612
+ self._symbols.define(sym)
613
+ if udt_ctor is not None:
614
+ self._udt_var_types[node.name] = udt_ctor
615
+ if type_spec is not None:
616
+ self._collection_types[node.name] = type_spec
617
+ if type_spec.kind == "udt" and type_spec.name:
618
+ self._udt_var_types[node.name] = type_spec.name
619
+
620
+ # Track var members
621
+ if node.is_var or node.is_varip:
622
+ init_str = self._expr_to_str(node.value)
623
+ self._var_members.append((node.name, val_type, init_str))
624
+ # Capture the init AST too so codegen can inspect the RHS callee
625
+ # (used to detect int64-returning builtins like ``time()`` and
626
+ # promote the symbol storage type to ``int64_t``).
627
+ if node.value is not None:
628
+ self._var_member_init_exprs[node.name] = node.value
629
+ # Track function-scoped var members
630
+ scope_name = self._symbols.current_scope.name
631
+ if scope_name.startswith("func_"):
632
+ func_name = scope_name[5:] # strip "func_" prefix
633
+ if func_name not in self._func_var_members:
634
+ self._func_var_members[func_name] = []
635
+ self._func_var_members[func_name].append((node.name, val_type, init_str))
636
+
637
+ # Track global-scope non-var declarations (needed as class members
638
+ # so user functions can reference them)
639
+ if (not node.is_var and not node.is_varip
640
+ and self._symbols.current_scope.name == "global"
641
+ and node.name not in self._series_vars):
642
+ self._global_var_decls.append((node.name, val_type))
643
+ self._global_expr_map[node.name] = node.value
644
+
645
+ if self._symbols.current_scope.name == "global":
646
+ self._record_global_binding_stmt(
647
+ node.name,
648
+ val_type,
649
+ node.is_var or node.is_varip,
650
+ decl_node=node,
651
+ )
652
+
653
+ return val_type
654
+
655
+ def _visit_Assignment(self, node: Assignment) -> PineType:
656
+ # Visit the value first
657
+ val_type = self._visit(node.value)
658
+
659
+ udt_ctor = self._udt_name_from_ctor(node.value)
660
+ if isinstance(node.target, Identifier) and udt_ctor is not None:
661
+ self._udt_var_types[node.target.name] = udt_ctor
662
+ if isinstance(node.target, Identifier):
663
+ spec = self._type_spec_from_expr(node.value)
664
+ if spec is not None:
665
+ self._collection_types[node.target.name] = spec
666
+
667
+ # Resolve the target
668
+ if isinstance(node.target, Identifier):
669
+ sym = self._symbols.resolve(node.target.name)
670
+ if sym is None:
671
+ self._error(
672
+ f"Undefined variable: '{node.target.name}'",
673
+ node.loc or node.target.loc,
674
+ )
675
+ else:
676
+ if sym.scope == "global":
677
+ self._global_reassigned_names.add(node.target.name)
678
+ self._record_global_binding_stmt(
679
+ node.target.name,
680
+ sym.pine_type,
681
+ sym.is_var,
682
+ )
683
+ if self._global_scope:
684
+ if self._is_static_expression(node.value):
685
+ self._static_vars.add(node.target.name)
686
+ setattr(sym, "is_static_series", True)
687
+ else:
688
+ self._static_vars.discard(node.target.name)
689
+ if hasattr(sym, "is_static_series"):
690
+ delattr(sym, "is_static_series")
691
+ else:
692
+ self._static_vars.discard(node.target.name)
693
+ if hasattr(sym, "is_static_series"):
694
+ delattr(sym, "is_static_series")
695
+ else:
696
+ self._visit(node.target)
697
+ base_name = self._get_target_base_name(node.target)
698
+ if base_name:
699
+ self._static_vars.discard(base_name)
700
+ sym = self._symbols.resolve(base_name)
701
+ if sym and hasattr(sym, "is_static_series"):
702
+ delattr(sym, "is_static_series")
703
+
704
+ return val_type
705
+
706
+ def _visit_TupleAssign(self, node: TupleAssign) -> PineType:
707
+ val_type = self._visit(node.value)
708
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
709
+
710
+ is_val_static = self._is_static_expression(node.value)
711
+
712
+ for name in node.names:
713
+ if name == "_":
714
+ continue
715
+
716
+ if self._global_scope and is_val_static:
717
+ self._static_vars.add(name)
718
+ else:
719
+ self._static_vars.discard(name)
720
+
721
+ sym = Symbol(
722
+ name=name,
723
+ pine_type=PineType.FLOAT, # tuple elements are typically float
724
+ is_series=False,
725
+ is_var=False,
726
+ is_const=False,
727
+ const_value=None,
728
+ scope=self._symbols.current_scope.name,
729
+ loc=loc,
730
+ )
731
+ if name in self._static_vars:
732
+ setattr(sym, "is_static_series", True)
733
+ self._symbols.define(sym)
734
+
735
+ return val_type
736
+
737
+ # ------------------------------------------------------------------
738
+ # Function definition
739
+ # ------------------------------------------------------------------
740
+
741
+ def _visit_FuncDef(self, node: FuncDef) -> PineType:
742
+ # Store the function def for later analysis
743
+ self._func_defs[node.name] = node
744
+
745
+ # Enter function scope
746
+ self._symbols.enter_scope(f"func_{node.name}")
747
+
748
+ # Define parameters (type unknown until called)
749
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
750
+ for param in node.params:
751
+ sym = Symbol(
752
+ name=param,
753
+ pine_type=PineType.UNKNOWN,
754
+ is_series=False,
755
+ is_var=False,
756
+ is_const=False,
757
+ const_value=None,
758
+ scope=f"func_{node.name}",
759
+ loc=loc,
760
+ )
761
+ self._symbols.define(sym)
762
+
763
+ # Record TA counter before visiting body
764
+ ta_start = len(self._ta_call_sites)
765
+
766
+ # Visit body to discover return type
767
+ body_type = PineType.VOID
768
+ old_global = self._global_scope
769
+ self._global_scope = False
770
+ try:
771
+ for stmt in node.body:
772
+ body_type = self._visit(stmt)
773
+ finally:
774
+ self._global_scope = old_global
775
+
776
+ # Record TA range for this function
777
+ ta_end = len(self._ta_call_sites)
778
+ if ta_end > ta_start:
779
+ self._func_ta_ranges[node.name] = (ta_start, ta_end)
780
+
781
+ self._symbols.exit_scope()
782
+
783
+ # Detect if function returns a tuple (last stmt is TupleLiteral)
784
+ self._func_returns_tuple[node.name] = False
785
+ self._func_tuple_element_count[node.name] = 0
786
+ if node.body:
787
+ last_stmt = node.body[-1]
788
+ tuple_node = None
789
+ if isinstance(last_stmt, ExprStmt) and isinstance(last_stmt.expr, TupleLiteral):
790
+ tuple_node = last_stmt.expr
791
+ elif isinstance(last_stmt, TupleLiteral):
792
+ tuple_node = last_stmt
793
+ if tuple_node is not None:
794
+ self._func_returns_tuple[node.name] = True
795
+ self._func_tuple_element_count[node.name] = len(tuple_node.elements)
796
+
797
+ # Detect if the function returns a UDT instance via ``T.new(...)`` —
798
+ # used by codegen to emit the C++ return type as the struct name and
799
+ # to propagate UDT typing onto the caller's local. Probe:
800
+ # data/validation/udt-method-probe-20-udt-return-from-func.
801
+ if node.body:
802
+ last_stmt = node.body[-1]
803
+ ret_expr = None
804
+ if isinstance(last_stmt, ExprStmt):
805
+ ret_expr = last_stmt.expr
806
+ elif not isinstance(last_stmt, (TupleLiteral,)):
807
+ # last_stmt is itself an expression node (single-expr funcs)
808
+ ret_expr = last_stmt if hasattr(last_stmt, "loc") else None
809
+ udt_ret = self._udt_name_from_ctor(ret_expr) if ret_expr is not None else None
810
+ if udt_ret is not None:
811
+ self._func_udt_return_types[node.name] = udt_ret
812
+
813
+ # Store return type
814
+ self._func_return_types[node.name] = body_type
815
+
816
+ # Define the function name in the enclosing scope
817
+ sym = Symbol(
818
+ name=node.name,
819
+ pine_type=body_type,
820
+ is_series=False,
821
+ is_var=False,
822
+ is_const=False,
823
+ const_value=None,
824
+ scope=self._symbols.current_scope.name,
825
+ loc=loc,
826
+ )
827
+ self._symbols.define(sym)
828
+
829
+ return PineType.VOID
830
+
831
+ # ------------------------------------------------------------------
832
+ # UDT visitors
833
+ # ------------------------------------------------------------------
834
+
835
+ def _visit_TypeDecl(self, node) -> PineType:
836
+ """Register UDT name and field types in symbol table."""
837
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
838
+ self._symbols.define(Symbol(
839
+ name=node.name, pine_type=PineType.FLOAT, is_series=False,
840
+ is_var=False, is_const=True, const_value=None,
841
+ scope="global", loc=loc,
842
+ ))
843
+ self._udt_fields[node.name] = {
844
+ f.name: self._type_hint_to_pine(f.type_name) for f in node.fields
845
+ }
846
+ self._udt_field_type_specs[node.name] = {
847
+ f.name: self._type_spec_from_hint(f.type_name) for f in node.fields
848
+ if self._type_spec_from_hint(f.type_name) is not None
849
+ }
850
+ for f in node.fields:
851
+ if f.default:
852
+ self._visit(f.default)
853
+ return PineType.VOID
854
+
855
+ def _visit_EnumDecl(self, node) -> PineType:
856
+ """Register user enum (derived type): ordinals in _enum_defs, field strings separately."""
857
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
858
+ # Enum *type* name in scope uses INT as a placeholder; real typing is via enum_defs.
859
+ self._symbols.define(Symbol(
860
+ name=node.name, pine_type=PineType.INT, is_series=False,
861
+ is_var=False, is_const=True, const_value=None,
862
+ scope="global", loc=loc,
863
+ ))
864
+ self._enum_defs[node.name] = node.members
865
+ # Parallel string payloads (arbitrary per field in TV); str.tostring uses this, not ordinals
866
+ strs: list[str] = []
867
+ for m in node.members:
868
+ av = node.member_values.get(m)
869
+ if isinstance(av, StringLiteral):
870
+ strs.append(av.value)
871
+ else:
872
+ strs.append(m)
873
+ self._enum_member_strings[node.name] = strs
874
+ return PineType.VOID
875
+
876
+ def _visit_MethodDef(self, node) -> PineType:
877
+ """Register UDT instance method under a unique key ``TypeName.methodName``."""
878
+ method_key = f"{node.type_name}.{node.name}"
879
+ self._symbols.enter_scope(f"method_{node.type_name}_{node.name}")
880
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
881
+ param_hints = (node.annotations or {}).get("param_type_hints", [])
882
+ param_types: list[PineType] = []
883
+ for i, p in enumerate(node.params):
884
+ udt_self = node.type_name if i == 0 else None
885
+ hint = param_hints[i] if i < len(param_hints) else None
886
+ ptype = self._type_hint_to_pine(hint) if hint else PineType.FLOAT
887
+ pspec = self._type_spec_from_hint(hint) if hint else None
888
+ param_types.append(ptype)
889
+ self._symbols.define(Symbol(
890
+ name=p, pine_type=ptype, is_series=False,
891
+ is_var=False, is_const=False, const_value=None,
892
+ scope=self._symbols.current_scope.name, loc=loc,
893
+ udt_type_name=udt_self,
894
+ type_spec=pspec,
895
+ ))
896
+ ret_type = PineType.VOID
897
+ old_global = self._global_scope
898
+ self._global_scope = False
899
+ try:
900
+ for stmt in node.body:
901
+ ret_type = self._visit(stmt)
902
+ finally:
903
+ self._global_scope = old_global
904
+ self._symbols.exit_scope()
905
+
906
+ # Detect tuple return on UDT methods (mirrors the regular FuncDef logic
907
+ # earlier in this file). Without this, codegen emits the method with a
908
+ # scalar return type and clang chokes on the ``std::make_tuple(...)``
909
+ # body. Probe: data/validation/udt-method-probe-17-tuple-return-destructure.
910
+ returns_tuple = False
911
+ tuple_element_count = 0
912
+ if node.body:
913
+ last_stmt = node.body[-1]
914
+ tuple_node = None
915
+ if isinstance(last_stmt, ExprStmt) and isinstance(last_stmt.expr, TupleLiteral):
916
+ tuple_node = last_stmt.expr
917
+ elif isinstance(last_stmt, TupleLiteral):
918
+ tuple_node = last_stmt
919
+ if tuple_node is not None:
920
+ returns_tuple = True
921
+ tuple_element_count = len(tuple_node.elements)
922
+
923
+ # Forward the parser-captured per-param defaults onto the FuncInfo so
924
+ # codegen can fill in missing args at UDT-method call sites. Probe:
925
+ # data/validation/udt-method-probe-04-default-param.
926
+ param_defaults = list((node.annotations or {}).get("param_defaults", []))
927
+ # Pad to len(params) for safety when an older parser did not record
928
+ # them (e.g., synthetic MethodDef nodes from tests).
929
+ while len(param_defaults) < len(node.params):
930
+ param_defaults.append(None)
931
+ fi = FuncInfo(
932
+ name=method_key,
933
+ param_types=param_types,
934
+ return_type=ret_type,
935
+ node=FuncDef(name=node.name, params=node.params,
936
+ body=node.body, is_single_expr=node.is_single_expr),
937
+ is_udt_method=True,
938
+ udt_type_name=node.type_name,
939
+ returns_tuple=returns_tuple,
940
+ tuple_element_count=tuple_element_count,
941
+ param_defaults=param_defaults,
942
+ )
943
+ self._func_infos.append(fi)
944
+ return PineType.VOID
945
+
946
+ # ------------------------------------------------------------------
947
+ # Control flow
948
+ # ------------------------------------------------------------------
949
+
950
+ def _visit_IfStmt(self, node: IfStmt) -> PineType:
951
+ old_global = self._global_scope
952
+ self._global_scope = False
953
+ try:
954
+ self._visit(node.condition)
955
+ body_type = PineType.VOID
956
+ for stmt in node.body:
957
+ body_type = self._visit(stmt)
958
+ for stmt in node.else_body:
959
+ self._visit(stmt)
960
+ finally:
961
+ self._global_scope = old_global
962
+ # If used as expression (x = if ...), return last expr type
963
+ return body_type
964
+
965
+ def _visit_ForStmt(self, node: ForStmt) -> PineType:
966
+ self._symbols.enter_scope("for")
967
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
968
+
969
+ # Define loop variable
970
+ sym = Symbol(
971
+ name=node.var,
972
+ pine_type=PineType.INT,
973
+ is_series=False,
974
+ is_var=False,
975
+ is_const=False,
976
+ const_value=None,
977
+ scope="for",
978
+ loc=loc,
979
+ )
980
+ self._symbols.define(sym)
981
+
982
+ old_global = self._global_scope
983
+ self._global_scope = False
984
+ try:
985
+ self._visit(node.start)
986
+ self._visit(node.end)
987
+ if node.step:
988
+ self._visit(node.step)
989
+ for stmt in node.body:
990
+ self._visit(stmt)
991
+ finally:
992
+ self._global_scope = old_global
993
+
994
+ self._symbols.exit_scope()
995
+ return PineType.VOID
996
+
997
+ def _visit_ForInStmt(self, node) -> PineType:
998
+ old_global = self._global_scope
999
+ self._global_scope = False
1000
+ try:
1001
+ self._visit(node.iterable)
1002
+ self._symbols.enter_scope("for_in")
1003
+ if node.var:
1004
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
1005
+ self._symbols.define(Symbol(
1006
+ name=node.var, pine_type=PineType.FLOAT, is_series=False,
1007
+ is_var=False, is_const=False, const_value=None,
1008
+ scope=self._symbols.current_scope.name, loc=loc,
1009
+ ))
1010
+ if node.vars:
1011
+ loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
1012
+ for v in node.vars:
1013
+ self._symbols.define(Symbol(
1014
+ name=v, pine_type=PineType.FLOAT, is_series=False,
1015
+ is_var=False, is_const=False, const_value=None,
1016
+ scope=self._symbols.current_scope.name, loc=loc,
1017
+ ))
1018
+ for stmt in node.body:
1019
+ self._visit(stmt)
1020
+ self._symbols.exit_scope()
1021
+ finally:
1022
+ self._global_scope = old_global
1023
+ return PineType.VOID
1024
+
1025
+ def _visit_WhileStmt(self, node: WhileStmt) -> PineType:
1026
+ old_global = self._global_scope
1027
+ self._global_scope = False
1028
+ try:
1029
+ self._visit(node.condition)
1030
+ for stmt in node.body:
1031
+ self._visit(stmt)
1032
+ finally:
1033
+ self._global_scope = old_global
1034
+ return PineType.VOID
1035
+
1036
+ def _visit_SwitchStmt(self, node: SwitchStmt) -> PineType:
1037
+ old_global = self._global_scope
1038
+ self._global_scope = False
1039
+ try:
1040
+ if node.expr:
1041
+ self._visit(node.expr)
1042
+ result_type = PineType.VOID
1043
+ for case_expr, case_body in node.cases:
1044
+ if case_expr:
1045
+ self._visit(case_expr)
1046
+ for stmt in case_body:
1047
+ result_type = self._visit(stmt)
1048
+ for stmt in node.default_body:
1049
+ self._visit(stmt)
1050
+ finally:
1051
+ self._global_scope = old_global
1052
+ # If used as expression (x = switch ...), return last expr type
1053
+ return result_type
1054
+
1055
+ def _visit_BreakStmt(self, node: BreakStmt) -> PineType:
1056
+ return PineType.VOID
1057
+
1058
+ def _visit_ContinueStmt(self, node: ContinueStmt) -> PineType:
1059
+ return PineType.VOID
1060
+
1061
+ # ------------------------------------------------------------------
1062
+ # Expression wrapper
1063
+ # ------------------------------------------------------------------
1064
+
1065
+ def _visit_ExprStmt(self, node: ExprStmt) -> PineType:
1066
+ return self._visit(node.expr)
1067
+
1068
+ # ------------------------------------------------------------------
1069
+ # Expression visitors
1070
+ # ------------------------------------------------------------------
1071
+
1072
+ def _visit_BinOp(self, node: BinOp) -> PineType:
1073
+ left_type = self._visit(node.left)
1074
+ right_type = self._visit(node.right)
1075
+
1076
+ # Comparison and logical operators return BOOL
1077
+ if node.op in ("==", "!=", ">", "<", ">=", "<=", "and", "or"):
1078
+ return PineType.BOOL
1079
+
1080
+ # String concatenation: if either side is STRING, result is STRING
1081
+ if left_type == PineType.STRING or right_type == PineType.STRING:
1082
+ return PineType.STRING
1083
+
1084
+ # Arithmetic: promote to FLOAT if either side is FLOAT
1085
+ if left_type == PineType.FLOAT or right_type == PineType.FLOAT:
1086
+ return PineType.FLOAT
1087
+ if left_type == PineType.INT and right_type == PineType.INT:
1088
+ return PineType.INT
1089
+
1090
+ # Division always returns FLOAT
1091
+ if node.op == "/":
1092
+ return PineType.FLOAT
1093
+
1094
+ return PineType.FLOAT # default
1095
+
1096
+ def _visit_UnaryOp(self, node: UnaryOp) -> PineType:
1097
+ operand_type = self._visit(node.operand)
1098
+ if node.op == "not":
1099
+ return PineType.BOOL
1100
+ return operand_type
1101
+
1102
+ def _visit_Ternary(self, node: Ternary) -> PineType:
1103
+ self._visit(node.condition)
1104
+ true_type = self._visit(node.true_val)
1105
+ false_type = self._visit(node.false_val)
1106
+
1107
+ # String type dominates
1108
+ if true_type == PineType.STRING or false_type == PineType.STRING:
1109
+ return PineType.STRING
1110
+ # Promote types
1111
+ if true_type == PineType.FLOAT or false_type == PineType.FLOAT:
1112
+ return PineType.FLOAT
1113
+ return true_type
1114
+
1115
+ def _visit_FuncCall(self, node: FuncCall) -> PineType:
1116
+ # Determine what is being called
1117
+ callee = node.callee
1118
+
1119
+ if isinstance(callee, MemberAccess):
1120
+ obj = callee.object
1121
+ member = callee.member
1122
+
1123
+ # ta.* calls
1124
+ if isinstance(obj, Identifier) and obj.name == "ta":
1125
+ if member == "sum":
1126
+ self._error(
1127
+ "PineScript has no ta.sum; use math.sum(source, length) for rolling sum",
1128
+ node.loc,
1129
+ )
1130
+ return self._handle_ta_call(member, node)
1131
+
1132
+ # strategy.* calls
1133
+ if isinstance(obj, Identifier) and obj.name == "strategy":
1134
+ return self._handle_strategy_call(member, node)
1135
+
1136
+ # input.* calls
1137
+ if isinstance(obj, Identifier) and obj.name == "input":
1138
+ return self._handle_input_member_call(member, node)
1139
+
1140
+ # math.* calls
1141
+ if isinstance(obj, Identifier) and obj.name == "math":
1142
+ # math.sum is a rolling sum — redirect to TA handling
1143
+ if member == "sum":
1144
+ return self._handle_ta_call("sum", node)
1145
+ # Visit args
1146
+ for arg in node.args:
1147
+ self._visit(arg)
1148
+ return PineType.FLOAT
1149
+
1150
+ # str.* calls
1151
+ if isinstance(obj, Identifier) and obj.name == "str":
1152
+ for arg in node.args:
1153
+ self._visit(arg)
1154
+ return PineType.STRING
1155
+
1156
+ # request.* calls
1157
+ if isinstance(obj, Identifier) and obj.name == "request":
1158
+ return self._handle_request_call(member, node)
1159
+
1160
+ # General member call (e.g., array.push, etc.)
1161
+ self._visit(obj)
1162
+ for arg in node.args:
1163
+ self._visit(arg)
1164
+ for val in node.kwargs.values():
1165
+ self._visit(val)
1166
+ # Matrix method dispatch: ``m.get(0, 0)`` on ``matrix<int>`` must
1167
+ # type as INT, not VOID, so ``v = m.get(...)`` propagates the
1168
+ # element PineType. ``_type_spec_from_expr`` already carries the
1169
+ # full TypeSpec for downstream codegen; this branch keeps the
1170
+ # legacy PineType-slot consumers (Symbol.pine_type, scalar
1171
+ # arithmetic inference) honest. See call_handlers.py
1172
+ # ``_handle_matrix_method``.
1173
+ if isinstance(obj, Identifier):
1174
+ recv_spec = self._collection_types.get(obj.name)
1175
+ if recv_spec is not None and recv_spec.kind == "matrix":
1176
+ return self._handle_matrix_method(member, recv_spec)
1177
+ return PineType.VOID
1178
+
1179
+ if isinstance(callee, Identifier):
1180
+ func_name = callee.name
1181
+
1182
+ # Skip functions (plot, etc.)
1183
+ if func_name in SKIP_FUNCS:
1184
+ # Still visit args for side effects
1185
+ for arg in node.args:
1186
+ self._visit(arg)
1187
+ for val in node.kwargs.values():
1188
+ self._visit(val)
1189
+ return PineType.VOID
1190
+
1191
+ # input() without qualifier
1192
+ if func_name == "input":
1193
+ return self._handle_input_call(node)
1194
+
1195
+ # fixnan
1196
+ if func_name == "fixnan":
1197
+ return self._handle_fixnan_call(node)
1198
+
1199
+ # nz
1200
+ if func_name == "nz":
1201
+ for arg in node.args:
1202
+ self._visit(arg)
1203
+ return PineType.FLOAT
1204
+
1205
+ # na() as function
1206
+ if func_name == "na":
1207
+ for arg in node.args:
1208
+ self._visit(arg)
1209
+ return PineType.BOOL
1210
+
1211
+ # color.* (e.g., color.new, color.rgb)
1212
+ if func_name == "color":
1213
+ for arg in node.args:
1214
+ self._visit(arg)
1215
+ return PineType.COLOR
1216
+
1217
+ # User-defined function call
1218
+ if func_name in self._func_defs:
1219
+ return self._handle_user_func_call(func_name, node)
1220
+
1221
+ # Built-in functions we don't specifically handle
1222
+ # Visit args for side effects
1223
+ for arg in node.args:
1224
+ self._visit(arg)
1225
+ for val in node.kwargs.values():
1226
+ self._visit(val)
1227
+
1228
+ # Check if it's a known symbol
1229
+ sym = self._symbols.resolve(func_name)
1230
+ if sym is not None:
1231
+ return sym.pine_type
1232
+
1233
+ return PineType.FLOAT # default for unknown functions
1234
+
1235
+ # Fallback
1236
+ self._visit(callee)
1237
+ for arg in node.args:
1238
+ self._visit(arg)
1239
+ for val in node.kwargs.values():
1240
+ self._visit(val)
1241
+ return PineType.VOID
1242
+
1243
+ def _visit_Subscript(self, node: Subscript) -> PineType:
1244
+ obj_type = self._visit(node.object)
1245
+ self._visit(node.index)
1246
+
1247
+ # Detect series vars / bar fields
1248
+ if isinstance(node.object, Identifier):
1249
+ name = node.object.name
1250
+ if name in BAR_FIELDS:
1251
+ self._series_bar_fields.add(name)
1252
+ else:
1253
+ sym = self._symbols.resolve(name)
1254
+ if sym is not None:
1255
+ if getattr(sym, "type_spec", None) is None or sym.type_spec.kind not in ("array", "map"):
1256
+ self._series_vars.add(name)
1257
+ sym.is_series = True
1258
+ # Track function-scoped series vars
1259
+ if sym.scope and sym.scope.startswith("func_"):
1260
+ func_name = sym.scope[5:]
1261
+ if func_name not in self._func_series_vars:
1262
+ self._func_series_vars[func_name] = set()
1263
+ self._func_series_vars[func_name].add(name)
1264
+
1265
+ return obj_type
1266
+
1267
+ def _visit_Identifier(self, node: Identifier) -> PineType:
1268
+ # Some identifiers are namespace prefixes handled elsewhere
1269
+ if node.name in ("strategy", "ta", "input", "math", "str", "color",
1270
+ "display", "syminfo", "timeframe", "plot",
1271
+ "alert", "barstate", "position", "shape", "location",
1272
+ "size", "currency", "order", "format", "text",
1273
+ "extend", "xloc", "yloc", "label", "line", "box",
1274
+ "table", "ticker", "request", "runtime", "chart",
1275
+ "barmerge", "adjustment", "earnings", "dividends",
1276
+ "splits", "session", "scale", "font",
1277
+ "hline", "backadjustment", "settlement_as_close",
1278
+ "dayofweek"):
1279
+ return PineType.VOID
1280
+
1281
+ sym = self._symbols.resolve(node.name)
1282
+ if sym is not None:
1283
+ return sym.pine_type
1284
+
1285
+ # Check if it's a user-defined function
1286
+ if node.name in self._func_defs:
1287
+ return self._func_return_types.get(node.name, PineType.FLOAT)
1288
+
1289
+ # Check for well-known PineScript built-in functions/types
1290
+ # that we didn't pre-populate (nz, na, fixnan, etc.)
1291
+ if node.name in ("nz", "na", "fixnan", "int", "float", "bool", "string",
1292
+ "array", "matrix", "label", "line", "box", "table",
1293
+ "log", "map", "type", "__array_literal__",
1294
+ "timestamp", "year", "month", "dayofmonth",
1295
+ "dayofweek", "hour", "minute", "second",
1296
+ "max_bars_back", "timenow", "barssince",
1297
+ "ta", "math", "str", "input", "color",
1298
+ "request", "ticker", "runtime"):
1299
+ return PineType.VOID
1300
+
1301
+ # Unknown identifier — treat as float (may be from skipped enum/type blocks)
1302
+ return PineType.FLOAT
1303
+
1304
+ def _visit_MemberAccess(self, node: MemberAccess) -> PineType:
1305
+ # Handle specific namespaces
1306
+ if isinstance(node.object, Identifier):
1307
+ ns = node.object.name
1308
+
1309
+ # strategy.* variables and constants
1310
+ if ns == "strategy":
1311
+ # Direction constants
1312
+ if node.member in ("long", "short"):
1313
+ return PineType.INT
1314
+ # Qty type constants
1315
+ if node.member in ("percent_of_equity", "fixed", "cash"):
1316
+ return PineType.INT
1317
+ # Commission type constants
1318
+ if node.member in ("commission",):
1319
+ return PineType.VOID # namespace prefix for .percent etc.
1320
+ # Integer strategy variables
1321
+ if node.member in ("closedtrades", "opentrades", "wintrades",
1322
+ "losstrades", "eventrades"):
1323
+ return PineType.INT
1324
+ # Float strategy variables
1325
+ if node.member in ("position_size", "position_avg_price",
1326
+ "equity", "initial_capital", "netprofit",
1327
+ "netprofit_percent", "openprofit",
1328
+ "openprofit_percent", "grossprofit",
1329
+ "grossprofit_percent", "grossloss",
1330
+ "grossloss_percent", "max_drawdown",
1331
+ "max_drawdown_percent", "max_runup",
1332
+ "max_runup_percent", "avg_trade",
1333
+ "avg_trade_percent", "avg_winning_trade",
1334
+ "avg_winning_trade_percent",
1335
+ "avg_losing_trade", "avg_losing_trade_percent",
1336
+ "margin_liquidation_price",
1337
+ "max_contracts_held_all",
1338
+ "max_contracts_held_long",
1339
+ "max_contracts_held_short"):
1340
+ return PineType.FLOAT
1341
+ # String strategy variables
1342
+ if node.member in ("account_currency", "position_entry_name"):
1343
+ return PineType.STRING
1344
+ # OCA / direction sub-namespaces
1345
+ if node.member in ("oca", "direction", "risk"):
1346
+ return PineType.VOID # namespace prefix
1347
+ # Default for unknown strategy members
1348
+ return PineType.INT
1349
+
1350
+ # ta.tr (no parens -- it's a property, not a function call)
1351
+ if ns == "ta":
1352
+ if node.member == "tr":
1353
+ # ta.tr uses close for previous bar
1354
+ self._series_bar_fields.add("close")
1355
+ self._series_bar_fields.add("high")
1356
+ self._series_bar_fields.add("low")
1357
+ return PineType.FLOAT
1358
+ # No-arg TA indicators used as properties (ta.obv, ta.accdist, etc.)
1359
+ _TA_PROPERTY_INDICATORS = {
1360
+ "obv", "accdist", "nvi", "pvi", "pvt", "wad", "wvad", "iii", "vwap",
1361
+ }
1362
+ if node.member in _TA_PROPERTY_INDICATORS and node.member in TA_CLASS_MAP:
1363
+ # Create a synthetic FuncCall node for the analyzer
1364
+ synthetic_call = FuncCall(
1365
+ callee=MemberAccess(object=Identifier(name="ta"), member=node.member),
1366
+ args=[], kwargs={},
1367
+ )
1368
+ return self._handle_ta_call(node.member, synthetic_call)
1369
+ return PineType.FLOAT
1370
+
1371
+ # math.* properties
1372
+ if ns == "math":
1373
+ return PineType.FLOAT
1374
+
1375
+ # syminfo.*
1376
+ if ns == "syminfo":
1377
+ if node.member == "mintick":
1378
+ return PineType.FLOAT
1379
+ return PineType.STRING
1380
+
1381
+ # color.* constants
1382
+ if ns == "color":
1383
+ return PineType.COLOR
1384
+
1385
+ # display.* constants
1386
+ if ns == "display":
1387
+ return PineType.INT
1388
+
1389
+ # plot.* constants (e.g., plot.style_areabr)
1390
+ if ns == "plot":
1391
+ return PineType.INT
1392
+
1393
+ # timeframe.*
1394
+ if ns == "timeframe":
1395
+ return PineType.STRING
1396
+
1397
+ # barstate.* (ishistory, isrealtime, islast, isfirst, etc.)
1398
+ if ns == "barstate":
1399
+ return PineType.BOOL
1400
+
1401
+ # alert.* constants (freq_once_per_bar, freq_once_per_bar_close, etc.)
1402
+ if ns == "alert":
1403
+ return PineType.INT
1404
+
1405
+ # position.* constants for tables (middle_right, top_left, etc.)
1406
+ if ns == "position":
1407
+ return PineType.INT
1408
+
1409
+ # shape.* constants (triangleup, triangledown, cross, etc.)
1410
+ if ns == "shape":
1411
+ return PineType.INT
1412
+
1413
+ # location.* constants (belowbar, abovebar, absolute, etc.)
1414
+ if ns == "location":
1415
+ return PineType.INT
1416
+
1417
+ # size.* constants (small, normal, large, etc.)
1418
+ if ns == "size":
1419
+ return PineType.INT
1420
+
1421
+ # currency.* constants (USD, EUR, TWD, etc.)
1422
+ if ns == "currency":
1423
+ return PineType.STRING
1424
+
1425
+ # order.* constants
1426
+ if ns == "order":
1427
+ return PineType.INT
1428
+
1429
+ # format.* constants
1430
+ if ns == "format":
1431
+ return PineType.INT
1432
+
1433
+ # text.* constants (align_left, align_right, etc.)
1434
+ if ns == "text":
1435
+ return PineType.INT
1436
+
1437
+ # extend.* constants (left, right, both, none)
1438
+ if ns == "extend":
1439
+ return PineType.INT
1440
+
1441
+ # xloc.*, yloc.* constants
1442
+ if ns in ("xloc", "yloc"):
1443
+ return PineType.INT
1444
+
1445
+ # label.*, line.*, box.*, table.* methods
1446
+ if ns in ("label", "line", "box", "table"):
1447
+ return PineType.VOID
1448
+
1449
+ # ticker.* functions
1450
+ if ns == "ticker":
1451
+ return PineType.STRING
1452
+
1453
+ # request.* (security, etc.) — skipped but valid
1454
+ if ns == "request":
1455
+ return PineType.FLOAT
1456
+
1457
+ # runtime.* (error, etc.)
1458
+ if ns == "runtime":
1459
+ return PineType.VOID
1460
+
1461
+ # chart.* (fg_color, bg_color, etc.)
1462
+ if ns == "chart":
1463
+ return PineType.COLOR
1464
+
1465
+ # barmerge.* (lookahead_off, gaps_off, etc.)
1466
+ if ns == "barmerge":
1467
+ return PineType.INT
1468
+
1469
+ # adjustment.*, session.* constants
1470
+ if ns in ("adjustment", "session"):
1471
+ return PineType.INT
1472
+
1473
+ # scale.*, font.*, backadjustment.*, settlement_as_close.* constants
1474
+ if ns in ("scale", "font", "backadjustment", "settlement_as_close"):
1475
+ return PineType.INT
1476
+
1477
+ # hline.* constants (style_dashed, style_dotted, etc.)
1478
+ if ns == "hline":
1479
+ return PineType.INT
1480
+
1481
+ # dayofweek.* constants (monday, tuesday, etc.)
1482
+ if ns == "dayofweek":
1483
+ return PineType.INT
1484
+
1485
+ # dividends.*, earnings.*, splits.* variables
1486
+ if ns in ("dividends", "earnings", "splits"):
1487
+ return PineType.FLOAT
1488
+
1489
+ sym = self._symbols.resolve(ns)
1490
+ udt_name = None
1491
+ if sym is not None:
1492
+ udt_name = sym.udt_type_name
1493
+ if udt_name is None and sym.type_spec is not None and sym.type_spec.kind == "udt":
1494
+ udt_name = sym.type_spec.name
1495
+ if udt_name:
1496
+ field_type = (self._udt_fields.get(udt_name) or {}).get(node.member)
1497
+ if field_type is not None:
1498
+ return field_type
1499
+
1500
+ # Handle nested member access (e.g., strategy.oca.reduce,
1501
+ # strategy.closedtrades.profit, strategy.commission.percent)
1502
+ if isinstance(node.object, MemberAccess):
1503
+ owner_spec = self._type_spec_from_expr(node.object)
1504
+ if owner_spec is not None and owner_spec.kind == "udt" and owner_spec.name:
1505
+ field_spec = (self._udt_field_type_specs.get(owner_spec.name) or {}).get(node.member)
1506
+ if field_spec is not None:
1507
+ return self._type_hint_to_pine(str(field_spec))
1508
+ self._visit(node.object)
1509
+ # strategy.closedtrades.* and strategy.opentrades.* return types
1510
+ if (isinstance(node.object.object, Identifier) and
1511
+ node.object.object.name == "strategy"):
1512
+ sub = node.object.member
1513
+ if sub in ("closedtrades", "opentrades"):
1514
+ # .profit, .entry_price, .exit_price, .commission, etc. → FLOAT
1515
+ # .entry_bar_index, .exit_bar_index → INT
1516
+ # .entry_id, .exit_id, .entry_comment, .exit_comment → STRING
1517
+ if node.member in ("entry_bar_index", "exit_bar_index",
1518
+ "first_index"):
1519
+ return PineType.INT
1520
+ if node.member in ("entry_id", "exit_id", "entry_comment",
1521
+ "exit_comment"):
1522
+ return PineType.STRING
1523
+ if node.member in ("entry_time", "exit_time"):
1524
+ return PineType.INT
1525
+ return PineType.FLOAT # profit, entry_price, etc.
1526
+ if sub == "commission":
1527
+ return PineType.INT # strategy.commission.percent, etc.
1528
+ if sub in ("oca", "direction", "risk"):
1529
+ return PineType.INT
1530
+ return PineType.INT
1531
+
1532
+ # General case
1533
+ obj_type = self._visit(node.object)
1534
+ return PineType.UNKNOWN
1535
+
1536
+ def _visit_TypeAnnotation(self, node: TypeAnnotation) -> PineType:
1537
+ return self._type_hint_to_pine(node.type_name)
1538
+
1539
+ # ------------------------------------------------------------------
1540
+ # Literal visitors
1541
+ # ------------------------------------------------------------------
1542
+
1543
+ def _visit_NumberLiteral(self, node: NumberLiteral) -> PineType:
1544
+ if isinstance(node.value, float):
1545
+ return PineType.FLOAT
1546
+ return PineType.INT
1547
+
1548
+ def _visit_StringLiteral(self, node: StringLiteral) -> PineType:
1549
+ return PineType.STRING
1550
+
1551
+ def _visit_BoolLiteral(self, node: BoolLiteral) -> PineType:
1552
+ return PineType.BOOL
1553
+
1554
+ def _visit_NaLiteral(self, node: NaLiteral) -> PineType:
1555
+ return PineType.NA
1556
+
1557
+ def _visit_ColorLiteral(self, node: ColorLiteral) -> PineType:
1558
+ return PineType.COLOR
1559
+
1560
+ def _visit_TupleLiteral(self, node: TupleLiteral) -> PineType:
1561
+ for elem in node.elements:
1562
+ self._visit(elem)
1563
+ return PineType.FLOAT