diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index e3e8531..8cc9690 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -118,6 +118,9 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._symbols = SymbolTable() self._ta_call_sites: list[TACallSite] = [] self._series_vars: set[str] = set() + self._series_var_members: set[str] = set() + self._series_decl_nodes: set[int] = set() + self._series_decl_bindings: set[tuple[int, str]] = set() self._series_bar_fields: set[str] = set() self._var_members: list[tuple[str, PineType, str]] = [] self._func_infos: list[FuncInfo] = [] @@ -132,6 +135,8 @@ def __init__(self, ast: Program, filename: str = "") -> None: # same raw identifier, so codegen must not reconstruct this mapping # from names when it emits declaration-site initialization. self._var_member_metadata_by_node: dict[int, tuple] = {} + self._var_member_type_specs_by_node: dict[int, TypeSpec | None] = {} + self._var_member_owners_by_node: dict[int, str | None] = {} # Block-scoped ``var``/``varip`` name-collision disambiguation. # Two same-named block-scoped vars in SIBLING non-global, non-function # scopes (e.g. ``var bool valid`` declared inside ``if A`` and again @@ -348,6 +353,7 @@ def analyze(self) -> AnalyzerContext: self._ensure_pine_v6() self._visit(self._ast) self._check_cross_callable_series_collection_collisions() + self._check_persistent_drawing_member_collisions() # Propagate call-site counts to sub-functions called within # multi-call-site functions. If f() has N call sites and calls g() @@ -381,6 +387,9 @@ def analyze(self) -> AnalyzerContext: symbols=self._symbols, ta_call_sites=self._ta_call_sites, series_vars=self._series_vars, + series_var_members=self._series_var_members, + series_decl_nodes=self._series_decl_nodes, + series_decl_bindings=self._series_decl_bindings, series_bar_fields=self._series_bar_fields, var_members=self._var_members, func_infos=self._func_infos, @@ -394,6 +403,8 @@ def analyze(self) -> AnalyzerContext: global_expr_map=pure_global_expr_map, var_member_init_exprs=self._var_member_init_exprs, var_member_metadata_by_node=self._var_member_metadata_by_node, + var_member_type_specs_by_node=self._var_member_type_specs_by_node, + var_member_owners_by_node=self._var_member_owners_by_node, func_ta_ranges=self._func_ta_ranges, func_call_cs_map=self._func_call_cs_map, func_call_site_counts=self._func_call_site_count, @@ -470,6 +481,191 @@ def _check_cross_callable_series_collection_collisions(self) -> None: node.loc if node is not None else None, ) + def _check_persistent_drawing_member_collisions(self) -> None: + """Fail closed when two lexical drawing vars share one C++ member. + + Persistent callable locals and the first block-scoped declaration still + use their raw Pine spelling as the class-member identity. Reusing that + spelling in another callable/global scope would silently share state + (and can emit uncompilable C++ when the handle kinds differ). Sibling + on-bar blocks that received ``__blkN`` identities are already safe. + Until all callable storage is owner-qualified, reject only the drawing + collisions made reachable by drawing-handle target typing. + """ + from .types import _DRAWING_TYPE_NAMES + + persistent_groups: dict[str, list[tuple[ASTNode, TypeSpec | None]]] = {} + for node_id, meta in self._var_member_metadata_by_node.items(): + node, member_name, _ptype, _init, _callable = meta + spec = self._var_member_type_specs_by_node.get(node_id) + persistent_groups.setdefault(member_name, []).append((node, spec)) + + def is_drawing(spec: TypeSpec | None) -> bool: + return bool( + spec is not None + and spec.kind == "udt" + and spec.name in _DRAWING_TYPE_NAMES + ) + + for member_name, bindings in persistent_groups.items(): + if len(bindings) < 2 or not any(is_drawing(spec) for _, spec in bindings): + continue + node = bindings[1][0] + self._error( + "Persistent drawing bindings named " + f"'{getattr(node, 'name', member_name)}' in distinct lexical " + "owners would share one generated state member; rename one " + "binding until callable-owned persistent storage is " + "owner-qualified.", + node.loc, + ) + + global_names = {name for name, _ptype in self._global_var_decls} + global_drawing_names: set[str] = set() + for stmt in self._ast.body: + if not isinstance(stmt, VarDecl) or stmt.is_var or stmt.is_varip: + continue + spec = ( + self._type_spec_from_hint(stmt.type_hint) + if stmt.type_hint + else self._type_spec_from_expr(stmt.value) + ) + if is_drawing(spec): + global_drawing_names.add(stmt.name) + + for member_name, bindings in persistent_groups.items(): + if member_name not in global_names: + continue + if not ( + member_name in global_drawing_names + or any(is_drawing(spec) for _, spec in bindings) + ): + continue + node = bindings[0][0] + self._error( + "Persistent drawing state named " + f"'{getattr(node, 'name', member_name)}' collides with a " + "top-level class-member binding; rename one binding until " + "persistent storage is owner-qualified.", + node.loc, + ) + + # Callable persistent drawing storage is currently cloned as a class + # member and remapped by raw Pine name. If such a declaration shadows + # an ancestor local or parameter, the preloaded clone remap and C++ + # lexical scope disagree about which binding is visible before/after + # the declaration. Reject this narrow shape instead of silently + # reading the parameter/local from the wrong storage. Independent + # sibling branches receive separate lexical inventories here (the + # existing distinct-owner collision gate above may still reject two + # persistent drawings that would share the same member identity). + callable_drawing_nodes = { + node_id + for node_id, meta in self._var_member_metadata_by_node.items() + if meta[4] and is_drawing( + self._var_member_type_specs_by_node.get(node_id) + ) + } + + def walk_callable_body( + body: list[ASTNode], + inherited_names: set[str], + ) -> None: + def walk_embedded_controls(value: Any, visible: set[str]) -> None: + """Find block-valued if/switch expressions inside an RHS. + + Pine permits ``x = if ...``. Those branch declarations have + the same storage hazard as statement-level blocks, and the RHS + must be inspected against the pre-declaration environment. + """ + if value is None: + return + if isinstance(value, IfStmt): + walk_embedded_controls(value.condition, visible) + walk_callable_body(value.body, visible) + walk_callable_body(value.else_body, visible) + return + if isinstance(value, SwitchStmt): + walk_embedded_controls(value.expr, visible) + for case_expr, case_body in value.cases: + walk_embedded_controls(case_expr, visible) + walk_callable_body(case_body, visible) + walk_callable_body(value.default_body, visible) + return + if isinstance(value, (FuncDef, MethodDef)): + return + if isinstance(value, (list, tuple)): + for item in value: + walk_embedded_controls(item, visible) + return + if isinstance(value, dict): + for item in value.values(): + walk_embedded_controls(item, visible) + return + if isinstance(value, ASTNode): + for child in vars(value).values(): + walk_embedded_controls(child, visible) + + visible = set(inherited_names) + for stmt in body: + if isinstance(stmt, VarDecl): + walk_embedded_controls(stmt.value, visible) + if (id(stmt) in callable_drawing_nodes + and stmt.name in visible): + self._error( + "Persistent drawing binding " + f"'{stmt.name}' shadows an ancestor callable " + "parameter or local; rename one binding until " + "callable-owned persistent storage has lexical " + "owner qualification.", + stmt.loc, + ) + visible.add(stmt.name) + continue + if isinstance(stmt, TupleAssign): + walk_embedded_controls(stmt.value, visible) + visible.update(stmt.names) + continue + if isinstance(stmt, Assignment): + walk_embedded_controls(stmt.target, visible) + walk_embedded_controls(stmt.value, visible) + continue + if isinstance(stmt, ExprStmt): + walk_embedded_controls(stmt.expr, visible) + continue + if isinstance(stmt, IfStmt): + walk_embedded_controls(stmt.condition, visible) + walk_callable_body(stmt.body, visible) + walk_callable_body(stmt.else_body, visible) + continue + if isinstance(stmt, WhileStmt): + walk_embedded_controls(stmt.condition, visible) + walk_callable_body(stmt.body, visible) + continue + if isinstance(stmt, ForStmt): + walk_embedded_controls(stmt.start, visible) + walk_embedded_controls(stmt.end, visible) + walk_embedded_controls(stmt.step, visible) + walk_callable_body(stmt.body, visible | {stmt.var}) + continue + if isinstance(stmt, ForInStmt): + walk_embedded_controls(stmt.iterable, visible) + loop_names = set(stmt.vars or []) + if stmt.var: + loop_names.add(stmt.var) + walk_callable_body(stmt.body, visible | loop_names) + continue + if isinstance(stmt, SwitchStmt): + walk_embedded_controls(stmt.expr, visible) + for case_expr, case_body in stmt.cases: + walk_embedded_controls(case_expr, visible) + walk_callable_body(case_body, visible) + walk_callable_body(stmt.default_body, visible) + + for stmt in self._ast.body: + if isinstance(stmt, (FuncDef, MethodDef)): + walk_callable_body(stmt.body, set(stmt.params)) + def _record_global_binding_stmt(self, name: str, pine_type: PineType, is_var: bool, decl_node: ASTNode | None = None) -> None: info = self._global_binding_infos.get(name) @@ -1233,59 +1429,124 @@ def _func_terminal_drawing_type(self, func_node: FuncDef) -> str | None: if not body: return None - # Map a drawing-handle local var name -> drawing type. Seeded from - # declared drawing type hints (``line result``) and the function's own - # drawing-typed parameters, plus any local first bound to a drawing - # ``.new(...)`` constructor. - local_drawing: dict[str, str] = {} + # Build a source-ordered lexical binding map. ``None`` is an explicit + # non-drawing tombstone: it prevents a same-named drawing declaration + # from a nested or earlier block from poisoning a later scalar return. + bindings: dict[str, str | None] = {} param_hints = (func_node.annotations or {}).get("param_type_hints", []) for i, p in enumerate(func_node.params): hint = param_hints[i] if i < len(param_hints) else None - if hint in _DRAWING_TYPE_NAMES: - local_drawing[p] = hint - - def _scan(stmts): - for st in stmts: - if isinstance(st, VarDecl): - if st.type_hint in _DRAWING_TYPE_NAMES: - local_drawing[st.name] = st.type_hint - else: - dt = self._udt_name_from_ctor(st.value) - if dt in _DRAWING_TYPE_NAMES: - local_drawing.setdefault(st.name, dt) - elif isinstance(st, Assignment) and isinstance(st.target, Identifier): - dt = self._udt_name_from_ctor(st.value) - if dt in _DRAWING_TYPE_NAMES: - local_drawing.setdefault(st.target.name, dt) - elif isinstance(st, IfStmt): - _scan(st.body) - _scan(st.else_body) - - _scan(body) - - def _resolve_terminal(stmt): - # An if used as the function's return expression: the value is the - # terminal of the executed branch — recurse into the body's (then - # else's) terminal statement. + bindings[p] = hint if hint in _DRAWING_TYPE_NAMES else None + + def _decl_drawing_type(stmt: VarDecl) -> str | None: + if stmt.type_hint in _DRAWING_TYPE_NAMES: + return stmt.type_hint + direct = self._udt_name_from_ctor(stmt.value) + if direct in _DRAWING_TYPE_NAMES: + return direct + exact = self._var_member_type_specs_by_node.get(id(stmt)) + if (exact is not None + and exact.kind == "udt" + and exact.name in _DRAWING_TYPE_NAMES): + return exact.name + spec = self._type_spec_from_expr(stmt.value) + if (spec is not None + and spec.kind == "udt" + and spec.name in _DRAWING_TYPE_NAMES): + return spec.name + return None + + def _scan_direct_prefix( + stmts: list[ASTNode], + env: dict[str, str | None], + ) -> None: + # Declarations inside nested control-flow bodies never leak into + # this lexical environment. Their own branch environment is + # created only when that control node is the terminal expression. + for stmt in stmts: + if isinstance(stmt, VarDecl): + env[stmt.name] = _decl_drawing_type(stmt) + elif isinstance(stmt, TupleAssign): + for name in stmt.names: + env[name] = None + + terminal_na = object() + + def _resolve_terminal( + stmt: ASTNode, + env: dict[str, str | None], + ) -> str | None | object: + if isinstance(stmt, ExprStmt): + return _resolve_terminal(stmt.expr, env) + if (isinstance(stmt, NaLiteral) + or (isinstance(stmt, Identifier) + and stmt.name == "na")): + return terminal_na if isinstance(stmt, IfStmt): + branch_results: list[str | None | object] = [] for branch in (stmt.body, stmt.else_body): - if branch: - t = _resolve_terminal(branch[-1]) - if t is not None: - return t - return None - expr = None - if isinstance(stmt, ExprStmt): - expr = stmt.expr - elif not isinstance(stmt, TupleLiteral) and hasattr(stmt, "loc"): - expr = stmt - if expr is None: - return None - if isinstance(expr, Identifier) and expr.name in local_drawing: - return local_drawing[expr.name] - return self._udt_name_from_ctor(expr) + if not branch: + continue # implicit na arm inherits the drawing type + branch_env = dict(env) + _scan_direct_prefix(branch[:-1], branch_env) + branch_results.append( + _resolve_terminal(branch[-1], branch_env) + ) + concrete = [ + item for item in branch_results if item is not terminal_na + ] + if not concrete: + return terminal_na + first = concrete[0] + return ( + first + if all(item == first for item in concrete) + else None + ) + if isinstance(stmt, SwitchStmt): + branch_results: list[str | None | object] = [] + for _case_expr, branch in stmt.cases: + if not branch: + continue + branch_env = dict(env) + _scan_direct_prefix(branch[:-1], branch_env) + branch_results.append( + _resolve_terminal(branch[-1], branch_env) + ) + if stmt.default_body: + branch_env = dict(env) + _scan_direct_prefix(stmt.default_body[:-1], branch_env) + branch_results.append( + _resolve_terminal(stmt.default_body[-1], branch_env) + ) + concrete = [ + item for item in branch_results if item is not terminal_na + ] + if not concrete: + return terminal_na + first = concrete[0] + return ( + first + if all(item == first for item in concrete) + else None + ) + if isinstance(stmt, VarDecl): + return _decl_drawing_type(stmt) + if isinstance(stmt, Identifier): + return env.get(stmt.name) + direct = self._udt_name_from_ctor(stmt) + if direct in _DRAWING_TYPE_NAMES: + return direct + spec = self._type_spec_from_expr(stmt) + if (spec is not None + and spec.kind == "udt" + and spec.name in _DRAWING_TYPE_NAMES): + return spec.name + return None - return _resolve_terminal(body[-1]) + _scan_direct_prefix(body[:-1], bindings) + resolved = _resolve_terminal(body[-1], bindings) + return resolved if isinstance(resolved, str) else None def _record_collection_type( self, @@ -1416,6 +1677,8 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: if node.name in self._static_vars: setattr(sym, "is_static_series", True) self._symbols.define(sym) + setattr(sym, "_pf_decl_node_id", id(node)) + setattr(sym, "_pf_decl_binding_name", node.name) if (type_spec is None and self._collection_scope_stack and self._block_node_stack @@ -1439,19 +1702,18 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: init_str = self._expr_to_str(node.value) scope_name = self._symbols.current_scope.name # Block-scoped var name-collision disambiguation. A ``var``/``varip`` - # declared inside a non-global, non-function block (an ``if`` / ``for`` - # / ``while`` body at on_bar scope) is keyed by RAW name. Two sibling - # blocks declaring the same name would dedupe to ONE C++ member and - # cross-contaminate (proven: egoigor1976-1-trendline-strategy's - # ``var bool valid`` in the upper- and lower-trendline ``if`` blocks). + # declared inside any non-global block (an ``if`` / ``for`` / + # ``while`` body, including one nested in a callable) is keyed by + # RAW name. Two sibling blocks declaring the same name would dedupe + # to ONE C++ member and cross-contaminate (proven: + # egoigor1976-1-trendline-strategy's ``var bool valid`` in the + # upper- and lower-trendline ``if`` blocks). # When such a name already belongs to a DIFFERENT block, mint a # scope-unique member name and record the rename so codegen activates # it (via ``_active_var_remap``) while emitting that block. member_name = node.name is_block_scoped = ( not self._global_scope - and not scope_name.startswith("func_") - and not scope_name.startswith("method_") and bool(self._block_node_stack) ) if is_block_scoped: @@ -1468,14 +1730,26 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: self._var_members.append((member_name, val_type, init_str)) scope_cursor = self._symbols.current_scope is_callable_scoped = False + callable_owner: str | None = None while scope_cursor is not None: if scope_cursor.name.startswith(("func_", "method_")): is_callable_scoped = True + callable_owner = ( + self._collection_scope_stack[-1] + if self._collection_scope_stack + else scope_cursor.name.split("_", 1)[1] + ) break scope_cursor = scope_cursor.parent self._var_member_metadata_by_node[id(node)] = ( node, member_name, val_type, init_str, is_callable_scoped, ) + self._var_member_type_specs_by_node[id(node)] = type_spec + self._var_member_owners_by_node[id(node)] = callable_owner + # Preserve the emitted storage identity on the lexical Symbol so a + # later history read can mark the exact sibling member rather than + # only the legacy raw spelling. + setattr(sym, "_pf_var_member_name", member_name) # Capture the init AST too so codegen can inspect the RHS callee # (used to detect int64-returning builtins like ``time()`` and # promote the symbol storage type to ``int64_t``). @@ -1486,7 +1760,9 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: func_name = scope_name[5:] # strip "func_" prefix if func_name not in self._func_var_members: self._func_var_members[func_name] = [] - self._func_var_members[func_name].append((node.name, val_type, init_str)) + self._func_var_members[func_name].append( + (member_name, val_type, init_str) + ) # Track global-scope non-var declarations (needed as class members # so user functions can reference them) @@ -1590,6 +1866,8 @@ def _visit_TupleAssign(self, node: TupleAssign) -> PineType: if name in self._static_vars: setattr(sym, "is_static_series", True) self._symbols.define(sym) + setattr(sym, "_pf_decl_node_id", id(node)) + setattr(sym, "_pf_decl_binding_name", name) if (self._collection_scope_stack and self._block_node_stack @@ -1739,6 +2017,12 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: if node.body: ret_expr = terminal_ret_expr udt_ret = self._udt_name_from_ctor(ret_expr) if ret_expr is not None else None + if (udt_ret is None + and terminal_direct_return_spec is not None + and terminal_direct_return_spec.kind == "udt"): + from .types import _DRAWING_TYPE_NAMES + if terminal_direct_return_spec.name in _DRAWING_TYPE_NAMES: + udt_ret = terminal_direct_return_spec.name if udt_ret is None: # Drawing-handle returns wrapped in an if-statement terminal # branch (``makeEventLabel``) or returned as a bare drawing-handle @@ -2900,6 +3184,18 @@ def _visit_Subscript(self, node: Subscript) -> PineType: node.loc, ) if getattr(sym, "type_spec", None) is None or sym.type_spec.kind not in ("array", "map"): + exact_member = getattr(sym, "_pf_var_member_name", None) + if exact_member is not None: + self._series_var_members.add(exact_member) + decl_node_id = getattr(sym, "_pf_decl_node_id", None) + if decl_node_id is not None: + self._series_decl_nodes.add(decl_node_id) + binding_name = getattr( + sym, "_pf_decl_binding_name", name + ) + self._series_decl_bindings.add( + (decl_node_id, binding_name) + ) global_sym = self._symbols.global_scope.symbols.get(name) shadows_map_state = ( sym.scope != "global" diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 47afec4..bfef8fe 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -1185,6 +1185,29 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: caller_name = sym.scope[5:] self._func_series_vars.setdefault(caller_name, set()).add(arg.name) else: + # Keep the exact declaration/member registries + # in lockstep with the legacy raw-name series + # promotion. Codegen uses those identities to + # distinguish the real global binding from a + # same-named lexical scalar tombstone. + exact_member = getattr( + sym, "_pf_var_member_name", None + ) + if exact_member is not None: + self._series_var_members.add(exact_member) + decl_node_id = getattr( + sym, "_pf_decl_node_id", None + ) + if decl_node_id is not None: + self._series_decl_nodes.add(decl_node_id) + binding_name = getattr( + sym, + "_pf_decl_binding_name", + arg.name, + ) + self._series_decl_bindings.add( + (decl_node_id, binding_name) + ) self._series_vars.add(arg.name) # Per-call-site cloning: TA, series/var, and fixnan state all advance diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index a299256..04b3d20 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -181,6 +181,18 @@ class AnalyzerContext: symbols: Any # SymbolTable ta_call_sites: list = field(default_factory=list) series_vars: set = field(default_factory=set) + # Exact emitted member identities for persistent ``var``/``varip`` + # bindings that are history-referenced. Unlike ``series_vars`` this set + # preserves sibling-block renames such as ``x__blk1``. + series_var_members: set = field(default_factory=set) + # Exact VarDecl node ids whose binding is history-referenced. This lets + # codegen distinguish a scalar local shadow from a same-named global + # Series without relying on the raw-name union above. + series_decl_nodes: set = field(default_factory=set) + # Exact declaration binding keys ``(node_id, raw_name)``. TupleAssign has + # one AST node for several independently scoped names, so node identity + # alone cannot say which destructured element owns history storage. + series_decl_bindings: set = field(default_factory=set) series_bar_fields: set = field(default_factory=set) var_members: list = field(default_factory=list) # [(name, PineType, init_expr_str)] func_infos: list = field(default_factory=list) @@ -202,6 +214,12 @@ class AnalyzerContext: # id(VarDecl) -> (node, emitted member name, PineType, rendered initializer, # is function/method scoped) var_member_metadata_by_node: dict = field(default_factory=dict) + # id(VarDecl) -> exact structured declaration type. Name-keyed type + # registries cannot distinguish sibling/callable persistent bindings. + var_member_type_specs_by_node: dict = field(default_factory=dict) + # id(VarDecl) -> owning callable key (``FuncInfo.name``), or None for + # top-level/on-bar persistent bindings. + var_member_owners_by_node: dict = field(default_factory=dict) # Per-call-site TA member cloning for user functions: func_ta_ranges: dict = field(default_factory=dict) # func_name -> (start_idx, end_idx) func_call_cs_map: dict = field(default_factory=dict) # call_node_id -> (func_name, call_site_index) diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index 96b954e..8677362 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -41,7 +41,7 @@ from ..ast_nodes import ( ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, IfStmt, - MemberAccess, NaLiteral, NumberLiteral, StringLiteral, Ternary, + MemberAccess, NaLiteral, NumberLiteral, StringLiteral, Subscript, Ternary, TupleLiteral, UnaryOp, ) from ..symbols import PineType, TypeSpec @@ -200,6 +200,34 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: and false_spec.kind == "map" and isinstance(value.true_val, NaLiteral)): return false_spec + # Drawing handles are nullable reference-like values in Pine. A + # bare ``na`` arm therefore acquires the other arm's exact handle + # type, just like the established PineMap path above. Keep this + # intentionally narrower than arbitrary UDTs/collections: their + # target-typed selection semantics are not established here. + if (true_spec is not None + and true_spec.kind == "udt" + and true_spec.name in _DRAWING_TYPE_NAMES + and isinstance(value.false_val, NaLiteral)): + return true_spec + if (false_spec is not None + and false_spec.kind == "udt" + and false_spec.name in _DRAWING_TYPE_NAMES + and isinstance(value.true_val, NaLiteral)): + return false_spec + return None + if isinstance(value, Subscript): + # Pine's history operator preserves the value type: a + # ``Series`` read such as ``h[1]`` is a scalar ``line`` + # handle, not the legacy numeric fallback. Keep this refinement + # drawing-only; collection subscripts have separate array/map + # semantics and primitive history inference already flows through + # PineType in ``_visit_Subscript``. + receiver_spec = self._type_spec_from_expr(value.object) + if (receiver_spec is not None + and receiver_spec.kind == "udt" + and receiver_spec.name in _DRAWING_TYPE_NAMES): + return receiver_spec return None if isinstance(value, IfStmt): def terminal_expr(body): @@ -214,6 +242,16 @@ def terminal_expr(body): return None true_spec = self._type_spec_from_expr(true_node) false_spec = self._type_spec_from_expr(false_node) + true_is_na = ( + isinstance(true_node, NaLiteral) + or (isinstance(true_node, Identifier) + and true_node.name == "na") + ) + false_is_na = ( + isinstance(false_node, NaLiteral) + or (isinstance(false_node, Identifier) + and false_node.name == "na") + ) if (true_spec is not None and true_spec.kind == "map" and true_spec == false_spec): @@ -226,6 +264,21 @@ def terminal_expr(body): and false_spec.kind == "map" and isinstance(true_node, NaLiteral)): return false_spec + if (true_spec is not None + and true_spec.kind == "udt" + and true_spec.name in _DRAWING_TYPE_NAMES + and true_spec == false_spec): + return true_spec + if (true_spec is not None + and true_spec.kind == "udt" + and true_spec.name in _DRAWING_TYPE_NAMES + and false_is_na): + return true_spec + if (false_spec is not None + and false_spec.kind == "udt" + and false_spec.name in _DRAWING_TYPE_NAMES + and true_is_na): + return false_spec return None if isinstance(value, FuncCall): cal = value.callee @@ -390,6 +443,10 @@ def terminal_expr(body): cal.name if isinstance(cal, Identifier) else None) if fname and fname in getattr(self, "_func_return_type_specs", {}): return self._func_return_type_specs[fname] + if fname and fname in getattr(self, "_func_udt_return_types", {}): + udt_return = self._func_udt_return_types[fname] + if udt_return in _DRAWING_TYPE_NAMES: + return TypeSpec.udt(udt_return) if isinstance(value, MemberAccess): owner = self._type_spec_from_expr(value.object) if owner is not None and owner.kind == "udt" and owner.name: diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 6a94fbc..046b139 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -173,6 +173,17 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm def __init__(self, ctx: AnalyzerContext) -> None: self.ctx = ctx + # Security metadata collection can render runtime timeframe + # expressions before the rest of the constructor state is prepared. + # Exact persistent-member identity is immutable analyzer output, so + # initialize it first for every expression visitor. + self._persistent_var_member_names: set[str] = { + self._safe_name(name) for name, _, _ in ctx.var_members + } + self._series_var_member_names: set[str] = { + self._safe_name(name) + for name in getattr(ctx, "series_var_members", set()) + } # Build lookup: node id -> TACallSite (only for non-function-local sites) self._ta_site_map: dict[int, TACallSite] = {} # Build per-call-site TA member name remapping for user functions @@ -382,6 +393,7 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._security_inline_counter = 0 self._random_call_counter = 0 self._for_counter = 0 + self._tuple_assign_counter = 0 # Synthetic history buffers used by inline call-history and by scalar # expressions passed to UDF series parameters. They are pre-registered # at generate() time so declarations precede method emission, then @@ -625,6 +637,15 @@ def __init__(self, ctx: AnalyzerContext) -> None: # Locals declared in the function currently being emitted (symbol table loses them after analysis) self._current_func_locals: set[str] = set() self._current_func_local_types: dict[str, str] = {} + # Source-ordered drawing-handle bindings in the currently emitted + # lexical block. Values are exact C++ handle types; ``None`` is a + # scalar/non-drawing tombstone that prevents an outer same-named handle + # from leaking inward. Block push/pop provides sibling isolation. + self._lexical_drawing_types: dict[str, str | None] = {} + # Source-ordered lexical Series status. A False tombstone prevents a + # scalar local from inheriting a same-spelled global entry from the + # legacy raw-name ``ctx.series_vars`` union. + self._lexical_series_bindings: dict[str, bool] = {} # for-in loop iterator names (must resolve member access, not enum fallback) self._current_loop_vars: set[str] = set() self._current_loop_var_specs: dict[str, "TypeSpec"] = {} @@ -749,7 +770,6 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._all_member_names.add(self._safe_name(name)) for name, _, _ in ctx.var_members: self._all_member_names.add(self._safe_name(name)) - self._register_global_aggregate_member_types() self._register_udt_array_get_ref_locals() self._uses_map = self._detect_map_usage() @@ -811,7 +831,9 @@ def _scalar_var_init_depends_on_runtime_input(self, init_ast) -> bool: return False def _is_runtime_scalar_var_initializer( - self, name: str, ptype, init_str: str, init_ast) -> bool: + self, name: str, ptype, init_str: str, init_ast, + drawing_cpp: str | None = None, + is_series: bool = False) -> bool: """Return True for a persistent primitive that must init in execution. Series and aggregate state keep their existing specialized preamble @@ -819,21 +841,32 @@ def _is_runtime_scalar_var_initializer( predicate only selects primitive global/on-bar-scope members for the declaration-site once guards prepared below. """ - if init_ast is None or name in self.ctx.series_vars: + if init_ast is None: return False if name in self._visual_drop_vars: return False if name in self._array_vars or name in self._map_vars \ or name in self._matrix_specs: return False + if drawing_cpp is not None: + # A nontrivial drawing-handle initializer may depend on preceding + # source-order state. Default construction already represents a + # bare ``na``, so only real expressions need a declaration-site + # once guard. + return ( + is_series + or not self._is_na_expr(init_ast) + ) + if name in self.ctx.series_vars: + return False + udt_type = self._udt_var_types.get(name) + if udt_type in self._udt_defs: + return False type_spec = self._collection_types.get(name) if type_spec is not None and type_spec.kind in { "array", "map", "matrix", "udt", }: return False - udt_type = self._udt_var_types.get(name) - if udt_type in self._udt_defs or udt_type in DRAWING_TYPE_TO_CPP: - return False ctor_val = self._resolve_known(init_str) ctor_val = self._typed_na_init(ctor_val, name, ptype) @@ -853,7 +886,12 @@ def _prepare_runtime_scalar_var_initializers(self) -> None: and Pine's lazy first-entry semantics for conditional declarations. """ self._runtime_scalar_var_init_by_node: dict[int, dict] = {} + self._runtime_scalar_var_init_by_member: dict[str, dict] = {} self._runtime_scalar_var_init_members: set[str] = set() + self._drawing_var_member_cpp_types: dict[str, str] = {} + self._drawing_var_decl_info_by_node: dict[int, dict] = {} + self._global_drawing_cpp_types: dict[str, str] = {} + self._runtime_var_init_flags: dict[tuple[int, str], str] = {} used_names = set(self._all_member_names) # ``_all_member_names`` historically covers persistent ``var`` and @@ -867,14 +905,50 @@ def _prepare_runtime_scalar_var_initializers(self) -> None: metadata_by_node = getattr( self.ctx, "var_member_metadata_by_node", {} ) or {} + type_specs_by_node = getattr( + self.ctx, "var_member_type_specs_by_node", {} + ) or {} + owners_by_node = getattr( + self.ctx, "var_member_owners_by_node", {} + ) or {} + top_level_node_ids = {id(stmt) for stmt in self.ctx.ast.body} for node_id, meta in metadata_by_node.items(): stmt, member_name, ptype, init_str, is_callable_scoped = meta - if is_callable_scoped: - continue if not isinstance(stmt, VarDecl) or not (stmt.is_var or stmt.is_varip): continue + stmt_spec = type_specs_by_node.get(node_id) + if stmt_spec is None: + stmt_spec = ( + self._type_spec_from_hint_name(stmt.type_hint) + if stmt.type_hint + else self._type_spec_from_expr(stmt.value) + ) + drawing_cpp = ( + DRAWING_TYPE_TO_CPP.get(stmt_spec.name) + if stmt_spec is not None and stmt_spec.kind == "udt" + else None + ) + if drawing_cpp is not None: + self._drawing_var_member_cpp_types[member_name] = drawing_cpp + self._drawing_var_decl_info_by_node[node_id] = { + "node_id": node_id, + "raw_name": stmt.name, + "member_name": member_name, + "drawing_cpp": drawing_cpp, + "is_callable_scoped": is_callable_scoped, + "owner": owners_by_node.get(node_id), + "type_spec": stmt_spec, + } + if node_id in top_level_node_ids: + self._global_drawing_cpp_types[stmt.name] = drawing_cpp + is_series = self._safe_name(member_name) in self._series_var_member_names + if node_id in self._drawing_var_decl_info_by_node: + self._drawing_var_decl_info_by_node[node_id]["is_series"] = is_series + if is_callable_scoped and drawing_cpp is None: + continue if not self._is_runtime_scalar_var_initializer( - member_name, ptype, init_str, stmt.value): + member_name, ptype, init_str, stmt.value, drawing_cpp, + is_series): continue base_flag = f"_pf_var_init_{self._safe_name(member_name)}" @@ -886,11 +960,58 @@ def _prepare_runtime_scalar_var_initializers(self) -> None: used_names.add(flag) self._all_member_names.add(flag) self._runtime_scalar_var_init_members.add(member_name) - self._runtime_scalar_var_init_by_node[node_id] = { + info = { "member_name": member_name, + "node_id": node_id, + "owner": owners_by_node.get(node_id), + "is_callable_scoped": is_callable_scoped, "ptype": ptype, "flag": flag, + "drawing_cpp": drawing_cpp, + "is_series": is_series, } + self._runtime_scalar_var_init_by_node[node_id] = info + base_storage = self._safe_name(member_name) + self._runtime_var_init_flags[(node_id, base_storage)] = flag + if is_callable_scoped: + owner = owners_by_node.get(node_id) + for (remap_owner, _cs_idx), remap in self._func_cs_var_remap.items(): + if remap_owner != owner or base_storage not in remap: + continue + storage = remap[base_storage] + if storage == base_storage: + continue + clone_base = f"_pf_var_init_{storage}" + clone_flag = clone_base + clone_suffix = 2 + while clone_flag in used_names: + clone_flag = f"{clone_base}_{clone_suffix}" + clone_suffix += 1 + used_names.add(clone_flag) + self._all_member_names.add(clone_flag) + self._runtime_var_init_flags[(node_id, storage)] = clone_flag + if not is_callable_scoped: + self._runtime_scalar_var_init_by_member[member_name] = info + + self._runtime_var_init_flag_used_names = used_names + + # Top-level non-persistent drawing declarations are not represented in + # var-member metadata. Record their exact source declaration type so + # functions can resolve globals without consulting the analyzer's + # legacy raw-name UDT registry (which later locals may overwrite). + for stmt in self.ctx.ast.body: + if not isinstance(stmt, VarDecl) or stmt.is_var or stmt.is_varip: + continue + spec = ( + self._type_spec_from_hint_name(stmt.type_hint) + if stmt.type_hint + else self._type_spec_from_expr(stmt.value) + ) + if (spec is not None and spec.kind == "udt" + and spec.name in DRAWING_TYPE_TO_CPP): + self._global_drawing_cpp_types[stmt.name] = ( + DRAWING_TYPE_TO_CPP[spec.name] + ) # ------------------------------------------------------------------ # Context-sensitive (call-path) instance machinery @@ -1135,7 +1256,13 @@ def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str, collection_spec = self._callable_var_collection_spec( vname, owner_func ) - if vname in self.ctx.series_vars: + drawing_cpp = self._drawing_var_member_cpp_types.get(vname) + if (drawing_cpp is not None + and orig_safe in self._series_var_member_names): + lines.append( + f" Series<{drawing_cpp}> {cloned_safe}{series_suffix};" + ) + elif vname in self.ctx.series_vars: lines.append(f" Series<{cpp_type}> {cloned_safe}{series_suffix};") elif collection_spec is not None: lines.append( @@ -1147,6 +1274,10 @@ def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str, lines.append(f" {self._type_spec_to_cpp(self._array_spec_for_name(vname))} {cloned_safe};") elif vname in self._map_vars: lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(vname))} {cloned_safe};") + elif drawing_cpp is not None: + lines.append( + f" {drawing_cpp} {cloned_safe} = {drawing_cpp}{{}};" + ) elif vname in self._udt_var_types: # Drawing handle / UDT var clone must match the original's # type (Line/Label/Box/), not the coarse PineType @@ -1165,6 +1296,45 @@ def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str, else: lines.append(f" double {cloned_safe} = 0.0;") + def _binding_is_series( + self, + raw_name: str, + resolved_safe_name: str | None = None, + ) -> bool: + """Resolve Series-ness without conflating renamed persistent siblings. + + ``ctx.series_vars`` is a legacy raw-name union. When a persistent + member identity is known, its exact membership wins; otherwise keep the + established raw-name behavior for ordinary non-var series, bar + builtins, and parameters. A call-site clone resolves to ``h_cs1`` but + inherits the base member ``h`` status, while a sibling rename such as + ``x__blk1`` is itself an exact member and therefore wins directly. + """ + base_safe = self._safe_name(raw_name) + resolved = resolved_safe_name or base_safe + if raw_name in self._lexical_series_bindings: + return self._lexical_series_bindings[raw_name] + if resolved in self._persistent_var_member_names: + return resolved in self._series_var_member_names + if base_safe in self._persistent_var_member_names: + return base_safe in self._series_var_member_names + return raw_name in self.ctx.series_vars + + def _decl_binding_is_series(self, node_id: int, raw_name: str) -> bool: + """Return exact history status for one declaration binding. + + A VarDecl has one name, but a TupleAssign node owns several names. The + node-only legacy set therefore cannot distinguish ``[x, y]`` when only + one element is history-referenced. Prefer the exact analyzer key and + retain the node-only fallback for older/manually-built contexts. + """ + exact = getattr(self.ctx, "series_decl_bindings", set()) or set() + if (node_id, raw_name) in exact: + return True + if any(binding_node == node_id for binding_node, _ in exact): + return False + return node_id in getattr(self.ctx, "series_decl_nodes", set()) + @staticmethod def _int_literal_value(node: ASTNode | None) -> int | None: """Return the integer value of a (possibly unary-minus) NumberLiteral, @@ -2294,6 +2464,56 @@ def walk_nodes(value): for child in walk_nodes(fi.node): owner_by_node[id(child)] = fi.name + # ``ctx.series_vars`` is a legacy raw-name union. A raw name can bind + # both an exact Series declaration and an unrelated scalar declaration + # (sibling blocks, or a callable local shadowing a global). Such calls + # may need a synthetic history bridge even though the raw name appears + # in that union, so reserve the bridge up front; source-order lexical + # state decides whether the emitted call actually uses it. + binding_series_states: dict[str, set[bool]] = {} + var_metadata = getattr( + self.ctx, "var_member_metadata_by_node", {} + ) or {} + for decl in walk_nodes(self.ctx.ast): + if isinstance(decl, VarDecl): + metadata = var_metadata.get(id(decl)) + if metadata is not None: + exact_member = self._safe_name(metadata[1]) + is_series = exact_member in self._series_var_member_names + else: + is_series = self._decl_binding_is_series( + id(decl), decl.name + ) + binding_series_states.setdefault(decl.name, set()).add( + is_series + ) + elif isinstance(decl, TupleAssign): + for name in decl.names: + if name != "_": + binding_series_states.setdefault(name, set()).add( + self._decl_binding_is_series(id(decl), name) + ) + elif isinstance(decl, ForStmt): + if decl.var: + binding_series_states.setdefault(decl.var, set()).add( + False + ) + elif isinstance(decl, ForInStmt): + loop_names = ( + [decl.var] + if decl.var + else list(decl.vars or []) + ) + for name in loop_names: + if name and name != "_": + binding_series_states.setdefault(name, set()).add( + False + ) + ambiguous_series_names = { + name for name, states in binding_series_states.items() + if len(states) > 1 + } + def register(kind: str, source_key: tuple, cpp_type: str, owner: str | None) -> None: if cpp_type not in ("double", "int", "bool"): @@ -2343,7 +2563,8 @@ def actual_args_for(call: FuncCall, params: list[str]) -> list: if isinstance(arg, Identifier): if arg.name in BAR_FIELDS or arg.name in BAR_SERIES_PUSH: continue - if arg.name in self.ctx.series_vars: + if (arg.name in self.ctx.series_vars + and arg.name not in ambiguous_series_names): continue register( "series_arg", (id(node), idx), self._infer_type(arg), owner @@ -2684,9 +2905,12 @@ def generate(self) -> str: # C++ handle struct (Series when also history-referenced). # Drawing names are NOT in _udt_defs, so the udt branch below would # self-zero them to double; handle them first. - _draw_cpp = DRAWING_TYPE_TO_CPP.get(self._udt_var_types.get(name)) + _draw_cpp = ( + self._drawing_var_member_cpp_types.get(name) + or DRAWING_TYPE_TO_CPP.get(self._udt_var_types.get(name)) + ) if _draw_cpp is not None: - if name in self.ctx.series_vars: + if safe in self._series_var_member_names: lines.append(f" Series<{_draw_cpp}> {safe}{_mbb};") else: lines.append(f" {_draw_cpp} {safe};") @@ -2707,7 +2931,14 @@ def generate(self) -> str: # (time/time_close/timestamp), otherwise the na sentinel narrows. if cpp_type == "int" and self._is_int64_builtin_init(name): cpp_type = "int64_t" - if name in self.ctx.series_vars: + # Persistent declarations have collision-safe exact member names + # (for example ``x`` and ``x__blk1`` for sibling lexical blocks). + # ``ctx.series_vars`` is only the legacy raw-name union, so using it + # here can invert the storage types: the unreferenced sibling gets + # Series while the exact history-referenced sibling stays a + # scalar. Exact analyzer identity must win for every persistent + # primitive, just as it already does for drawing handles above. + if safe in self._series_var_member_names: lines.append(f" Series<{cpp_type}> {safe}{_mbb};") else: if name in self._runtime_scalar_var_init_members: @@ -2851,8 +3082,36 @@ def generate(self) -> str: # initializers. Unlike the global aggregate/Series latch above, these # live at the declaration site so prior statements are available and a # conditional declaration initializes on its first actual execution. + used_runtime_flags = set(self._runtime_var_init_flag_used_names) for info in self._runtime_scalar_var_init_by_node.values(): - lines.append(f" bool {info['flag']} = false;") + if not info.get("is_callable_scoped"): + continue + node_id = info["node_id"] + base_storage = self._safe_name(info["member_name"]) + for inst in self._fresh_instances: + if inst.get("fname") != info.get("owner"): + continue + storage = inst.get("var_remap", {}).get( + base_storage, base_storage + ) + key = (node_id, storage) + if key in self._runtime_var_init_flags: + continue + flag_base = f"_pf_var_init_{storage}" + flag = flag_base + suffix = 2 + while flag in used_runtime_flags: + flag = f"{flag_base}_{suffix}" + suffix += 1 + used_runtime_flags.add(flag) + self._runtime_var_init_flags[key] = flag + + emitted_runtime_flags: set[str] = set() + for flag in self._runtime_var_init_flags.values(): + if flag in emitted_runtime_flags: + continue + emitted_runtime_flags.add(flag) + lines.append(f" bool {flag} = false;") # 9b. Per-function-variant ``var`` init flags. A function-scoped # ``var`` (Pine "init once" semantics) is a function-local static: diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index c09a94d..bb0d443 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -581,7 +581,9 @@ def _emit_constructor(self, lines: list[str]) -> None: # default-construct to {-1} (na). A ``b(na())`` ctor init # would not type-match the handle struct — skip it (the in-class # member default is the once-only persistent na init). - if name in self._udt_var_types and self._udt_var_types[name] in DRAWING_TYPE_TO_CPP: + if (name in self._drawing_var_member_cpp_types + or (name in self._udt_var_types + and self._udt_var_types[name] in DRAWING_TYPE_TO_CPP)): continue if name not in self.ctx.series_vars: cpp_val = self._resolve_known(init_expr) @@ -768,6 +770,8 @@ def _emit_history_series_write( lines.append(f"{pad}else {member}.update({value});") def _emit_on_bar(self, lines: list[str]) -> None: + self._lexical_drawing_types = {} + self._lexical_series_bindings = {} lines.append(" void on_bar(const Bar& bar) override {") # A GeneratedStrategy handle may execute multiple batch runs or @@ -849,6 +853,17 @@ def _emit_on_bar(self, lines: list[str]) -> None: if name in getattr(self, "_func_local_var_names", ()): continue safe = self._safe_name(name) + runtime_info = self._runtime_scalar_var_init_by_member.get(name) + if (runtime_info is not None + and runtime_info.get("drawing_cpp") is not None): + if runtime_info.get("is_series"): + self._emit_history_series_write( + lines, + " ", + safe, + f"{runtime_info['drawing_cpp']}{{}}", + ) + continue if name in self._array_vars: for stmt in self.ctx.ast.body: if isinstance(stmt, VarDecl) and stmt.name == name: @@ -877,6 +892,8 @@ def _emit_on_bar(self, lines: list[str]) -> None: lines.append(f" {safe} = {cpp_val};") break continue + if name in self._runtime_scalar_var_init_members: + continue # UDT vars: init with constructor expression init_s = str(init_expr) is_udt_init = False @@ -895,7 +912,7 @@ def _emit_on_bar(self, lines: list[str]) -> None: break if is_udt_init: continue - if name in self.ctx.series_vars: + if self._binding_is_series(name, safe): cpp_val = self._resolve_known(init_expr) cpp_val = self._typed_na_init(cpp_val, name, ptype) lines.append(f" {safe}.push({cpp_val});") @@ -913,9 +930,26 @@ def _emit_on_bar(self, lines: list[str]) -> None: lines.append(" } else {") for name, _, _ in self.ctx.var_members: safe = self._safe_name(name) + runtime_info = self._runtime_scalar_var_init_by_member.get(name) + if (runtime_info is not None + and runtime_info.get("drawing_cpp") is not None): + if runtime_info.get("is_series"): + lines.append(f" if ({runtime_info['flag']}) {{") + self._emit_history_series_write( + lines, " ", safe, f"{safe}[0]" + ) + lines.append(" } else {") + self._emit_history_series_write( + lines, + " ", + safe, + f"{runtime_info['drawing_cpp']}{{}}", + ) + lines.append(" }") + continue if name in self._array_vars: continue - if name in self.ctx.series_vars: + if self._binding_is_series(name, safe): self._emit_history_series_write( lines, " ", safe, f"{safe}[0]" ) @@ -931,6 +965,20 @@ def _emit_on_bar(self, lines: list[str]) -> None: self._emit_history_series_write( lines, " ", cloned, f"{cloned}[0]" ) + # Context-sensitive nested helper instances own fresh + # Series members outside the flat cs remap table. Advance + # each exact fresh member once per bar as well; otherwise a + # valid ``fresh[1]`` history read never moves past slot 0. + for _owner, orig_safe, fresh_safe in self._fresh_var_members: + if orig_safe != safe or fresh_safe in carry_emitted: + continue + carry_emitted.add(fresh_safe) + self._emit_history_series_write( + lines, + " ", + fresh_safe, + f"{fresh_safe}[0]", + ) lines.append(" }") # c. Push non-var series (they start fresh each bar with a push) @@ -1260,8 +1308,11 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No ret_type = self._type_spec_to_cpp(fi.return_type_spec) else: ret_type = PINE_TYPE_TO_CPP.get(fi.return_type, "double") - map_return_cpp_type = ( - ret_type if ret_type.startswith("PineMap<") else None + rhs_return_cpp_type = ( + ret_type + if (ret_type.startswith("PineMap<") + or ret_type in DRAWING_TYPE_TO_CPP.values()) + else None ) # For per-call-site variants, suffix the function name and activate TA + var remapping @@ -1303,6 +1354,8 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No prev_func_locals = self._current_func_locals prev_func_local_types = self._current_func_local_types + prev_lexical_drawing_types = self._lexical_drawing_types + prev_lexical_series_bindings = self._lexical_series_bindings prev_func_body = getattr(self, "_current_func_body", None) prev_func_name = getattr(self, "_active_func_name", None) # The function body is the lexical scope used by the UDT-alias analysis @@ -1317,6 +1370,11 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._udt_ptr_alias_locals = set() self._current_func_locals = {n for n, _, _ in self.ctx.func_var_members.get(fi.name, [])} self._current_func_local_types = {} + self._lexical_drawing_types = {} + self._lexical_series_bindings = { + param: param in self._current_func_series_params + for param in node.params + } # Plain (non-persistent) scalar locals are emitted inline and live in # no other set; collect them so the unknown-identifier guard in # _visit_ident does not mistake them for undeclared symbols. @@ -1375,7 +1433,7 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No elif expr: lines.append( " return " - f"{self._visit_rhs_value(expr, target_cpp_type=map_return_cpp_type)};" + f"{self._visit_rhs_value(expr, target_cpp_type=rhs_return_cpp_type)};" ) emitted_return = True else: @@ -1392,7 +1450,7 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No else: lines.append( " return " - f"{self._visit_rhs_value(s.expr, target_cpp_type=map_return_cpp_type)};" + f"{self._visit_rhs_value(s.expr, target_cpp_type=rhs_return_cpp_type)};" ) emitted_return = True elif i == len(node.body) - 1 and isinstance(s, (SwitchStmt, IfStmt)): @@ -1412,7 +1470,7 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No "_func_ret", lines, indent=2, - target_cpp_type=map_return_cpp_type, + target_cpp_type=rhs_return_cpp_type, ) lines.append(f" return _func_ret;") emitted_return = True @@ -1440,6 +1498,8 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._udt_param_udt = {} self._current_func_locals = prev_func_locals self._current_func_local_types = prev_func_local_types + self._lexical_drawing_types = prev_lexical_drawing_types + self._lexical_series_bindings = prev_lexical_series_bindings self._current_func_body = prev_func_body self._active_func_name = prev_func_name self._udt_ptr_alias_locals = prev_ptr_alias @@ -1485,7 +1545,15 @@ class member but its initializer was dropped, leaving the member ``na`` # so lowering each init expression here correctly resolves references # to sibling var members (which are themselves remapped for clones). init_lines: list[str] = [] + declaration_site_drawing_names = { + info["raw_name"] + for info in self._drawing_var_decl_info_by_node.values() + if info.get("owner") == fi.name + and info.get("node_id") in self._runtime_scalar_var_init_by_node + } for name, ptype, _init_str in members: + if name in declaration_site_drawing_names: + continue init_ast = self.ctx.var_member_init_exprs.get(name) safe = self._safe_name(name) target = self._active_var_remap.get(safe, safe) @@ -1496,13 +1564,22 @@ def activate_member() -> None: name, collection_spec ) - if name in self.ctx.series_vars: + if self._safe_name(name) in self._series_var_member_names: if init_ast is None: activate_member() continue - init_cpp = self._visit_expr(init_ast) - init_cpp = self._typed_na_init(init_cpp, name, ptype) - init_lines.append(f" {target}.push({init_cpp});") + drawing_cpp = self._drawing_var_member_cpp_types.get(name) + if drawing_cpp is not None: + init_cpp = self._visit_rhs_value( + init_ast, + name, + target_cpp_type=drawing_cpp, + ) + init_lines.append(f" {target}.update({init_cpp});") + else: + init_cpp = self._visit_expr(init_ast) + init_cpp = self._typed_na_init(init_cpp, name, ptype) + init_lines.append(f" {target}.push({init_cpp});") activate_member() continue if init_ast is None: @@ -1514,7 +1591,11 @@ def activate_member() -> None: # default-constructed member is already the na sentinel; assigning # ``na()`` would not type-match the handle / struct. udt_t = self._udt_var_types.get(name) - is_drawing = udt_t in DRAWING_TYPE_TO_CPP if udt_t else False + drawing_cpp = ( + self._drawing_var_member_cpp_types.get(name) + or DRAWING_TYPE_TO_CPP.get(udt_t) + ) + is_drawing = drawing_cpp is not None is_udt = udt_t in self._udt_defs if udt_t else False from ..ast_nodes import NaLiteral if (is_drawing or is_udt) and isinstance(init_ast, NaLiteral): @@ -1527,7 +1608,7 @@ def activate_member() -> None: self._type_spec_to_cpp(collection_spec) if collection_spec is not None and collection_spec.kind == "map" - else None + else drawing_cpp ), ) # A bare-``na`` initializer for an int/int64_t/bool ``var`` member diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 05fbb60..4989a36 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -36,7 +36,7 @@ from ..ast_nodes import ( BinOp, BoolLiteral, ExprStmt, FuncCall, FuncDef, Identifier, IfStmt, MemberAccess, NaLiteral, NumberLiteral, StringLiteral, SwitchStmt, - Ternary, TupleLiteral, UnaryOp, VarDecl, + Subscript, Ternary, TupleLiteral, UnaryOp, VarDecl, ) from ..symbols import PineType, TypeSpec from .. import signatures as sigs @@ -162,6 +162,8 @@ def _default_for_type(cpp_type: str) -> str: return "false" if cpp_type == "int": return "0" + if cpp_type in DRAWING_TYPE_TO_CPP.values(): + return f"{cpp_type}{{}}" if cpp_type.startswith("std::vector") or cpp_type.startswith("PineMap"): return f"{cpp_type}()" return "0.0" @@ -327,6 +329,30 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: collection_spec = self._collection_spec_for_name(node.name) if collection_spec is not None: return collection_spec + if node.name in getattr(self, "_lexical_drawing_types", {}): + lexical_cpp = self._lexical_drawing_types[node.name] + if lexical_cpp is None: + return None + for pine_name, cpp_name in DRAWING_TYPE_TO_CPP.items(): + if cpp_name == lexical_cpp: + return TypeSpec.udt(pine_name) + return None + local_cpp = getattr(self, "_current_func_local_types", {}).get( + node.name + ) + if local_cpp is not None: + local_cpp = local_cpp.removesuffix("&") + for pine_name, cpp_name in DRAWING_TYPE_TO_CPP.items(): + if cpp_name == local_cpp: + return TypeSpec.udt(pine_name) + return None + global_cpp = getattr(self, "_global_drawing_cpp_types", {}).get( + node.name + ) + if global_cpp is not None: + for pine_name, cpp_name in DRAWING_TYPE_TO_CPP.items(): + if cpp_name == global_cpp: + return TypeSpec.udt(pine_name) if node.name in self._udt_var_types: return TypeSpec.udt(self._udt_var_types[node.name]) # Drawing-typed method/function parameter (L.6d / U.5): a ``line ln`` @@ -341,11 +367,74 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: if sym is not None and getattr(sym, "type_spec", None) is not None: return sym.type_spec return None + if isinstance(node, Subscript): + # History access preserves the scalar drawing-handle type. This + # is intentionally not generalized to arrays/maps: their + # subscripting rules are handled by the collection paths below. + receiver_spec = self._type_spec_from_expr(node.object) + if (receiver_spec is not None + and receiver_spec.kind == "udt" + and receiver_spec.name in DRAWING_TYPE_TO_CPP): + return receiver_spec + return None if isinstance(node, Ternary): true_spec = self._type_spec_from_expr(node.true_val) false_spec = self._type_spec_from_expr(node.false_val) if true_spec is not None and true_spec == false_spec: return true_spec + if (true_spec is not None + and true_spec.kind == "udt" + and true_spec.name in DRAWING_TYPE_TO_CPP + and isinstance(node.false_val, NaLiteral)): + return true_spec + if (false_spec is not None + and false_spec.kind == "udt" + and false_spec.name in DRAWING_TYPE_TO_CPP + and isinstance(node.true_val, NaLiteral)): + return false_spec + return None + if isinstance(node, IfStmt): + def terminal_expr(body): + if not body: + return None + terminal = body[-1] + return ( + terminal.expr + if isinstance(terminal, ExprStmt) + else terminal + ) + + true_node = terminal_expr(node.body) + false_node = terminal_expr(node.else_body) + if true_node is None or false_node is None: + return None + true_spec = self._type_spec_from_expr(true_node) + false_spec = self._type_spec_from_expr(false_node) + true_is_na = ( + isinstance(true_node, NaLiteral) + or (isinstance(true_node, Identifier) + and true_node.name == "na") + ) + false_is_na = ( + isinstance(false_node, NaLiteral) + or (isinstance(false_node, Identifier) + and false_node.name == "na") + ) + if (true_spec is not None + and true_spec.kind == "udt" + and true_spec.name in DRAWING_TYPE_TO_CPP + and true_spec == false_spec): + return true_spec + if (true_spec is not None + and true_spec.kind == "udt" + and true_spec.name in DRAWING_TYPE_TO_CPP + and false_is_na): + return true_spec + if (false_spec is not None + and false_spec.kind == "udt" + and false_spec.name in DRAWING_TYPE_TO_CPP + and true_is_na): + return false_spec return None if isinstance(node, MemberAccess): owner = self._type_spec_from_expr(node.object) @@ -359,6 +448,9 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: return_spec = getattr(func_info, "return_type_spec", None) if return_spec is not None: return return_spec + udt_return = getattr(func_info, "udt_return_type", None) + if udt_return in DRAWING_TYPE_TO_CPP: + return TypeSpec.udt(udt_return) # ticker.* constructors (inherit/standard/heikinashi) return a symbol # string; without this the member-type inference defaults to double # and a ``haTicker = ticker.heikinashi(...)`` global mis-declares as @@ -1288,6 +1380,11 @@ def _infer_type(self, node) -> str: if spec is not None: return self._type_spec_to_cpp(spec) if isinstance(node, Ternary): + ternary_spec = self._type_spec_from_expr(node) + if (ternary_spec is not None + and ternary_spec.kind == "udt" + and ternary_spec.name in DRAWING_TYPE_TO_CPP): + return self._type_spec_to_cpp(ternary_spec) tt = self._infer_type(node.true_val) ft = self._infer_type(node.false_val) if tt.startswith("std::vector") or ft.startswith("std::vector"): @@ -1304,6 +1401,11 @@ def _infer_type(self, node) -> str: # Block-as-expression cases: read the type of the last statement of # the first branch / case; matches Pine semantics for ``x = if...``. if isinstance(node, IfStmt): + block_spec = self._type_spec_from_expr(node) + if (block_spec is not None + and block_spec.kind == "udt" + and block_spec.name in DRAWING_TYPE_TO_CPP): + return self._type_spec_to_cpp(block_spec) if node.body: last = node.body[-1] if isinstance(last, ExprStmt): diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 9afdd30..6566af9 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -1863,11 +1863,22 @@ def _visit_arg_for_series(arg_node, arg_idx): # Bar field: pass _s_close instead of current_bar_.close if aname in BAR_FIELDS or aname in BAR_SERIES_PUSH: return f"_s_{aname}" - # Series var: pass the Series object directly - if aname in self.ctx.series_vars: - safe = self._safe_name(aname) - if self._active_var_remap and safe in self._active_var_remap: - safe = self._active_var_remap[safe] + # Exact Series binding: pass the Series object directly. + # A raw name can also denote a scalar sibling/callable + # shadow, in which case lexical state must override the + # legacy ``ctx.series_vars`` union and fall through to the + # synthetic history bridge below. + safe = self._safe_name(aname) + # Function parameters are lexical C++ arguments in every + # emitted variant. The legacy function-series clone table + # can contain the same raw name, but applying it here makes + # cs1+ ignore the actual parameter and read an unrelated + # class member instead. + if aname in self._current_func_param_types: + return safe + if self._active_var_remap and safe in self._active_var_remap: + safe = self._active_var_remap[safe] + if self._binding_is_series(aname, safe): return safe expr_cpp = self._visit_expr(arg_node) cpp_t = self._infer_type(arg_node) diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index ece266c..81622be 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -222,7 +222,21 @@ def _visit_expr(self, node: ASTNode | None) -> str: if isinstance(node, Subscript): return self._visit_subscript(node) if isinstance(node, TupleLiteral): - elems = ", ".join(self._visit_expr(e) for e in node.elements) + elems = [] + for element in node.elements: + element_spec = self._type_spec_from_expr(element) + element_target = None + if (element_spec is not None + and element_spec.kind == "udt" + and element_spec.name in DRAWING_TYPE_TO_CPP): + element_target = self._type_spec_to_cpp(element_spec) + elems.append( + self._visit_rhs_value( + element, + target_cpp_type=element_target, + ) + ) + elems = ", ".join(elems) return f"std::make_tuple({elems})" return "/* unknown */" @@ -234,16 +248,52 @@ def _is_na_expr(self, node) -> bool: return (isinstance(node, NaLiteral) or (isinstance(node, Identifier) and node.name == "na")) - def _drawing_na_default(self, target_name: str | None) -> str | None: - """If ``target_name`` is a drawing-handle variable (line/box/label/ - linefill/chart.point), return its na default literal (e.g. ``Box{}`` — - a default-constructed handle whose id == -1 == na); otherwise None.""" + def _drawing_target_cpp_type( + self, + target_name: str | None, + target_cpp_type: str | None, + ) -> str | None: + """Resolve a drawing target without leaking a shadowed global type. + + An explicit contextual type always wins. For reassignments, where the + caller may not have one, consult function-local and parameter types + before the analyzer's flat global drawing registry. A same-named + scalar local/parameter must therefore block the global fallback. + """ + if target_cpp_type is not None: + return ( + target_cpp_type + if target_cpp_type in DRAWING_TYPE_TO_CPP.values() + else None + ) if not target_name: return None - udt = self._udt_var_types.get(target_name) - if udt in DRAWING_TYPE_TO_CPP: - return f"{DRAWING_TYPE_TO_CPP[udt]}{{}}" - return None + if target_name in self._lexical_drawing_types: + return self._lexical_drawing_types[target_name] + param_spec = getattr(self, "_current_func_param_specs", {}).get( + target_name + ) + if (param_spec is not None + and param_spec.kind == "udt" + and param_spec.name in DRAWING_TYPE_TO_CPP): + return DRAWING_TYPE_TO_CPP[param_spec.name] + for scoped_types in ( + getattr(self, "_current_func_local_types", {}), + getattr(self, "_current_func_param_types", {}), + ): + if target_name in scoped_types: + scoped_type = scoped_types[target_name].removesuffix("&") + return ( + scoped_type + if scoped_type in DRAWING_TYPE_TO_CPP.values() + else None + ) + # Only declarations already emitted into the current lexical path may + # shadow a global. Scanning the whole future function body makes a + # later local declaration pre-shadow earlier statements. Exact + # top-level types are captured separately because the analyzer's flat + # raw-name registry can be overwritten by a same-named local. + return self._global_drawing_cpp_types.get(target_name) def _visit_rhs_value(self, value_node, target_name: str | None = None, target_cpp_type: str | None = None) -> str: @@ -256,34 +306,39 @@ def _visit_rhs_value(self, value_node, target_name: str | None = None, ``string s = na;`` would both emit ``na()`` and fail to compile (no viable ``operator=`` / conversion). Every other RHS lowers unchanged. """ + drawing_target = self._drawing_target_cpp_type( + target_name, + target_cpp_type, + ) if self._is_na_expr(value_node): - draw_default = self._drawing_na_default(target_name) - if draw_default is not None: - return draw_default + if drawing_target is not None: + return f"{drawing_target}{{}}" if target_cpp_type and target_cpp_type.startswith("PineMap<"): # PineMap's default constructor is the typed ``na`` ID. A # map.new call is the only operation that allocates storage. return f"{target_cpp_type}{{}}" if target_cpp_type in ("std::string", "int", "int64_t", "bool"): return f"na<{target_cpp_type}>()" - if (target_cpp_type - and target_cpp_type.startswith("PineMap<") - and isinstance(value_node, Ternary)): + if (isinstance(value_node, Ternary) + and ((target_cpp_type + and target_cpp_type.startswith("PineMap<")) + or drawing_target is not None)): # C++ cannot deduce a common type for ``na()`` and a - # PineMap handle. Pine's ternary is target typed, so propagate the - # declared/reassignment target into both arms. Keep this map-only: - # every non-map ternary remains byte-for-byte on the established - # generic expression path. + # PineMap/drawing handle. Pine's ternary is target typed, so + # propagate the exact declared/reassignment target into both arms. + # Arbitrary UDTs, arrays, matrices, and scalar ternaries retain the + # established generic expression path. + branch_target = drawing_target or target_cpp_type condition = self._visit_expr(value_node.condition) true_value = self._visit_rhs_value( value_node.true_val, target_name, - target_cpp_type=target_cpp_type, + target_cpp_type=branch_target, ) false_value = self._visit_rhs_value( value_node.false_val, target_name, - target_cpp_type=target_cpp_type, + target_cpp_type=branch_target, ) return ( f"(({condition}) ? ({true_value}) : ({false_value}))" @@ -346,7 +401,7 @@ def _visit_ident(self, node: Identifier) -> str: # Apply per-call-site var remap (for function-local vars) if self._active_var_remap and safe in self._active_var_remap: safe = self._active_var_remap[safe] - if name in self.ctx.series_vars: + if self._binding_is_series(name, safe): return f"{safe}[0]" # Safety net: by here the name resolved to none of the builtins, # constants, parameters, or declared variables handled above, so @@ -956,11 +1011,12 @@ def _visit_subscript(self, node: Subscript) -> str: if name in BAR_FIELDS or name in BAR_SERIES_PUSH: # Index matches Pine: [0] current bar, [k] k bars ago (runtime Series deque). return f"_s_{name}[{idx}]" - if name in self.ctx.series_vars: - safe = self._safe_name(name) - # Apply per-call-site var remap - if self._active_var_remap and safe in self._active_var_remap: - safe = self._active_var_remap[safe] + safe = self._safe_name(name) + # Apply per-call-site / exact block-member remap before deciding + # whether the current lexical binding is a Series. + if self._active_var_remap and safe in self._active_var_remap: + safe = self._active_var_remap[safe] + if self._binding_is_series(name, safe): # Same Pine [k] semantics as Series in runtime/series.hpp return f"{safe}[{idx}]" spec = self._collection_spec_for_name(name) diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 0613e3f..b25ed53 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -101,6 +101,7 @@ from ..symbols import TypeSpec from .tables import ( ARRAY_NEW_CTORS, + DRAWING_TYPE_TO_CPP, TA_RETURNS_BOOL, TA_TUPLE_FIELDS, MATRIX_RETURNING_METHODS, @@ -200,6 +201,60 @@ def _visit_stmt(self, node: ASTNode, lines: list[str], indent: int) -> None: node.name, activation_spec, ) + metadata = getattr( + self.ctx, "var_member_metadata_by_node", {} + ).get(id(node)) + if metadata is not None: + member_name = metadata[1] + if member_name != node.name: + # The declaration RHS was emitted against the inherited + # binding. Only now activate the collision-safe member for + # later statements in this exact lexical block. + exact_storage = self._safe_name(member_name) + # A callable variant may already map the exact member to a + # per-call-site clone (``x__blk1`` -> ``x__blk1_cs1``). + # Preserve that outer clone mapping when exposing the raw + # lexical spelling after the declaration. + exact_storage = self._active_var_remap.get( + exact_storage, exact_storage + ) + self._active_var_remap = dict(self._active_var_remap) + self._active_var_remap[self._safe_name(node.name)] = ( + exact_storage + ) + drawing_info = self._drawing_var_decl_info_by_node.get(id(node)) + if (drawing_info is not None + and drawing_info.get("is_callable_scoped")): + raw_safe = self._safe_name(node.name) + storage = self._active_var_remap.get( + raw_safe, self._safe_name(member_name) + ) + if storage.startswith("this->"): + storage = storage[len("this->"):] + self._active_var_remap = dict(self._active_var_remap) + self._active_var_remap[raw_safe] = f"this->{storage}" + self._lexical_series_bindings[node.name] = ( + self._safe_name(member_name) + in self._series_var_member_names + ) + else: + self._lexical_series_bindings[node.name] = ( + self._decl_binding_is_series(id(node), node.name) + ) + decl_spec = getattr( + self.ctx, "var_member_type_specs_by_node", {} + ).get(id(node)) + if decl_spec is None: + decl_spec = ( + self._type_spec_from_hint_name(node.type_hint) + if node.type_hint + else self._type_spec_from_expr(node.value) + ) + self._lexical_drawing_types[node.name] = ( + DRAWING_TYPE_TO_CPP.get(decl_spec.name) + if decl_spec is not None and decl_spec.kind == "udt" + else None + ) if ( node.name == "map" and getattr(self, "_block_map_visibility_depth", 0) > 0 @@ -217,6 +272,30 @@ def _visit_stmt(self, node: ASTNode, lines: list[str], indent: int) -> None: for name in node.names: if name and name != "_": self._activate_callable_collection_binding(name, None) + scalar_names = [ + name + for name in node.names + if name + and name != "_" + and not self._decl_binding_is_series(id(node), name) + ] + if any( + self._safe_name(name) in self._active_var_remap + for name in scalar_names + ): + # Tuple declarations are lexical bindings. A scalar element + # must shadow any same-spelled Series storage inherited from a + # broad callable call-site remap; otherwise a later read can + # silently resolve to an unrelated ``x_csN`` member even + # though the structured binding declared a local ``x``. + self._active_var_remap = dict(self._active_var_remap) + for name in scalar_names: + self._active_var_remap.pop(self._safe_name(name), None) + for name in node.names: + if name and name != "_": + self._lexical_series_bindings[name] = ( + self._decl_binding_is_series(id(node), name) + ) elif isinstance(node, IfStmt): self._visit_if(node, lines, indent) elif isinstance(node, ForStmt): @@ -348,6 +427,18 @@ def _map_target_cpp_type( return self._type_spec_to_cpp(spec) def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: + member_meta = getattr( + self.ctx, "var_member_metadata_by_node", {} + ).get(id(node)) + if member_meta is not None: + declaration_is_series = ( + self._safe_name(member_meta[1]) + in self._series_var_member_names + ) + else: + declaration_is_series = ( + self._decl_binding_is_series(id(node), node.name) + ) # Primitive runtime var/varip initializers execute at the declaration # site under a dedicated once flag. This preserves source order (a # preceding input/plain/TA declaration is already available) and Pine's @@ -359,18 +450,50 @@ def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: if info is not None: member_name = info["member_name"] target = self._safe_name(member_name) + if self._active_var_remap and target in self._active_var_remap: + target = self._active_var_remap[target] + flag = self._runtime_var_init_flags.get( + (info["node_id"], target), + info["flag"], + ) + target_expr = ( + f"this->{target}" + if info.get("is_callable_scoped") + else target + ) + flag_expr = ( + f"this->{flag}" + if info.get("is_callable_scoped") + else flag + ) previous_input_name = self._current_input_var_name self._current_input_var_name = node.name try: - init_cpp = self._visit_expr(node.value) + if info.get("drawing_cpp") is not None: + init_cpp = self._visit_rhs_value( + node.value, + member_name, + target_cpp_type=info["drawing_cpp"], + ) + else: + init_cpp = self._visit_expr(node.value) finally: self._current_input_var_name = previous_input_name - init_cpp = self._typed_na_init( - init_cpp, member_name, info["ptype"] - ) - lines.append(f"{pad}if (!{info['flag']}) {{") - lines.append(f"{pad} {target} = {init_cpp};") - lines.append(f"{pad} {info['flag']} = true;") + if info.get("drawing_cpp") is None: + init_cpp = self._typed_na_init( + init_cpp, member_name, info["ptype"] + ) + lines.append(f"{pad}if (!{flag_expr}) {{") + # A history-referenced persistent primitive is a Series too, + # not just a drawing handle. The per-bar carry has already + # advanced it before this declaration-site one-shot runs, so + # replace the current slot rather than assigning a scalar to + # the Series object (or pushing a duplicate bar). + if info.get("is_series"): + lines.append(f"{pad} {target_expr}.update({init_cpp});") + else: + lines.append(f"{pad} {target_expr} = {init_cpp};") + lines.append(f"{pad} {flag_expr} = true;") lines.append(f"{pad}}}") return @@ -378,8 +501,15 @@ def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: # Apply per-call-site var remap (for function-local vars) if self._active_var_remap and safe in self._active_var_remap: safe = self._active_var_remap[safe] - # Global-scope non-var vars are class members — emit assignment, not declaration - is_global_member = node.name in self._global_member_vars + # Only a declaration in the script body can bind a hoisted global + # member. A callable-local declaration may legally shadow that global + # with the same raw name; treating it as the member emits an assignment + # into unrelated storage (and can assign a scalar into Series when + # the global was promoted for an indirect history call). + is_global_member = ( + getattr(self, "_active_func_name", None) is None + and node.name in self._global_member_vars + ) def remember_local_type(cpp_type: str | None) -> None: if cpp_type and not is_global_member: @@ -410,7 +540,7 @@ def remember_local_type(cpp_type: str | None) -> None: self._enforce_enum_declared_before_input_enum(node.value) title = self._get_input_title(node.value, var_name=node.name) cpp_val = self._render_input_value(node.value, func_name_i, namespace_i, title) - if node.name in self.ctx.series_vars: + if declaration_is_series: self._emit_history_series_write(lines, pad, safe, cpp_val) elif is_global_member: lines.append(f"{pad}{safe} = {cpp_val};") @@ -539,7 +669,7 @@ def remember_local_type(cpp_type: str | None) -> None: f"(history_advances_new_bar() ? {ta_name}.compute({compute_args}) " f": {ta_name}.recompute({compute_args}))" ) - if node.name in self.ctx.series_vars: + if declaration_is_series: self._emit_history_series_write(lines, pad, safe, ta_expr) elif is_global_member: lines.append(f"{pad}{safe} = {ta_expr};") @@ -548,32 +678,49 @@ def remember_local_type(cpp_type: str | None) -> None: return # Non-var series variable — push instead of declare - if node.name in self.ctx.series_vars: - cpp_val = self._visit_expr(node.value) + if declaration_is_series: + target_cpp_type = self._type_for_decl(node) + cpp_val = self._visit_rhs_value( + node.value, + node.name, + target_cpp_type=( + target_cpp_type + if target_cpp_type in DRAWING_TYPE_TO_CPP.values() + else None + ), + ) self._emit_history_series_write(lines, pad, safe, cpp_val) return # If/switch expression: x = if cond ... else ... if isinstance(node.value, (IfStmt, SwitchStmt)): cpp_type = self._type_for_decl(node) if not is_global_member else None - map_cpp_type = ( + selection_cpp_type = ( cpp_type - if cpp_type is not None and cpp_type.startswith("PineMap<") + if (cpp_type is not None + and (cpp_type.startswith("PineMap<") + or cpp_type in DRAWING_TYPE_TO_CPP.values())) else self._map_target_cpp_type( name=node.name, type_hint=node.type_hint, ) ) + if selection_cpp_type is None: + selection_cpp_type = self._drawing_target_cpp_type( + node.name, + cpp_type, + ) if not is_global_member: default = self._default_for_type(cpp_type) lines.append(f"{pad}{cpp_type} {safe} = {default};") + remember_local_type(cpp_type) indent = len(pad) // 4 self._visit_if_switch_expr( node.value, safe, lines, indent, - target_cpp_type=map_cpp_type, + target_cpp_type=selection_cpp_type, ) return @@ -687,17 +834,22 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non if isinstance(node.value, (IfStmt, SwitchStmt)): target_name = self._get_target_name(node.target) safe = self._safe_name(target_name) if target_name else self._visit_expr(node.target) - map_cpp_type = self._map_target_cpp_type( + selection_cpp_type = self._map_target_cpp_type( name=target_name, target_node=node.target if target_name is None else None, ) + if selection_cpp_type is None: + selection_cpp_type = self._drawing_target_cpp_type( + target_name, + None, + ) indent = len(pad) // 4 self._visit_if_switch_expr( node.value, safe, lines, indent, - target_cpp_type=map_cpp_type, + target_cpp_type=selection_cpp_type, ) return @@ -748,8 +900,15 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non lines.append(f"{pad}{safe} = {self._addr_of_udt_selection(node.value, target_name)};") return - if target_name in self.ctx.series_vars: - val_cpp = self._visit_expr(node.value) + if self._binding_is_series(target_name, safe): + val_cpp = self._visit_rhs_value( + node.value, + target_name, + target_cpp_type=self._drawing_target_cpp_type( + target_name, + None, + ), + ) if node.op == ":=": lines.append(f"{pad}{safe}.update({val_cpp});") else: @@ -821,6 +980,43 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non lines.append(f"{pad}{safe} {node.op} {val_cpp};") def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> None: + def emit_call_tuple(call_expr: str) -> None: + """Destructure once, routing exact history elements to Series. + + A structured binding always creates scalar C++ locals, which would + shadow the class Series storage required by a later ``x[n]`` read. + Materialize the tuple once whenever any element is exact-Series; + scalar elements remain locals while Series elements advance their + per-call-site remapped members. + """ + series_names = { + name + for name in node.names + if name != "_" + and self._decl_binding_is_series(id(node), name) + } + if not series_names: + binding_names = ", ".join(node.names) + lines.append(f"{pad}auto [{binding_names}] = {call_expr};") + return + + temp = f"_tuple_result_{self._tuple_assign_counter}" + self._tuple_assign_counter += 1 + lines.append(f"{pad}auto {temp} = {call_expr};") + for idx, name in enumerate(node.names): + if name == "_": + continue + value = f"std::get<{idx}>({temp})" + safe = self._safe_name(name) + if name in series_names: + if safe in self._active_var_remap: + safe = self._active_var_remap[safe] + self._emit_history_series_write( + lines, pad, safe, value + ) + else: + lines.append(f"{pad}auto {safe} = {value};") + site = self._get_ta_site(node.value) if site is not None: compute_args = self._ta_compute_args_for_site(site) @@ -848,8 +1044,10 @@ def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> # fresh ``double`` local would shadow the member and make # ``dir[n]`` a scalar subscript (clang error). Non-series # destructured names keep the plain scalar declaration. - if name in self.ctx.series_vars: + if self._decl_binding_is_series(id(node), name): safe = self._safe_name(name) + if safe in self._active_var_remap: + safe = self._active_var_remap[safe] self._emit_history_series_write( lines, pad, safe, field_expr ) @@ -861,14 +1059,12 @@ def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> if isinstance(node.value, FuncCall): func_name, namespace = self._resolve_callee(node.value.callee) if namespace == "request" and func_name == "security": - binding_names = ", ".join(n for n in node.names if n != "_") call_expr = self._visit_func_call(node.value) - lines.append(f"{pad}auto [{binding_names}] = {call_expr};") + emit_call_tuple(call_expr) return if func_name and namespace is None and func_name in self._func_names: - binding_names = ", ".join(node.names) call_expr = self._visit_func_call(node.value) - lines.append(f"{pad}auto [{binding_names}] = {call_expr};") + emit_call_tuple(call_expr) return # UDT instance method returning a tuple: ``[a, b, c] = receiver.method(...)``. @@ -890,9 +1086,8 @@ def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> if (fi_u is not None and getattr(fi_u, "is_udt_method", False) and getattr(fi_u, "returns_tuple", False)): - binding_names = ", ".join(node.names) call_expr = self._visit_func_call(node.value) - lines.append(f"{pad}auto [{binding_names}] = {call_expr};") + emit_call_tuple(call_expr) return lines.append(f"{pad}/* unsupported tuple assignment */") @@ -900,8 +1095,9 @@ def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> def _push_block_var_remap(self, owner): """Activate exact lexical metadata for one branch/loop body. - Persistent-var renames are merged over the inherited remap. Collection - registries use copy-on-write; declarations activate their bindings in + Persistent-var renames use copy-on-write and activate only after their + declarations. Collection registries likewise use copy-on-write and + declarations activate their bindings in source order, and block exit restores the outer state. Thus a sibling block can reuse the same raw name without either pre-shadowing earlier statements or controlling dispatch in the other branch. @@ -911,6 +1107,10 @@ def _push_block_var_remap(self, owner): ) previous_map_depth = getattr(self, "_block_map_visibility_depth", 0) self._block_map_visibility_depth = previous_map_depth + 1 + saved_drawing_types = self._lexical_drawing_types + self._lexical_drawing_types = dict(saved_drawing_types) + saved_series_bindings = self._lexical_series_bindings + self._lexical_series_bindings = dict(saved_series_bindings) renames = self._block_var_renames.get(id(owner)) collection_specs = self._block_collection_types.get(id(owner)) @@ -920,10 +1120,14 @@ def _push_block_var_remap(self, owner): None, previous_map_visible, previous_map_depth, + saved_drawing_types, + saved_series_bindings, ) saved_remap = self._active_var_remap if renames: - self._active_var_remap = {**saved_remap, **renames} + # Do not pre-shadow an outer binding at block entry. _visit_stmt + # installs each exact rename immediately after that VarDecl's RHS. + self._active_var_remap = dict(saved_remap) saved_collections = None if collection_specs is not None: @@ -950,6 +1154,8 @@ def _push_block_var_remap(self, owner): saved_collections, previous_map_visible, previous_map_depth, + saved_drawing_types, + saved_series_bindings, ) def _pop_block_var_remap(self, saved) -> None: @@ -958,6 +1164,8 @@ def _pop_block_var_remap(self, saved) -> None: saved_collections, previous_map_visible, previous_map_depth, + saved_drawing_types, + saved_series_bindings, ) = saved try: if saved_remap is not _NO_BLOCK_REMAP: @@ -972,6 +1180,8 @@ def _pop_block_var_remap(self, saved) -> None: self._matrix_specs, ) = saved_collections finally: + self._lexical_drawing_types = saved_drawing_types + self._lexical_series_bindings = saved_series_bindings self._block_map_binding_visible = previous_map_visible self._block_map_visibility_depth = previous_map_depth @@ -1070,8 +1280,24 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None: e_var = f"_for_end_{fid}" step_var = f"_for_step_{fid}" down_var = f"_for_down_{fid}" + end_mentions_binder = bool( + node.var + and any( + isinstance(part, Identifier) and part.name == node.var + for part in self._walk_ast(node.end) + ) + ) + end_eval = f"_for_end_eval_{fid}" if end_mentions_binder else None + if end_eval is not None: + # The ``to`` expression is authored outside the loop-binder scope, + # but its refresh executes inside the generated C++ ``for`` where + # the binder would shadow a same-named outer member/parameter. A + # pre-binder lambda preserves the authored lexical binding while + # still reevaluating the expression after every iteration. + lines.append(f"{pad}auto {end_eval} = [&]() {{ return ({end}); }};") lines.append(f"{pad}int {s_var} = ({start});") - lines.append(f"{pad}int {e_var} = ({end});") + end_expr = f"{end_eval}()" if end_eval is not None else f"({end})" + lines.append(f"{pad}int {e_var} = {end_expr};") lines.append(f"{pad}int {step_var} = ({step});") lines.append(f"{pad}if ({step_var} < 0) {step_var} = -{step_var};") lines.append(f"{pad}if ({step_var} == 0) {step_var} = 1;") @@ -1079,7 +1305,8 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None: lines.append( f"{pad}for (int {var} = {s_var}; " f"({down_var} ? ({var} >= {e_var}) : ({var} <= {e_var})); " - f"{var} += ({down_var} ? -{step_var} : {step_var}), {e_var} = ({end})) {{" + f"{var} += ({down_var} ? -{step_var} : {step_var}), " + f"{e_var} = {end_expr}) {{" ) # Register the loop counter so reads of it inside the body resolve (the # unknown-identifier guard in _visit_ident would otherwise flag it). @@ -1091,6 +1318,8 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None: self._current_loop_vars.add(node.var) self._current_loop_var_specs[node.var] = TypeSpec.primitive("int") _blk_saved = self._push_block_var_remap(node) + if node.var: + self._lexical_series_bindings[node.var] = False try: for s in node.body: self._visit_stmt(s, lines, indent + 1) @@ -1216,6 +1445,12 @@ def _visit_for_in(self, node, lines: list[str], indent: int) -> None: bindings = ", ".join(node.vars) lines.append(f"{pad}for (auto [{bindings}] : {iterable}) {{") _blk_saved = self._push_block_var_remap(node) + loop_binding_names = ( + [node.var] if node.var else list(node.vars or []) + ) + for name in loop_binding_names: + if name and name != "_": + self._lexical_series_bindings[name] = False try: for s in node.body: self._visit_stmt(s, lines, indent + 1) diff --git a/tests/test_codegen_drawing_data.py b/tests/test_codegen_drawing_data.py index 3991e69..25ffa2d 100644 --- a/tests/test_codegen_drawing_data.py +++ b/tests/test_codegen_drawing_data.py @@ -8,7 +8,10 @@ from __future__ import annotations +import pytest + from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError from tests._compile import compile_cpp, skip_if_no_compile_env @@ -141,6 +144,548 @@ def test_var_handle_na_default_no_ctor_init(): assert "x(na())" not in cpp +def test_drawing_handle_ternary_na_uses_exact_handle_types(): + """A drawing constructor selected against ``na`` is target-typed. + + Without both TypeSpec propagation and typed arm lowering, the declaration + becomes ``double`` and C++ sees incompatible ``Handle``/``double`` arms. + Cover every real drawing handle type while leaving visual-only types out. + """ + cpp = _cpp( + "cond = close > open\n" + "ln1 = cond ? line.new(bar_index, close, bar_index + 1, close) : na\n" + "ln2 = line.new(bar_index, open, bar_index + 1, open)\n" + "bx = cond ? box.new(bar_index, high, bar_index + 1, low) : na\n" + 'lb = cond ? label.new(bar_index, close, "x") : na\n' + "lf = cond ? linefill.new(ln1, ln2, color.red) : na\n" + "pt = cond ? chart.point.now(close) : na" + ) + for cpp_type, name in ( + ("Line", "ln1"), + ("Box", "bx"), + ("Label", "lb"), + ("Linefill", "lf"), + ("ChartPoint", "pt"), + ): + assert f"{cpp_type} {name} =" in cpp + assert f": ({cpp_type}{{}})" in cpp + assert f"double {name} =" not in cpp + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-ternary-all-handles") + + +def test_drawing_handle_ternary_na_reverse_arm_and_reassignment_compile(): + src = '''//@version=6 +strategy("drawing ternary na") +cond = close > open +reverse = cond ? na : label.new(bar_index, close, "reverse") +var label reassigned = na +reassigned := cond ? label.new(bar_index, high, "next") : na +if not na(reverse) and not na(reassigned) + strategy.entry("L", strategy.long) +''' + cpp = transpile(src) + assert "Label reverse =" in cpp + assert "(Label{}) : (pf_label_new" in cpp + assert "reassigned = ((cond) ? (pf_label_new" in cpp + assert ": (Label{}))" in cpp + assert "double reverse =" not in cpp + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-ternary-na") + + +def test_drawing_handle_ternary_na_respects_shadowed_scalar_local(): + cpp = _cpp( + "var line x = na\n" + "f() =>\n" + " float x = close > open ? close : na\n" + " x := close > open ? open : na\n" + " x\n" + "value = f()" + ) + function_body = cpp.split("double f() {", 1)[1].split(" }", 1)[0] + assert "double x = ((" in function_body + assert "x = ((" in function_body + assert "Line{}" not in function_body + assert function_body.count("na()") == 2 + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-ternary-shadowed-scalar") + + +def test_drawing_handle_ternary_na_function_return_and_global_var_compile(): + src = '''//@version=6 +strategy("drawing ternary return") +cond = close > open +makeLabel() => cond ? label.new(bar_index, close, "returned") : na +var label globalLabel = cond ? label.new(bar_index, close, "initial") : na +globalLabel := cond ? makeLabel() : na +if not na(globalLabel) + strategy.entry("L", strategy.long) +''' + cpp = transpile(src) + assert "Label makeLabel(" in cpp + assert "return ((cond) ? (pf_label_new" in cpp + assert ": (Label{}));" in cpp + assert "globalLabel = ((cond) ? (makeLabel()) : (Label{}));" in cpp + cond_pos = cpp.index("cond = ([&]{") + init_pos = cpp.index("if (!_pf_var_init_globalLabel) {") + init_end = cpp.index("_pf_var_init_globalLabel = true;", init_pos) + init_block = cpp[init_pos:init_end] + assert cond_pos < init_pos + assert "globalLabel = ((cond) ? (pf_label_new" in init_block + assert 'std::string("initial")' in init_block + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-ternary-return-global") + + +def test_drawing_handle_udf_arm_and_tuple_element_compile(): + src = '''//@version=6 +strategy("drawing ternary udf and tuple") +cond = close > open +makeLine() => line.new(bar_index, close, bar_index + 1, close) +makePair() => [cond ? makeLine() : na, makeLine()] +h = cond ? makeLine() : na +[a, b] = makePair() +if not na(h) and not na(a) and not na(b) + strategy.entry("L", strategy.long) +''' + cpp = transpile(src) + assert "Line makeLine(" in cpp + assert "std::tuple makePair(" in cpp + assert "std::make_tuple(((cond) ? (makeLine()) : (Line{})), makeLine())" in cpp + assert "Line h = Line{};" in cpp + assert "h = ((cond) ? (makeLine()) : (Line{}));" in cpp + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-ternary-udf-tuple") + + +def test_drawing_handle_ternary_na_drawing_parameter_reassignment_compile(): + src = '''//@version=6 +strategy("drawing ternary parameter") +cond = close > open +update(line h) => + h := cond ? line.new(bar_index, close, bar_index + 1, close) : na + h +var line globalLine = na +globalLine := update(globalLine) +if not na(globalLine) + strategy.entry("L", strategy.long) +''' + cpp = transpile(src) + body = cpp.split("Line update(Line& h) {", 1)[1].split(" }", 1)[0] + assert "h = ((cond) ? (pf_line_new" in body + assert ": (Line{}));" in body + assert "na()" not in body + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-ternary-parameter") + + +def test_drawing_handle_ternary_na_function_var_reassignment_compile(): + src = '''//@version=6 +strategy("drawing ternary function var") +cond = close > open +make() => + var line h = na + h := cond ? line.new(bar_index, close, bar_index + 1, close) : na + h +result = make() +if not na(result) + strategy.entry("L", strategy.long) +''' + cpp = transpile(src) + body = cpp.split("Line make_cs0() {", 1)[1].split(" }", 1)[0] + assert "h = ((cond) ? (pf_line_new" in body + assert ": (Line{}));" in body + assert "na()" not in body + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-ternary-function-var") + + +def test_function_drawing_var_initializes_at_declaration_after_prior_local(): + src = '''//@version=6 +strategy("function drawing declaration init") +make() => + bool cond = close > open + var line h = cond ? line.new(bar_index, close, bar_index + 1, close) : na + h +result = make() +''' + cpp = transpile(src) + body = cpp.split("Line make_cs0() {", 1)[1].split(" }", 1)[0] + cond_pos = body.index("bool cond =") + guard_pos = body.index("if (!this->_pf_var_init_h) {") + assert cond_pos < guard_pos + assert "h = ((cond) ? (pf_line_new" in body + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-function-declaration-init") + + +def test_conditional_function_drawing_var_keeps_lazy_first_entry_guard(): + src = '''//@version=6 +strategy("conditional function drawing init") +make(bool cond) => + if cond + var line h = line.new(bar_index, close, bar_index + 1, close) + 0.0 +value = make(close > open) +''' + cpp = transpile(src) + body = cpp.split("double make_cs0(bool cond) {", 1)[1].split( + " return 0.0;", 1 + )[0] + branch_pos = body.index("if (cond) {") + guard_pos = body.index("if (!this->_pf_var_init_h) {") + assert branch_pos < guard_pos + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-function-conditional-init") + + +@pytest.mark.parametrize( + "body", + ( + """f(float h, bool cond) => + before = h + if cond + var line h = line.new(bar_index, h, bar_index + 1, h) + line.delete(h) + before +a = f(close, close > open) +b = f(open, close < open)""", + """f(bool cond) => + float h = close + if cond + var line h = line.new(bar_index, h, bar_index + 1, h) + line.delete(h) + h +value = f(close > open)""", + """f(bool cond) => + line h = line.new(bar_index, close, bar_index + 1, close) + if cond + var line h = line.copy(h) + line.delete(h) + h +value = f(close > open)""", + """f(float h, bool cond) => + result = if cond + var line h = line.new(bar_index, h, bar_index + 1, h) + h + else + na + result +value = f(close, close > open)""", + ), +) +def test_callable_persistent_drawing_ancestor_shadow_fails_closed(body): + with pytest.raises(CompileError) as exc: + _cpp(body) + assert "Persistent drawing binding 'h' shadows an ancestor callable" in str( + exc.value + ) + + +def test_later_scalar_local_is_not_poisoned_by_nested_drawing_binding(): + src = '''//@version=6 +strategy("later local source order") +f(bool cond) => + if cond + var line h = line.new(bar_index, close, bar_index + 1, close) + line.delete(h) + float h = close + h +value = f(close > open) +''' + cpp = transpile(src) + assert "double f_cs0(bool cond)" in cpp + assert "double h = current_bar_.close;" in cpp + assert "return h;" in cpp + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-later-scalar-source-order") + + +def test_function_terminal_drawing_if_accepts_explicit_na_arm(): + src = '''//@version=6 +strategy("block if drawing na") +make(bool cond) => + if cond + label.new(bar_index, close, "x") + else + na +result = make(close > open) +''' + cpp = transpile(src) + assert "Label make(bool cond)" in cpp + assert "_func_ret = Label{};" in cpp + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-terminal-explicit-na") + + +def test_block_valued_drawing_if_propagates_through_wrappers_and_fresh_clone(): + src = '''//@version=6 +strategy("block drawing fresh return") +cond = close > open +leg(int size, bool arm) => + peak = ta.highest(size) + result = if arm + var line l = arm ? na : line.new(bar_index, peak, bar_index + 1, peak) + old = l[1] + l + else + na + result +f_get(int len, bool arm) => leg(len, arm) +g_get(int len, bool arm) => leg(len, arm) +a = f_get(10, cond) +b = f_get(20, not cond) +c = g_get(30, cond) +''' + cpp = transpile(src) + assert "Line leg_cs0(int size, bool arm)" in cpp + assert "Line leg_cs1(int size, bool arm)" in cpp + assert "Line leg__ni1(int size, bool arm)" in cpp + assert "Series l__ni1;" in cpp + assert "l__ni1.push(l__ni1[0])" in cpp + assert "l__ni1.update(l__ni1[0])" in cpp + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-block-return-fresh-clone") + + +def test_drawing_handle_ternary_na_cross_type_shadow_in_both_orders_compile(): + sources = ( + '''//@version=6 +strategy("drawing shadow global first") +cond = close > open +var line x = na +make() => + label x = cond ? label.new(bar_index, close, "local") : na + x +result = make() +''', + '''//@version=6 +strategy("drawing shadow function first") +cond = close > open +make() => + label x = cond ? label.new(bar_index, close, "local") : na + x +var line x = na +result = make() +''', + ) + for index, src in enumerate(sources): + cpp = transpile(src) + body = cpp.split("Label make() {", 1)[1].split(" }", 1)[0] + assert "Label x = ((cond) ? (pf_label_new" in body + assert ": (Label{}));" in body + assert "Line{}" not in body + skip_if_no_compile_env() + compile_cpp(cpp, label=f"drawing-ternary-cross-shadow-{index}") + + +def test_sibling_persistent_drawing_members_keep_exact_renamed_types(): + src = '''//@version=6 +strategy("sibling drawing vars") +cond = close > open +if cond + var label x = label.new(bar_index, high, "upper") +if not cond + var label x = label.new(bar_index, low, "lower") +''' + cpp = transpile(src) + assert "Label x;" in cpp + assert "Label x__blk1;" in cpp + assert "double x__blk1" not in cpp + assert "_pf_var_init_x__blk1" in cpp + assert "x__blk1 = pf_label_new" in cpp + skip_if_no_compile_env() + compile_cpp(cpp, label="drawing-sibling-persistent-members") + + +def test_only_history_read_sibling_becomes_exact_handle_series(): + src = '''//@version=6 +strategy("sibling exact drawing series") +cond = close > open +if cond + var line x = line.new(bar_index, high, bar_index + 1, high) +if not cond + var label x = label.new(bar_index, low, "lower") + prior = x[1] +''' + cpp = transpile(src) + assert "Line x;" in cpp + assert "Series