From 2a4642d1eef08d833f54c3237c5f3486731d52e5 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 18 Jul 2026 08:34:31 +0800 Subject: [PATCH] fix(codegen): isolate local collection scopes --- pineforge_codegen/analyzer/base.py | 159 ++++- pineforge_codegen/analyzer/contracts.py | 20 + pineforge_codegen/analyzer/types.py | 49 +- pineforge_codegen/codegen/base.py | 122 +++- pineforge_codegen/codegen/drawing.py | 8 + pineforge_codegen/codegen/emit_top.py | 109 ++- pineforge_codegen/codegen/security.py | 118 +++- pineforge_codegen/codegen/types.py | 163 ++++- pineforge_codegen/codegen/visit_call.py | 97 ++- pineforge_codegen/codegen/visit_expr.py | 7 +- pineforge_codegen/codegen/visit_stmt.py | 210 +++++- tests/test_collection_scope_isolation.py | 840 +++++++++++++++++++++++ 12 files changed, 1803 insertions(+), 99 deletions(-) create mode 100644 tests/test_collection_scope_isolation.py diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 870e844..8665637 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -214,6 +214,23 @@ def __init__(self, ast: Program, filename: str = "") -> None: # var_name -> UDT type for variables holding UDT instances self._udt_var_types: dict[str, str] = {} self._collection_types: dict[str, TypeSpec] = {} + # Collection metadata for callable locals must retain lexical identity. + # Keys match FuncInfo.name: ``func`` for ordinary UDFs and + # ``Type.method`` for UDT methods. ``_collection_types`` remains the + # top-level/on_bar registry consumed outside callable emission. + self._func_collection_types: dict[str, dict[str, TypeSpec]] = {} + # id(immediate branch/loop owner) -> raw local name -> TypeSpec. + # A callable may legally reuse one raw name for unrelated collections + # in sibling lexical blocks; keeping those bindings in the flat + # callable inventory would make the last analyzed branch win. + self._block_collection_types: dict[int, dict[str, TypeSpec | None]] = {} + self._block_collection_owners: dict[int, str] = {} + # id(callable-local VarDecl) -> its exact collection TypeSpec, or None + # for a scalar/UDT tombstone. Codegen activates these in source order + # after emitting each declaration RHS. + self._callable_collection_bindings: dict[int, TypeSpec | None] = {} + self._callable_collection_binding_owners: dict[int, str] = {} + self._collection_scope_stack: list[str] = [] self._udt_field_type_specs: dict[str, dict[str, TypeSpec]] = {} # Enum definitions: enum_name -> list of member names self._enum_defs: dict[str, list[str]] = {} @@ -362,6 +379,21 @@ def analyze(self) -> AnalyzerContext: func_return_type_specs=dict(self._func_return_type_specs), udt_var_types=dict(self._udt_var_types), collection_types=dict(self._collection_types), + func_collection_types={ + name: dict(specs) + for name, specs in self._func_collection_types.items() + }, + block_collection_types={ + owner_id: dict(specs) + for owner_id, specs in self._block_collection_types.items() + }, + block_collection_owners=dict(self._block_collection_owners), + callable_collection_bindings=dict( + self._callable_collection_bindings + ), + callable_collection_binding_owners=dict( + self._callable_collection_binding_owners + ), udt_field_type_specs=dict(self._udt_field_type_specs), block_var_renames=dict(self._block_var_renames), ) @@ -1183,12 +1215,68 @@ def _resolve_terminal(stmt): return _resolve_terminal(body[-1]) + def _record_collection_type( + self, + name: str, + spec: TypeSpec, + *, + symbol_scope: str | None = None, + ) -> None: + """Record collection metadata without erasing another callable's local. + + ``symbol_scope`` is supplied for reassignments so a UDF that mutates a + top-level collection keeps updating the top-level binding. A VarDecl + inside an active callable is necessarily lexical to that callable. + UDT TypeSpecs retain their established registry path; this overlay is + intentionally limited to array/map/matrix dispatch. + """ + callable_key = ( + self._collection_scope_stack[-1] + if self._collection_scope_stack + else None + ) + if (callable_key is not None + and symbol_scope != "global" + and self._block_node_stack): + owner_id = id(self._block_node_stack[-1]) + self._block_collection_owners[owner_id] = callable_key + self._block_collection_types.setdefault(owner_id, {})[name] = ( + spec if spec.kind in {"array", "map", "matrix"} else None + ) + if spec.kind in {"array", "map", "matrix"}: + return + if spec.kind not in {"array", "map", "matrix"}: + # Primitive locals have no collection metadata to export and must + # not overwrite a same-named top-level collection. Preserve the + # established UDT registry path until UDT identity gets its own + # lexical migration. + if (callable_key is not None + and symbol_scope != "global" + and spec.kind == "primitive"): + return + self._collection_types[name] = spec + return + if callable_key is not None and symbol_scope != "global": + self._func_collection_types.setdefault(callable_key, {})[name] = spec + return + self._collection_types[name] = spec + def _visit_VarDecl(self, node: VarDecl) -> PineType: # Infer type from the value expression val_type = self._visit(node.value) type_spec = self._type_spec_from_hint(node.type_hint) if node.type_hint else None if type_spec is None: type_spec = self._type_spec_from_expr(node.value) + if self._collection_scope_stack: + self._callable_collection_bindings[id(node)] = ( + type_spec + if type_spec is not None + and type_spec.kind in {"array", "map", "matrix"} + else None + ) + self._callable_collection_binding_owners[id(node)] = ( + self._collection_scope_stack[-1] + ) # Check for type hint override if node.type_hint: @@ -1244,10 +1332,21 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: if node.name in self._static_vars: setattr(sym, "is_static_series", True) self._symbols.define(sym) + if (type_spec is None + and self._collection_scope_stack + and self._block_node_stack + and self._symbols.current_scope.name != "global"): + owner_id = id(self._block_node_stack[-1]) + self._block_collection_owners[owner_id] = self._collection_scope_stack[-1] + self._block_collection_types.setdefault(owner_id, {})[node.name] = None if udt_ctor is not None: self._udt_var_types[node.name] = udt_ctor if type_spec is not None: - self._collection_types[node.name] = type_spec + self._record_collection_type( + node.name, + type_spec, + symbol_scope=self._symbols.current_scope.name, + ) if type_spec.kind == "udt" and type_spec.name: self._udt_var_types[node.name] = type_spec.name @@ -1333,7 +1432,12 @@ def _visit_Assignment(self, node: Assignment) -> PineType: if isinstance(node.target, Identifier): spec = self._type_spec_from_expr(node.value) if spec is not None: - self._collection_types[node.target.name] = spec + target_sym = self._symbols.resolve(node.target.name) + self._record_collection_type( + node.target.name, + spec, + symbol_scope=(target_sym.scope if target_sym is not None else None), + ) # Resolve the target if isinstance(node.target, Identifier): @@ -1403,6 +1507,13 @@ def _visit_TupleAssign(self, node: TupleAssign) -> PineType: setattr(sym, "is_static_series", True) self._symbols.define(sym) + if (self._collection_scope_stack + and self._block_node_stack + and self._symbols.current_scope.name != "global"): + owner_id = id(self._block_node_stack[-1]) + self._block_collection_owners[owner_id] = self._collection_scope_stack[-1] + self._block_collection_types.setdefault(owner_id, {})[name] = None + # Track global-scope tuple-assign targets (e.g. # ``[pdH, pdL] = request.security(...)``) as class members so user # functions / later references resolve — mirroring _visit_VarDecl. @@ -1464,12 +1575,14 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: self._global_scope = False self._enclosing_func_params.append(set(node.params)) self._enclosing_func_names.append(node.name) + self._collection_scope_stack.append(node.name) self._nested_ta_touched = set() try: for stmt in node.body: body_type = self._visit(stmt) finally: self._global_scope = old_global + self._collection_scope_stack.pop() self._enclosing_func_params.pop() self._enclosing_func_names.pop() nested_touched = self._nested_ta_touched @@ -1649,11 +1762,13 @@ def _visit_MethodDef(self, node) -> PineType: ret_type = PineType.VOID old_global = self._global_scope self._global_scope = False + self._collection_scope_stack.append(method_key) try: for stmt in node.body: ret_type = self._visit(stmt) finally: self._global_scope = old_global + self._collection_scope_stack.pop() self._symbols.exit_scope() # Detect tuple return on UDT methods (mirrors the regular FuncDef logic @@ -1739,6 +1854,7 @@ def _visit_ForStmt(self, node: ForStmt) -> PineType: const_value=None, scope="for", loc=loc, + type_spec=TypeSpec.primitive("int"), ) self._symbols.define(sym) @@ -1765,21 +1881,43 @@ def _visit_ForInStmt(self, node) -> PineType: self._block_node_stack.append(node) try: self._visit(node.iterable) + iterable_spec = self._type_spec_from_expr(node.iterable) + element_spec = ( + iterable_spec.element + if iterable_spec is not None and iterable_spec.kind == "array" + else None + ) + tuple_specs: list[TypeSpec | None] = [] + if iterable_spec is not None and iterable_spec.kind == "map": + tuple_specs = [iterable_spec.key, iterable_spec.value] self._symbols.enter_scope("for_in") if node.var: loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1) + pine_type = self._element_pine_type(element_spec) + if pine_type == PineType.VOID: + pine_type = PineType.FLOAT self._symbols.define(Symbol( - name=node.var, pine_type=PineType.FLOAT, is_series=False, + name=node.var, pine_type=pine_type, is_series=False, is_var=False, is_const=False, const_value=None, scope=self._symbols.current_scope.name, loc=loc, + type_spec=element_spec, )) if node.vars: loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1) - for v in node.vars: + for idx, v in enumerate(node.vars): + binder_spec = ( + tuple_specs[idx] + if idx < len(tuple_specs) + else None + ) + pine_type = self._element_pine_type(binder_spec) + if pine_type == PineType.VOID: + pine_type = PineType.FLOAT self._symbols.define(Symbol( - name=v, pine_type=PineType.FLOAT, is_series=False, + name=v, pine_type=pine_type, is_series=False, is_var=False, is_const=False, const_value=None, scope=self._symbols.current_scope.name, loc=loc, + type_spec=binder_spec, )) for stmt in node.body: self._visit(stmt) @@ -1988,7 +2126,16 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: # arithmetic inference) honest. See call_handlers.py # ``_handle_matrix_method``. if isinstance(obj, Identifier): - recv_spec = self._collection_types.get(obj.name) + recv_sym = self._symbols.resolve(obj.name) + recv_spec = ( + getattr(recv_sym, "type_spec", None) + if recv_sym is not None + else None + ) + # Only fall back to the top-level raw registry when lexical + # resolution did not find a shadowing local/parameter. + if recv_sym is None or recv_sym.scope == "global": + recv_spec = recv_spec or self._collection_types.get(obj.name) if recv_spec is not None and recv_spec.kind == "matrix": return self._handle_matrix_method(member, recv_spec) return PineType.VOID diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index f826e2a..a299256 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -237,6 +237,26 @@ class AnalyzerContext: udt_var_types: dict[str, str] = field(default_factory=dict) # var_name -> structured collection/UDT type metadata collection_types: dict[str, TypeSpec] = field(default_factory=dict) + # Stable callable key -> raw local name -> collection TypeSpec. Function + # and method locals are lexical: two callables may both declare ``values`` + # with unrelated collection kinds/element types, so they cannot share the + # top-level raw-name registry above. + func_collection_types: dict[str, dict[str, TypeSpec]] = field(default_factory=dict) + # id(immediate branch/loop owner) -> raw local name -> collection TypeSpec. + # Sibling lexical blocks may reuse one raw name with unrelated kinds, so + # their bindings cannot be collapsed into ``func_collection_types``. + block_collection_types: dict[int, dict[str, TypeSpec | None]] = field(default_factory=dict) + # Block id -> owning FuncInfo key. Used for dead-callable header filtering + # and exact persistent-member TypeSpec lookup outside callable emission. + block_collection_owners: dict[int, str] = field(default_factory=dict) + # id(callable-local VarDecl) -> exact collection TypeSpec, or None for a + # non-collection declaration that lexically tombstones an outer binding. + callable_collection_bindings: dict[int, TypeSpec | None] = field(default_factory=dict) + # Declaration node id -> owning callable key. Persistent-member emission + # happens outside the analyzer's lexical scope, so raw names alone cannot + # distinguish a real persistent collection from a same-named ordinary + # local in another callable. + callable_collection_binding_owners: dict[int, str] = field(default_factory=dict) # UDT name -> field_name -> structured type metadata udt_field_type_specs: dict[str, dict[str, TypeSpec]] = field(default_factory=dict) # id(block_node) -> {raw_var_name: scope_unique_member_name} for block-scoped diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index 3271fa8..9e627de 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -40,7 +40,7 @@ from typing import Any from ..ast_nodes import ( - ASTNode, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, + ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, NaLiteral, NumberLiteral, StringLiteral, TupleLiteral, UnaryOp, ) from ..symbols import PineType, TypeSpec @@ -134,6 +134,50 @@ def _template_args_from_call(self, node: FuncCall) -> list[str]: raw = ann.get("template_args") or [] return [str(x).replace(" ", "") for x in raw] + def _array_from_element_spec(self, value: ASTNode | None) -> TypeSpec | None: + """Exact scalar element type for one ``array.from`` argument. + + Keep this refinement local to array construction. General BinOp + TypeSpec inference changes unrelated scalar comparator lowering across + the corpus; array declarations only need enough structure to keep the + analyzer's captured LHS type aligned with codegen's RHS vector type. + """ + if value is None: + return None + if isinstance(value, NumberLiteral): + return TypeSpec.primitive( + "float" if isinstance(value.value, float) else "int" + ) + if isinstance(value, BoolLiteral): + return TypeSpec.primitive("bool") + if isinstance(value, StringLiteral): + return TypeSpec.primitive("string") + if isinstance(value, BinOp): + left = self._array_from_element_spec(value.left) + right = self._array_from_element_spec(value.right) + if value.op in ("==", "!=", ">", "<", ">=", "<=", "and", "or"): + return TypeSpec.primitive("bool") + if (left is not None and right is not None + and left.kind == "primitive" and right.kind == "primitive"): + if left.name == "string" or right.name == "string": + return TypeSpec.primitive("string") + if value.op == "/" or left.name == "float" or right.name == "float": + return TypeSpec.primitive("float") + if left.name == "int" and right.name == "int": + return TypeSpec.primitive("int") + return None + spec = self._type_spec_from_expr(value) + if spec is not None: + return spec + if isinstance(value, Identifier): + sym = self._symbols.resolve(value.name) + if sym is not None and sym.pine_type in { + PineType.INT, PineType.FLOAT, PineType.BOOL, + PineType.STRING, PineType.COLOR, + }: + return self._pine_type_to_spec(sym.pine_type) + return None + def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: if value is None: return None @@ -166,6 +210,9 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: elem = self._type_spec_from_hint(targs[0]) or TypeSpec.udt(targs[0]) return TypeSpec.array(elem) if func == "from" and value.args: + first_spec = self._array_from_element_spec(value.args[0]) + if first_spec is not None: + return TypeSpec.array(first_spec) first = self._visit(value.args[0]) return TypeSpec.array(self._pine_type_to_spec(first)) return TypeSpec.array(TypeSpec.primitive("float")) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 65cec40..0f46f48 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -355,7 +355,7 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._current_instance_name: str | None = None self._instance_dispatch: dict[tuple[str | None, int], str] = {} self._fresh_instances: list[dict] = [] - self._fresh_var_members: list[tuple[str, str]] = [] + self._fresh_var_members: list[tuple[str, str, str]] = [] # Fresh fixnan members for context-sensitive helper instances (nested # helpers reached through >1 distinct call path). Each fresh instance # gets its OWN previous-value member so two paths never share fixnan @@ -549,7 +549,33 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._global_member_vars.add(name) self._global_mutable_infos: dict[str, object] = getattr(ctx, "global_mutable_infos", {}) or {} self._udt_var_types: dict[str, str] = getattr(ctx, "udt_var_types", {}) or {} - self._collection_types: dict[str, TypeSpec] = getattr(ctx, "collection_types", {}) or {} + self._global_collection_types: dict[str, TypeSpec] = dict( + getattr(ctx, "collection_types", {}) or {} + ) + self._collection_types: dict[str, TypeSpec] = dict( + self._global_collection_types + ) + self._func_collection_types: dict[str, dict[str, TypeSpec]] = { + name: dict(specs) + for name, specs in ( + getattr(ctx, "func_collection_types", {}) or {} + ).items() + } + self._block_collection_types: dict[int, dict[str, TypeSpec | None]] = { + owner_id: dict(specs) + for owner_id, specs in ( + getattr(ctx, "block_collection_types", {}) or {} + ).items() + } + self._block_collection_owners: dict[int, str] = dict( + getattr(ctx, "block_collection_owners", {}) or {} + ) + self._callable_collection_bindings: dict[int, TypeSpec | None] = dict( + getattr(ctx, "callable_collection_bindings", {}) or {} + ) + self._callable_collection_binding_owners: dict[int, str] = dict( + getattr(ctx, "callable_collection_binding_owners", {}) or {} + ) # id(block_node) -> {raw_var_name: unique_member} for block-scoped var # name collisions (see Analyzer._visit_VarDecl). Activated into # ``_active_var_remap`` while emitting the owning block's statements. @@ -580,6 +606,20 @@ def __init__(self, ctx: AnalyzerContext) -> None: # Current function parameter types (set during _emit_func_def) self._current_func_param_types: dict[str, str] = {} self._current_func_param_specs: dict[str, "TypeSpec"] = {} + # Copy-on-write lexical collection inventory for the function/method + # currently being emitted. Activated by _emit_func_def and restored + # together with the legacy raw-name registries at function exit. + self._current_func_collection_specs: dict[str, "TypeSpec"] = {} + # Direct callable-body declarations that shadow a top-level collection. + # Nested declarations are activated only by their block COW overlay. + self._current_func_collection_shadows: set[str] = set() + # While emitting ``name = RHS`` for a declaration that shadows an + # already-active same-named collection, RHS identifiers are redirected + # through a pre-declaration alias. C++ brings the new name into scope + # inside its own initializer, so raw ``name`` would otherwise self-bind. + self._pending_decl_outer_alias: dict[str, str] = {} + self._collection_shadow_tmp_counter = 0 + self._collection_shadow_tmp_names: set[str] = set() # Current function params that are series (const Series&) self._current_func_series_params: set[str] = set() # Locals declared in the function currently being emitted (symbol table loses them after analysis) @@ -1006,7 +1046,7 @@ def natural_name(fname: str, cs_idx: int) -> str: for v in var_originals(g_name): fresh_member = f"{v}__ni{fresh_counter}" fvar_remap[v] = fresh_member - self._fresh_var_members.append((v, fresh_member)) + self._fresh_var_members.append((g_name, v, fresh_member)) # Fresh fixnan members: each path gets its OWN previous- # value member so two call paths never share fixnan state. ffixnan_remap: dict[str, str] = {} @@ -1035,8 +1075,55 @@ def natural_name(fname: str, cs_idx: int) -> str: worklist.append(ginst) self._instance_dispatch[(inst["name"], id(callnode))] = ginst["name"] + def _callable_var_collection_spec( + self, name: str, owner_func: str | None = None) -> TypeSpec | None: + """Exact TypeSpec for a function-scoped persistent collection member. + + Only declaration nodes that actually became callable-scoped persistent + members are eligible here. A raw-name scan of the general callable + inventory can otherwise turn a global scalar member into a collection + merely because an unrelated helper has a same-named ordinary local. + Clone contexts may own state reachable through another callable, so an + explicit owner is preferred and the owner-less fallback remains + deliberately limited to a single unambiguous persistent TypeSpec. + """ + safe = self._safe_name(name) + + candidates_by_owner: dict[str, list[TypeSpec]] = {} + metadata = getattr(self.ctx, "var_member_metadata_by_node", {}) or {} + for node_id, meta in metadata.items(): + _node, member_name, _ptype, _init_str, is_callable_scoped = meta + if (not is_callable_scoped + or self._safe_name(member_name) != safe): + continue + spec = self._callable_collection_bindings.get(node_id) + owner = self._callable_collection_binding_owners.get(node_id) + if (owner is None or spec is None + or spec.kind not in {"array", "map", "matrix"}): + continue + owner_candidates = candidates_by_owner.setdefault(owner, []) + if spec not in owner_candidates: + owner_candidates.append(spec) + + if owner_func is not None: + owned = candidates_by_owner.get(owner_func, []) + if len(owned) == 1: + return owned[0] + # The existing clone inventory deliberately unions persistent vars + # from reachable helpers into an enclosing function's remap. In + # that case ``owner_func`` names the clone context, not the lexical + # declaration owner; fall through to the unambiguous owner search. + + candidates: list[TypeSpec] = [] + for owned in candidates_by_owner.values(): + for spec in owned: + if spec not in candidates: + candidates.append(spec) + return candidates[0] if len(candidates) == 1 else None + def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str, - series_suffix: str, lines: list[str]) -> None: + series_suffix: str, lines: list[str], + owner_func: str | None = None) -> None: """Declare a per-clone copy of a function-scoped ``var`` member, matching the original's C++ type (series / matrix / array / map / drawing-handle / UDT / scalar). Shared by the per-call-site clone loop and the fresh @@ -1044,8 +1131,15 @@ def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str, for vname, ptype, _init_str in self.ctx.var_members: if self._safe_name(vname) == orig_safe: cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double") + collection_spec = self._callable_var_collection_spec( + vname, owner_func + ) if vname in self.ctx.series_vars: lines.append(f" Series<{cpp_type}> {cloned_safe}{series_suffix};") + elif collection_spec is not None: + lines.append( + f" {self._type_spec_to_cpp(collection_spec)} {cloned_safe};" + ) elif vname in self._matrix_specs: lines.append(f" {self._type_spec_to_cpp(self._matrix_specs[vname])} {cloned_safe};") elif vname in self._array_vars: @@ -2443,6 +2537,16 @@ def generate(self) -> str: continue seen_var_members.add(name) safe = self._safe_name(name) + callable_collection_spec = ( + None + if name in self._global_collection_types + else self._callable_var_collection_spec(name) + ) + if callable_collection_spec is not None: + lines.append( + f" {self._type_spec_to_cpp(callable_collection_spec)} {safe};" + ) + continue # Detect array vars from init expression. Guard the substring # heuristic against a UDT constructor that merely WRAPS array.new / # array.from in its arguments — e.g. @@ -2614,16 +2718,20 @@ def generate(self) -> str: if cloned_safe in emitted_clones: continue # already declared by another function's clone emitted_clones.add(cloned_safe) - self._emit_cloned_var_decl(orig_safe, cloned_safe, _mbb, lines) + self._emit_cloned_var_decl( + orig_safe, cloned_safe, _mbb, lines, owner_func=fname + ) # 8c2. Fresh var members for context-sensitive helper instances (nested # helpers reached through >1 distinct call path). Each fresh instance # gets its OWN scalar/series state so two paths never collide. - for orig_safe, fresh_safe in self._fresh_var_members: + for owner_func, orig_safe, fresh_safe in self._fresh_var_members: if fresh_safe in emitted_clones: continue emitted_clones.add(fresh_safe) - self._emit_cloned_var_decl(orig_safe, fresh_safe, _mbb, lines) + self._emit_cloned_var_decl( + orig_safe, fresh_safe, _mbb, lines, owner_func=owner_func + ) # 8c3. Fresh fixnan members for context-sensitive helper instances. # Each fresh instance gets its OWN previous-value member so two diff --git a/pineforge_codegen/codegen/drawing.py b/pineforge_codegen/codegen/drawing.py index cad80ff..2cd1e49 100644 --- a/pineforge_codegen/codegen/drawing.py +++ b/pineforge_codegen/codegen/drawing.py @@ -558,6 +558,14 @@ def _detect_drawing_usage(self) -> bool: for spec in self._collection_types.values(): if self._spec_mentions_drawing(spec): return True + for specs in self._func_collection_types.values(): + for spec in specs.values(): + if self._spec_mentions_drawing(spec): + return True + for specs in self._block_collection_types.values(): + for spec in specs.values(): + if self._spec_mentions_drawing(spec): + return True for fields in self._udt_field_type_specs.values(): for spec in fields.values(): if self._spec_mentions_drawing(spec): diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index da2f95f..7060af6 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -137,7 +137,26 @@ def _emit_includes(self, lines: list[str]) -> None: # matrix.hpp; pulling in the generic header otherwise is a wasted # include. float_spec = TypeSpec.primitive("float") - if any(spec.element != float_spec for spec in self._matrix_specs.values()): + scoped_matrix_specs = ( + spec + for specs in self._func_collection_types.values() + for spec in specs.values() + if spec.kind == "matrix" + ) + block_matrix_specs = ( + spec + for specs in self._block_collection_types.values() + for spec in specs.values() + if spec is not None and spec.kind == "matrix" + ) + if any( + spec.element != float_spec + for spec in ( + *self._matrix_specs.values(), + *scoped_matrix_specs, + *block_matrix_specs, + ) + ): lines.append('#include ') # Drawing-objects-as-data runtime (line/box/label/linefill arenas + # ChartPoint). Gated on _uses_drawing so non-drawing strategies stay @@ -978,12 +997,44 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No if node is None: return + # Collection registries historically used raw variable names for the + # whole translation unit. Emit each callable against copy-on-write + # registries overlaid with its analyzer-captured lexical TypeSpecs, then + # restore the top-level state. This keeps legacy readers working while + # preventing local declarations/method dispatch from contaminating a + # sibling UDF, UDT method, clone, or on_bar. + prev_func_collection_specs = self._current_func_collection_specs + prev_func_collection_shadows = self._current_func_collection_shadows + prev_collection_types = self._collection_types + prev_array_vars = self._array_vars + prev_map_vars = self._map_vars + prev_matrix_specs = self._matrix_specs + # Start from top-level state only. Callable locals become visible at + # their declaration statement (after its RHS), not at function entry: + # preloading the analyzer's final inventory would make a later local + # shadow an earlier read of a same-named global collection. + self._current_func_collection_specs = {} + self._current_func_collection_shadows = set() + self._collection_types = dict(prev_collection_types) + self._array_vars = set(prev_array_vars) + self._map_vars = set(prev_map_vars) + self._matrix_specs = dict(prev_matrix_specs) + is_udt = bool(getattr(fi, "is_udt_method", False)) and fi.udt_type_name # Determine param types and set context for type inference inside body param_strs = [] self._current_func_param_types = {} self._current_func_param_specs = {} + param_hints = (getattr(node, "annotations", None) or {}).get( + "param_type_hints", [] + ) + self._current_func_declared_param_names = { + alias + for i, param in enumerate(node.params) + if i < len(param_hints) and param_hints[i] + for alias in (param, self._safe_name(param)) + } self._current_func_series_params = set() self._udt_param_udt = {} func_sv = self.ctx.func_series_vars.get(fi.name, set()) @@ -1118,7 +1169,42 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # function is a function-local static — its initializer runs exactly # once, on the first call to THIS variant, with the first bar's values # the function actually sees. Each clone (cs0/cs1/…) is independent. - self._emit_func_var_init_block(fi, call_site_idx, lines, flag_override=var_init_flag) + # Generate that initializer against its own source-ordered lexical + # state. Persistent declarations must be visible to later persistent + # initializers (``var copy = seed.copy()``), but preloading them into + # the ordinary body would make future declarations shadow earlier + # reads. Copy-on-write here provides both properties. + init_collection_state = ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) + self._current_func_collection_specs = dict( + self._current_func_collection_specs + ) + self._current_func_collection_shadows = set( + self._current_func_collection_shadows + ) + self._collection_types = dict(self._collection_types) + self._array_vars = set(self._array_vars) + self._map_vars = set(self._map_vars) + self._matrix_specs = dict(self._matrix_specs) + try: + self._emit_func_var_init_block( + fi, call_site_idx, lines, flag_override=var_init_flag + ) + finally: + ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) = init_collection_state emitted_return = False if node.is_single_expr and node.body: @@ -1178,6 +1264,7 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No lines.append(" }") self._current_func_param_types = {} self._current_func_param_specs = {} + self._current_func_declared_param_names = set() self._current_func_series_params = set() self._udt_param_udt = {} self._current_func_locals = prev_func_locals @@ -1185,6 +1272,12 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._current_func_body = prev_func_body self._active_func_name = prev_func_name self._udt_ptr_alias_locals = prev_ptr_alias + self._current_func_collection_specs = prev_func_collection_specs + self._current_func_collection_shadows = prev_func_collection_shadows + self._collection_types = prev_collection_types + self._array_vars = prev_array_vars + self._map_vars = prev_map_vars + self._matrix_specs = prev_matrix_specs self._active_ta_remap = {} self._active_var_remap = {} self._active_fixnan_remap = {} @@ -1225,16 +1318,26 @@ class member but its initializer was dropped, leaving the member ``na`` init_ast = self.ctx.var_member_init_exprs.get(name) safe = self._safe_name(name) target = self._active_var_remap.get(safe, safe) + collection_spec = self._callable_var_collection_spec(name, fi.name) + + def activate_member() -> None: + self._activate_callable_collection_binding( + name, collection_spec + ) + if name in self.ctx.series_vars: 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});") + activate_member() continue if init_ast is None: # No initializer to lower (e.g. bare ``var box b``); leave the # member at its default-constructed value. + activate_member() continue # Skip a plain ``na`` initializer for drawing handles / UDTs whose # default-constructed member is already the na sentinel; assigning @@ -1244,6 +1347,7 @@ class member but its initializer was dropped, leaving the member ``na`` 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): + activate_member() continue init_cpp = self._visit_expr(init_ast) # A bare-``na`` initializer for an int/int64_t/bool ``var`` member @@ -1252,6 +1356,7 @@ class member but its initializer was dropped, leaving the member ``na`` # NaN stored into an int member is UB and defeats is_na(). init_cpp = self._typed_na_init(init_cpp, name, ptype) init_lines.append(f" {target} = {init_cpp};") + activate_member() if not init_lines: return lines.append(f" if (!{flag}) {{") diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 2205dee..9ef83bd 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -1067,6 +1067,49 @@ def _emit_security_linear_helper_call( security_mutable_names: set[str], lines: list[str], resolving: set[str] | None = None, + ) -> str: + """Inline one linear helper against isolated lexical collection state.""" + saved = ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) + self._current_func_collection_specs = {} + self._current_func_collection_shadows = set() + self._collection_types = dict(self._collection_types) + self._array_vars = set(self._array_vars) + self._map_vars = set(self._map_vars) + self._matrix_specs = dict(self._matrix_specs) + try: + return self._emit_security_linear_helper_call_scoped( + sec_id, + plan, + ta_results, + security_mutable_names, + lines, + resolving, + ) + finally: + ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) = saved + + def _emit_security_linear_helper_call_scoped( + self, + sec_id: int, + plan: dict, + ta_results: dict, + security_mutable_names: set[str], + lines: list[str], + resolving: set[str] | None = None, ) -> str: if plan["mode"] != "linear": self._codegen_error( @@ -1084,6 +1127,15 @@ def emit_stmt(stmt: ASTNode, active_bindings: dict[str, str], indent: int) -> No pad = " " * indent runtime_stack_local = plan["binding_stack"] + (active_bindings,) + def activate_decl() -> None: + spec = self._callable_collection_bindings.get(id(stmt)) + if spec is None: + inferred = self._type_spec_from_expr(stmt.value) + if (inferred is not None + and inferred.kind in {"array", "map", "matrix"}): + spec = inferred + self._activate_callable_collection_binding(stmt.name, spec) + if isinstance(stmt, VarDecl): is_persistent_var = stmt.is_var if stmt.name in local_series_names or is_persistent_var: @@ -1151,6 +1203,7 @@ def emit_stmt(stmt: ASTNode, active_bindings: dict[str, str], indent: int) -> No lines.append(f'{pad}}} else {{') lines.append(f'{pad} _security_helper_series_["{series_name}"].update({expr_cpp});') lines.append(f'{pad}}}') + activate_decl() return local_name = active_bindings.get(stmt.name) @@ -1185,6 +1238,7 @@ def emit_stmt(stmt: ASTNode, active_bindings: dict[str, str], indent: int) -> No lines, ) lines.append(f"{pad}{local_name} = {expr_cpp};") + activate_decl() return if isinstance(stmt, Assignment): @@ -1241,13 +1295,69 @@ def emit_stmt(stmt: ASTNode, active_bindings: dict[str, str], indent: int) -> No ) lines.append(f"{pad}if ({cond_cpp}) {{") body_bindings = dict(active_bindings) - for child in stmt.body: - emit_stmt(child, body_bindings, indent + 1) + body_state = ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) + self._current_func_collection_specs = dict( + self._current_func_collection_specs + ) + self._current_func_collection_shadows = set( + self._current_func_collection_shadows + ) + self._collection_types = dict(self._collection_types) + self._array_vars = set(self._array_vars) + self._map_vars = set(self._map_vars) + self._matrix_specs = dict(self._matrix_specs) + try: + for child in stmt.body: + emit_stmt(child, body_bindings, indent + 1) + finally: + ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) = body_state if stmt.else_body: lines.append(f"{pad}}} else {{") else_bindings = dict(active_bindings) - for child in stmt.else_body: - emit_stmt(child, else_bindings, indent + 1) + else_state = ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) + self._current_func_collection_specs = dict( + self._current_func_collection_specs + ) + self._current_func_collection_shadows = set( + self._current_func_collection_shadows + ) + self._collection_types = dict(self._collection_types) + self._array_vars = set(self._array_vars) + self._map_vars = set(self._map_vars) + self._matrix_specs = dict(self._matrix_specs) + try: + for child in stmt.else_body: + emit_stmt(child, else_bindings, indent + 1) + finally: + ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) = else_state lines.append(f"{pad}}}") return diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 35516ee..4210524 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -184,20 +184,134 @@ def _default_for_spec(self, spec: TypeSpec | None) -> str: return f"{cpp_type}()" return self._default_for_type(cpp_type) + def _collection_spec_for_name(self, name: str) -> TypeSpec | None: + """Resolve collection metadata with lexical precedence. + + Source-ordered callable locals shadow loop bindings and parameters once + their declaration has executed; before that point, loop/parameter + bindings shadow top-level/on_bar state. The process-wide raw-name maps + are retained for top-level compatibility, but no callable may infer a + collection kind from a same-named sibling callable. + """ + local_specs = getattr(self, "_current_func_collection_specs", {}) + if name in local_specs: + return local_specs[name] + # A scalar/UDT local still shadows a same-named top-level collection. + # Returning None is semantically different from "not found locally": it + # prevents legacy global kind registries from resurrecting the hidden + # binding during member dispatch or alias analysis. + if name in getattr(self, "_current_func_collection_shadows", set()): + return None + loop_specs = getattr(self, "_current_loop_var_specs", None) + if loop_specs and name in loop_specs: + return loop_specs[name] + if name in getattr(self, "_current_loop_vars", set()): + return None + param_specs = getattr(self, "_current_func_param_specs", {}) + if name in param_specs: + param_spec = param_specs[name] + # Keep the established unresolved/untyped-parameter compatibility + # route: scalar TypeSpecs inferred only from a call site did not + # historically mask a same-named top-level collection registry. + # Declared scalar/UDT parameters do shadow it, while inferred or + # declared collection parameters always carry their exact kind. + if (param_spec.kind in {"array", "map", "matrix"} + or name in getattr( + self, "_current_func_declared_param_names", set() + )): + return param_spec + # During callable emission this is the copy-on-write lexical overlay; + # outside a callable it is the live top-level registry, including + # aliases discovered by codegen after construction. + return self._collection_types.get(name) + + def _collection_name_is_lexically_shadowed(self, name: str) -> bool: + """Whether ``name`` is bound in the active callable/block scope. + + A ``None`` collection lookup alone cannot distinguish a real scalar or + UDT tombstone from an absent name. Callers that otherwise fall back to + the analyzer's popped symbol table use this predicate to avoid + resurrecting a hidden top-level collection. Untyped parameters retain + their established compatibility path and are intentionally not tested + here. + """ + if name in getattr(self, "_current_loop_vars", set()): + return True + if name in getattr(self, "_current_func_collection_specs", {}): + return True + return name in getattr(self, "_current_func_collection_shadows", set()) + + def _activate_callable_collection_binding( + self, name: str, spec: TypeSpec | None + ) -> None: + """Install one callable-local binding after its declaration RHS. + + Every raw kind marker is removed first. A collection installs its exact + TypeSpec; a scalar/UDT leaves a tombstone in the lexical shadow set. + Function and block entry already established copy-on-write state, so + this mutation is restored at the appropriate lexical boundary. + """ + self._array_vars.discard(name) + self._map_vars.discard(name) + self._matrix_specs.pop(name, None) + self._current_func_collection_specs.pop(name, None) + self._collection_types.pop(name, None) + self._current_func_collection_shadows.add(name) + if spec is None or spec.kind not in {"array", "map", "matrix"}: + return + self._current_func_collection_specs[name] = spec + self._collection_types[name] = spec + if spec.kind == "array": + self._array_vars.add(name) + elif spec.kind == "map": + self._map_vars.add(name) + else: + self._matrix_specs[name] = spec + + def _collection_receiver_expr(self, name: str) -> str: + """C++ receiver for an active collection identifier.""" + return getattr(self, "_pending_decl_outer_alias", {}).get( + name, self._safe_name(name) + ) + def _array_spec_for_name(self, name: str) -> TypeSpec: """Spec for ``array<...>`` variable ``name`` (falls back to array).""" - spec = self._collection_types.get(name) + spec = self._collection_spec_for_name(name) if spec is not None and spec.kind == "array": return spec return TypeSpec.array(TypeSpec.primitive("float")) def _map_spec_for_name(self, name: str) -> TypeSpec: """Spec for ``map<...>`` variable ``name`` (falls back to map).""" - spec = self._collection_types.get(name) + spec = self._collection_spec_for_name(name) if spec is not None and spec.kind == "map": return spec return TypeSpec.map(TypeSpec.primitive("string"), TypeSpec.primitive("float")) + def _array_from_element_spec(self, node) -> TypeSpec | None: + """Exact scalar element spec used only by ``array.from`` lowering. + + This intentionally does not make BinOp TypeSpecs globally visible: + doing so changes unrelated float-band comparator output. Collection + construction needs the narrower fact so its vector type agrees with + the analyzer-captured declaration type, including lexical loop binders. + """ + if isinstance(node, BinOp): + left = self._array_from_element_spec(node.left) + right = self._array_from_element_spec(node.right) + if node.op in ("==", "!=", ">", "<", ">=", "<=", "and", "or"): + return TypeSpec.primitive("bool") + if (left is not None and right is not None + and left.kind == "primitive" and right.kind == "primitive"): + if left.name == "string" or right.name == "string": + return TypeSpec.primitive("string") + if node.op == "/" or left.name == "float" or right.name == "float": + return TypeSpec.primitive("float") + if left.name == "int" and right.name == "int": + return TypeSpec.primitive("int") + return None + return self._type_spec_from_expr(node) + def _type_spec_from_expr(self, node) -> TypeSpec | None: """Best-effort TypeSpec inference for an expression node. @@ -210,14 +324,9 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: if isinstance(node, StringLiteral): return TypeSpec.primitive("string") if isinstance(node, Identifier): - loop_specs = getattr(self, "_current_loop_var_specs", None) - if loop_specs and node.name in loop_specs: - return loop_specs[node.name] - param_specs = getattr(self, "_current_func_param_specs", {}) - if node.name in param_specs: - return param_specs[node.name] - if node.name in self._collection_types: - return self._collection_types[node.name] + collection_spec = self._collection_spec_for_name(node.name) + if collection_spec is not None: + return collection_spec 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`` @@ -226,6 +335,8 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: _pu = getattr(self, "_udt_param_udt", None) if _pu and node.name in _pu and _pu[node.name] in DRAWING_TYPE_TO_CPP: return TypeSpec.udt(_pu[node.name]) + if self._collection_name_is_lexically_shadowed(node.name): + return None sym = self.ctx.symbols.resolve(node.name) if sym is not None and getattr(sym, "type_spec", None) is not None: return sym.type_spec @@ -284,7 +395,10 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: if targs: return TypeSpec.array(self._type_spec_from_hint_name(targs[0]) or TypeSpec.udt(targs[0])) if func_name == "from" and node.args: - return TypeSpec.array(self._type_spec_from_expr(node.args[0]) or TypeSpec.primitive("float")) + return TypeSpec.array( + self._array_from_element_spec(node.args[0]) + or TypeSpec.primitive("float") + ) return TypeSpec.array(TypeSpec.primitive("float")) # Functional-form array element/copy accessors: the receiver is # the first argument (``array.copy(arr)``), mirroring the @@ -523,6 +637,12 @@ def _type_for_decl(self, node: VarDecl) -> str: _dret = self._drawing_call_return_cpp(node.value) if _dret is not None: return _dret + # Analyzer scopes are popped before codegen, so ctx.symbols.resolve may + # find a same-named global instead of this active callable's plain + # local. The local binding is known from the emitted body inventory; + # infer it from its own RHS rather than borrowing the global's PineType. + if node.name in getattr(self, "_current_func_locals", set()): + return self._infer_type(node.value) sym = self.ctx.symbols.resolve(node.name) if sym is not None: inferred = self._infer_type(node.value) @@ -616,9 +736,9 @@ def _na_reassign_cpp_type(self, name: str) -> str | None: """ # Collections / UDT / drawing handles never take a scalar ``na()``: # leave them to the drawing-na / default lowering in _visit_rhs_value. - if (name in self._array_vars - or name in self._map_vars - or name in getattr(self, "_matrix_specs", {}) + collection_spec = self._collection_spec_for_name(name) + if ((collection_spec is not None + and collection_spec.kind in {"array", "map", "matrix"}) or name in self._udt_var_types): return None cpp_type: str | None = None @@ -835,15 +955,9 @@ def _collection_lvalue_spec(self, expr): if not isinstance(expr, Identifier): return None name = expr.name - if name in self._matrix_specs: - return self._matrix_specs[name] - spec = self._collection_types.get(name) + spec = self._collection_spec_for_name(name) if spec is not None and spec.kind in ("array", "map", "matrix"): return spec - if name in self._array_vars: - return self._array_spec_for_name(name) - if name in self._map_vars: - return self._map_spec_for_name(name) return None def _collection_lvalue_selection_spec(self, expr): @@ -999,6 +1113,8 @@ def _infer_type(self, node) -> str: return self._current_func_param_types[node.name] if node.name in getattr(self, "_current_func_local_types", {}): return self._current_func_local_types[node.name] + if node.name in getattr(self, "_current_loop_vars", set()): + return "double" sym = self.ctx.symbols.resolve(node.name) if sym is not None and getattr(sym, "type_spec", None) is not None: return self._type_spec_to_cpp(sym.type_spec) @@ -1167,6 +1283,11 @@ def _infer_tuple_types(self, func_node: FuncDef, count: int) -> list[str]: local_types: dict[str, str] = {} for stmt in self._walk_ast(func_node): if isinstance(stmt, VarDecl) and stmt.value is not None and stmt.name: + captured = self._callable_collection_bindings.get(id(stmt)) + if (captured is not None + and captured.kind in {"array", "map", "matrix"}): + local_types[stmt.name] = self._type_spec_to_cpp(captured) + continue if stmt.type_hint: spec = self._type_spec_from_hint_name(stmt.type_hint) if spec is not None: diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 65969f7..ed62f12 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -447,12 +447,21 @@ def _visit_func_call(self, node: FuncCall) -> str: param_spec = getattr( self, "_current_func_param_specs", {} ).get(oname) + active_local_shadow = ( + oname in getattr( + self, "_current_func_collection_specs", {} + ) + or oname in getattr( + self, "_current_func_collection_shadows", set() + ) + ) if ( param_spec is not None and param_spec.kind == "array" + and not active_local_shadow and meth_raw in ARRAY_METHODS ): - arr = self._safe_name(oname) + arr = self._collection_receiver_expr(oname) arg_nodes = self._array_method_arg_nodes(meth_raw, node) margs = self._array_method_args( meth_raw, arg_nodes, param_spec @@ -463,30 +472,47 @@ def _visit_func_call(self, node: FuncCall) -> str: if ( param_spec is not None and param_spec.kind == "map" + and not active_local_shadow and meth_raw in MAP_METHODS ): - m = self._safe_name(oname) + m = self._collection_receiver_expr(oname) arg_nodes = self._map_param_method_arg_nodes( meth_raw, node ) return self._map_param_method_expr( m, meth_raw, arg_nodes, param_spec ) - # map.put / map.get / … must lower to unordered_map ops, not `.put` on C++ - if oname in self._map_vars and meth_raw in MAP_METHODS: - m = self._safe_name(oname) + recv_spec = self._collection_spec_for_name(oname) + # Resolve the lexical TypeSpec once and route by its exact + # kind. Raw membership sets can contain a same-named + # binding from another scope; overlapping methods such as + # ``get`` must never use those as a tie-breaker. + if ( + recv_spec is not None + and recv_spec.kind == "map" + and meth_raw in MAP_METHODS + ): + m = self._collection_receiver_expr(oname) margs = [self._visit_expr(a) for a in node.args] - return self._map_method_expr(m, meth_raw, margs, self._map_spec_for_name(oname)) - if oname in self._array_vars and meth_raw in ARRAY_METHODS: - arr = self._safe_name(oname) + return self._map_method_expr(m, meth_raw, margs, recv_spec) + if ( + recv_spec is not None + and recv_spec.kind == "array" + and meth_raw in ARRAY_METHODS + ): + arr = self._collection_receiver_expr(oname) arg_nodes = self._array_method_arg_nodes(meth_raw, node) margs = self._array_method_args( - meth_raw, arg_nodes, self._array_spec_for_name(oname) + meth_raw, arg_nodes, recv_spec ) - return self._array_method_expr(arr, meth_raw, margs, self._array_spec_for_name(oname)) - if oname in self._matrix_specs and meth_raw in MATRIX_METHODS: - arr = self._safe_name(oname) - self._check_matrix_method_allowed(meth_raw, self._matrix_specs[oname], node) + return self._array_method_expr(arr, meth_raw, margs, recv_spec) + if ( + recv_spec is not None + and recv_spec.kind == "matrix" + and meth_raw in MATRIX_METHODS + ): + arr = self._collection_receiver_expr(oname) + self._check_matrix_method_allowed(meth_raw, recv_spec, node) param_names = MATRIX_METHOD_KWARGS.get(meth_raw) if param_names and node.kwargs: margs = _merge_kwargs( @@ -611,10 +637,19 @@ def _visit_func_call(self, node: FuncCall) -> str: return self._visit_str_call(func_name, node) # Map method syntax: m.put(key, val) where namespace is the map variable name - if namespace is not None and namespace in self._map_vars and func_name in MAP_METHODS: - m = self._safe_name(namespace) + namespace_spec = ( + self._collection_spec_for_name(namespace) + if namespace is not None + else None + ) + if ( + namespace_spec is not None + and namespace_spec.kind == "map" + and func_name in MAP_METHODS + ): + m = self._collection_receiver_expr(namespace) args = [self._visit_expr(a) for a in node.args] - return self._map_method_expr(m, func_name, args, self._map_spec_for_name(namespace)) + return self._map_method_expr(m, func_name, args, namespace_spec) # map.method(m, args...) — functional form if namespace == "map": @@ -628,9 +663,13 @@ def _visit_func_call(self, node: FuncCall) -> str: return self._map_method_expr(m, func_name, rest, spec) return "0" - if namespace is not None and namespace in self._matrix_specs and func_name in MATRIX_METHODS: - arr = self._safe_name(namespace) - self._check_matrix_method_allowed(func_name, self._matrix_specs[namespace], node) + if ( + namespace_spec is not None + and namespace_spec.kind == "matrix" + and func_name in MATRIX_METHODS + ): + arr = self._collection_receiver_expr(namespace) + self._check_matrix_method_allowed(func_name, namespace_spec, node) param_names = MATRIX_METHOD_KWARGS.get(func_name) if param_names and node.kwargs: args = _merge_kwargs(node.args, node.kwargs, param_names, self._visit_expr) @@ -647,9 +686,13 @@ def _visit_func_call(self, node: FuncCall) -> str: ) # Array method syntax: arr.push(val) where namespace is the array variable name - if namespace is not None and namespace in self._array_vars and func_name in ARRAY_METHODS: - arr = self._safe_name(namespace) - spec = self._array_spec_for_name(namespace) + if ( + namespace_spec is not None + and namespace_spec.kind == "array" + and func_name in ARRAY_METHODS + ): + arr = self._collection_receiver_expr(namespace) + spec = namespace_spec arg_nodes = self._array_method_arg_nodes(func_name, node) args = self._array_method_args(func_name, arg_nodes, spec) return self._array_method_expr(arr, func_name, args, spec) @@ -1135,14 +1178,16 @@ def _visit_func_call(self, node: FuncCall) -> str: if not isinstance(node.args[0], _Ident): self._codegen_error(node, f"matrix.{func_name} receiver must be a variable reference") recv_name = node.args[0].name - if recv_name not in self._matrix_specs: + recv_spec = self._collection_spec_for_name(recv_name) + if recv_spec is None or recv_spec.kind != "matrix": self._codegen_error(node, f"matrix.{func_name}: receiver '{recv_name}' is not a known matrix variable") - self._check_matrix_method_allowed(func_name, self._matrix_specs[recv_name], node) + self._check_matrix_method_allowed(func_name, recv_spec, node) if func_name == "sort": if isinstance(node.args[0], _Ident): recv_name = node.args[0].name - if recv_name in self._matrix_specs: - self._check_matrix_method_allowed(func_name, self._matrix_specs[recv_name], node) + recv_spec = self._collection_spec_for_name(recv_name) + if recv_spec is not None and recv_spec.kind == "matrix": + self._check_matrix_method_allowed(func_name, recv_spec, node) obj = self._visit_expr(node.args[0]) param_names = MATRIX_METHOD_KWARGS.get(func_name) if param_names: diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index abeb082..1181e45 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -269,6 +269,9 @@ def _visit_ident(self, node: Identifier) -> str: # Bare 'na' identifier → na() if name == "na": return "na()" + pending_outer = getattr(self, "_pending_decl_outer_alias", {}) + if name in pending_outer: + return pending_outer[name] # Function parameters that are series — read current value if name in self._current_func_series_params: return f"{self._safe_name(name)}[0]" @@ -934,9 +937,9 @@ def _visit_subscript(self, node: Subscript) -> str: safe = self._active_var_remap[safe] # Same Pine [k] semantics as Series in runtime/series.hpp return f"{safe}[{idx}]" - spec = self._collection_types.get(name) + spec = self._collection_spec_for_name(name) if spec is not None and spec.kind in ("array", "map"): - return f"{self._safe_name(name)}[{idx}]" + return f"{self._collection_receiver_expr(name)}[{idx}]" # Handle strategy.* history access (e.g., strategy.position_size[1]) if isinstance(node.object, MemberAccess): if isinstance(node.object.object, Identifier): diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 5ecea37..f4ab67c 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -106,8 +106,8 @@ MATRIX_RETURNING_METHODS, ) -# Sentinel for "no block-scoped var remap was activated" so an empty dict -# saved-remap is still distinguishable from the no-op case. +# Sentinel for "no block-scoped overlay was activated" so an empty saved map +# is still distinguishable from the no-op case. _NO_BLOCK_REMAP = object() @@ -137,11 +137,77 @@ def _visit_stmt(self, node: ASTNode, lines: list[str], indent: int) -> None: if isinstance(node, MethodDef): return # handled as class method via FuncInfo if isinstance(node, VarDecl): - self._visit_var_decl(node, lines, pad) + is_callable_binding = ( + getattr(self, "_active_func_name", None) is not None + and id(node) in self._callable_collection_bindings + ) + activation_spec = ( + self._callable_collection_bindings.get(id(node)) + if is_callable_binding + else None + ) + if is_callable_binding and activation_spec is None: + inferred_spec = self._type_spec_from_expr(node.value) + if (inferred_spec is not None + and inferred_spec.kind in {"array", "map", "matrix"}): + activation_spec = inferred_spec + previous_pending_alias = self._pending_decl_outer_alias + if (is_callable_binding + and not node.is_var + and not node.is_varip + and self._collection_spec_for_name(node.name) is not None + and any( + isinstance(part, Identifier) and part.name == node.name + for part in self._walk_ast(node.value) + )): + safe_name = self._safe_name(node.name) + if (node.name in self._global_collection_types + and node.name not in self._current_func_param_specs + and node.name not in self._current_func_collection_specs + and node.name not in self._current_loop_var_specs): + outer_expr = f"this->{safe_name}" + else: + outer_expr = self._active_var_remap.get(safe_name, safe_name) + occupied_alias_names = ( + set(self._all_bound_names) + | set(self._collection_shadow_tmp_names) + | set(self._current_func_param_types) + | { + self._safe_name(param) + for param in self._current_func_param_types + } + ) + while True: + alias = ( + f"_pf_outer_{safe_name}_" + f"{self._collection_shadow_tmp_counter}" + ) + self._collection_shadow_tmp_counter += 1 + if alias not in occupied_alias_names: + break + self._collection_shadow_tmp_names.add(alias) + lines.append(f"{pad}auto& {alias} = {outer_expr};") + self._pending_decl_outer_alias = { + **previous_pending_alias, + node.name: alias, + } + try: + self._visit_var_decl(node, lines, pad) + finally: + self._pending_decl_outer_alias = previous_pending_alias + if is_callable_binding: + self._activate_callable_collection_binding( + node.name, + activation_spec, + ) elif isinstance(node, Assignment): self._visit_assignment(node, lines, pad) elif isinstance(node, TupleAssign): self._visit_tuple_assign(node, lines, pad) + if getattr(self, "_active_func_name", None) is not None: + for name in node.names: + if name and name != "_": + self._activate_callable_collection_binding(name, None) elif isinstance(node, IfStmt): self._visit_if(node, lines, indent) elif isinstance(node, ForStmt): @@ -234,7 +300,8 @@ def _concat_receiver_name(self, expr) -> str | None: # m.concat(other, ...) — receiver is callee.object if isinstance(callee.object, Identifier): recv = callee.object.name - if recv in getattr(self, "_matrix_specs", {}): + recv_spec = self._collection_spec_for_name(recv) + if recv_spec is not None and recv_spec.kind == "matrix": return recv # matrix.concat(m, other, ...) — receiver is first arg if (isinstance(callee.object, Identifier) @@ -242,7 +309,8 @@ def _concat_receiver_name(self, expr) -> str | None: and expr.args and isinstance(expr.args[0], Identifier)): recv = expr.args[0].name - if recv in getattr(self, "_matrix_specs", {}): + recv_spec = self._collection_spec_for_name(recv) + if recv_spec is not None and recv_spec.kind == "matrix": return recv return None @@ -323,10 +391,16 @@ def remember_local_type(cpp_type: str | None) -> None: if isinstance(node.value, FuncCall): func_name, namespace = self._resolve_callee(node.value.callee) if namespace == "array" and func_name in ARRAY_NEW_CTORS | {"new", "from", "copy", "slice"}: + captured = self._callable_collection_bindings.get(id(node)) + spec = ( + captured + if captured is not None and captured.kind == "array" + else self._type_spec_from_expr(node.value) + or self._array_spec_for_name(node.name) + ) + init = self._visit_expr(node.value) self._array_vars.add(node.name) - spec = self._type_spec_from_expr(node.value) or self._array_spec_for_name(node.name) self._collection_types.setdefault(node.name, spec) - init = self._visit_expr(node.value) cpp_type = self._type_spec_to_cpp(spec) if is_global_member: lines.append(f"{pad}{safe} = {init};") @@ -340,9 +414,13 @@ def remember_local_type(cpp_type: str | None) -> None: if namespace == "matrix" and func_name == "new": targs = self._template_args_from_call(node.value) if hasattr(node.value, "annotations") else [] elem_spec = self._type_spec_from_hint_name(targs[0]) if targs else TypeSpec.primitive("float") - spec = TypeSpec.matrix(elem_spec) - self._matrix_specs[node.name] = spec - self._collection_types[node.name] = spec + captured = self._callable_collection_bindings.get(id(node)) + spec = ( + captured + if captured is not None and captured.kind == "matrix" + else TypeSpec.matrix(elem_spec) + ) + elem_spec = spec.element or elem_spec cpp_type = self._type_spec_to_cpp(spec) if len(node.value.args) >= 2: r = self._visit_expr(node.value.args[0]) @@ -351,6 +429,8 @@ def remember_local_type(cpp_type: str | None) -> None: init = f"{cpp_type}::new_({r}, {c}, {v})" else: init = f"{cpp_type}::new_(0, 0, {self._default_for_spec(elem_spec)})" + self._matrix_specs[node.name] = spec + self._collection_types[node.name] = spec if is_global_member: lines.append(f"{pad}{safe} = {init};") else: @@ -363,12 +443,20 @@ def remember_local_type(cpp_type: str | None) -> None: # rejects ``double = PineMatrix``. The RHS expression itself is # already lowered to the right ``m.inv()`` form by visit_call. if namespace == "matrix" and func_name in MATRIX_RETURNING_METHODS: - recv_spec = self._matrix_specs.get(self._extract_receiver_name(node.value)) - if recv_spec is None: + recv_name = self._extract_receiver_name(node.value) + recv_spec = ( + self._collection_spec_for_name(recv_name) + if recv_name is not None + else None + ) + if recv_spec is None or recv_spec.kind != "matrix": recv_spec = TypeSpec.matrix(TypeSpec.primitive("float")) + captured = self._callable_collection_bindings.get(id(node)) + if captured is not None and captured.kind == "matrix": + recv_spec = captured + init = self._visit_expr(node.value) self._matrix_specs[node.name] = recv_spec self._collection_types[node.name] = recv_spec - init = self._visit_expr(node.value) cpp_type = self._type_spec_to_cpp(recv_spec) if is_global_member: lines.append(f"{pad}{safe} = {init};") @@ -376,8 +464,14 @@ def remember_local_type(cpp_type: str | None) -> None: lines.append(f"{pad}{cpp_type} {safe} = {init};") return if namespace == "map" and func_name == "new": + captured = self._callable_collection_bindings.get(id(node)) + spec = ( + captured + if captured is not None and captured.kind == "map" + else self._type_spec_from_expr(node.value) + or self._map_spec_for_name(node.name) + ) self._map_vars.add(node.name) - spec = self._type_spec_from_expr(node.value) or self._map_spec_for_name(node.name) self._collection_types.setdefault(node.name, spec) cpp_type = self._type_spec_to_cpp(spec) if is_global_member: @@ -466,6 +560,8 @@ def remember_local_type(cpp_type: str | None) -> None: if not is_global_member: coll_spec = self._collection_lvalue_selection_spec(node.value) if coll_spec is not None and self._collection_local_must_alias(node): + cpp_type = self._type_spec_to_cpp(coll_spec) + cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type) # Register the local's collection kind so subsequent # ``.size()/.get()/.unshift()`` dispatch resolves correctly. self._collection_types[node.name] = coll_spec @@ -475,8 +571,6 @@ def remember_local_type(cpp_type: str | None) -> None: self._map_vars.add(node.name) elif coll_spec.kind == "matrix": self._matrix_specs[node.name] = coll_spec - cpp_type = self._type_spec_to_cpp(coll_spec) - cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type) lines.append(f"{pad}{cpp_type}& {safe} = {cpp_val};") return @@ -592,7 +686,11 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non op_char = node.op[0] # e.g., "+" from "+=" lines.append(f"{pad}{safe}.update({safe}[0] {op_char} {val_cpp});") elif target_name in self._var_names: - if node.op == ":=" and target_name in self._matrix_specs and isinstance(node.value, FuncCall): + target_spec = self._collection_spec_for_name(target_name) + if (node.op == ":=" + and target_spec is not None + and target_spec.kind == "matrix" + and isinstance(node.value, FuncCall)): rhs_fn, rhs_ns = self._resolve_callee(node.value.callee) rhs_spec = None if rhs_ns == "matrix" and rhs_fn == "new": @@ -601,9 +699,13 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non rhs_spec = TypeSpec.matrix(elem) elif rhs_ns == "matrix" and rhs_fn in MATRIX_RETURNING_METHODS: rcv = self._extract_receiver_name(node.value) - rhs_spec = self._matrix_specs.get(rcv) + rhs_spec = ( + self._collection_spec_for_name(rcv) + if rcv is not None + else None + ) if rhs_spec is not None: - lhs_spec = self._matrix_specs[target_name] + lhs_spec = target_spec if rhs_spec.element != lhs_spec.element: self._codegen_error( node, @@ -718,20 +820,58 @@ def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> lines.append(f"{pad}/* unsupported tuple assignment */") def _push_block_var_remap(self, owner): - """Activate block-scoped var renames for ``owner`` (BUG 1). Returns the - previous ``_active_var_remap`` to restore (or ``_NO_BLOCK_REMAP`` if this - block owns no renames). Renames are MERGED over the inherited remap so - nested blocks keep any enclosing func-clone / outer-block mapping.""" + """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 + 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. + """ renames = self._block_var_renames.get(id(owner)) - if not renames: + collection_specs = self._block_collection_types.get(id(owner)) + if not renames and collection_specs is None: return _NO_BLOCK_REMAP - saved = self._active_var_remap - self._active_var_remap = {**saved, **renames} - return saved + saved_remap = self._active_var_remap + if renames: + self._active_var_remap = {**saved_remap, **renames} + + saved_collections = None + if collection_specs is not None: + saved_collections = ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) + self._current_func_collection_specs = dict( + self._current_func_collection_specs + ) + self._current_func_collection_shadows = set( + self._current_func_collection_shadows + ) + self._collection_types = dict(self._collection_types) + self._array_vars = set(self._array_vars) + self._map_vars = set(self._map_vars) + self._matrix_specs = dict(self._matrix_specs) + return saved_remap, saved_collections def _pop_block_var_remap(self, saved) -> None: - if saved is not _NO_BLOCK_REMAP: - self._active_var_remap = saved + if saved is _NO_BLOCK_REMAP: + return + saved_remap, saved_collections = saved + self._active_var_remap = saved_remap + if saved_collections is not None: + ( + self._current_func_collection_specs, + self._current_func_collection_shadows, + self._collection_types, + self._array_vars, + self._map_vars, + self._matrix_specs, + ) = saved_collections def _visit_block_statements(self, body: list, lines: list[str], indent: int) -> None: @@ -830,9 +970,12 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None: # Register the loop counter so reads of it inside the body resolve (the # unknown-identifier guard in _visit_ident would otherwise flag it). saved_loop = self._current_loop_vars + saved_loop_specs = self._current_loop_var_specs self._current_loop_vars = set(self._current_loop_vars) + self._current_loop_var_specs = dict(self._current_loop_var_specs) if node.var: 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) try: for s in node.body: @@ -840,6 +983,7 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None: finally: self._pop_block_var_remap(_blk_saved) self._current_loop_vars = saved_loop + self._current_loop_var_specs = saved_loop_specs lines.append(f"{pad}}}") def _loop_elem_is_writeback_udt(self, iterable) -> bool: @@ -884,9 +1028,15 @@ def _visit_for_in(self, node, lines: list[str], indent: int) -> None: if elem_spec is not None: self._current_loop_var_specs[node.var] = elem_spec if node.vars: - for v in node.vars: + tuple_specs: list[TypeSpec | None] = [] + if iterable_spec is not None and iterable_spec.kind == "map": + tuple_specs = [iterable_spec.key, iterable_spec.value] + for idx, v in enumerate(node.vars): if v != "_": self._current_loop_vars.add(v) + if (idx < len(tuple_specs) + and tuple_specs[idx] is not None): + self._current_loop_var_specs[v] = tuple_specs[idx] if node.var: v_cpp = self._safe_name(node.var) ref = "&" if self._loop_elem_is_writeback_udt(node.iterable) else "" diff --git a/tests/test_collection_scope_isolation.py b/tests/test_collection_scope_isolation.py new file mode 100644 index 0000000..64d21f6 --- /dev/null +++ b/tests/test_collection_scope_isolation.py @@ -0,0 +1,840 @@ +"""Lexical isolation for callable-local array/map/matrix TypeSpecs.""" + +from __future__ import annotations + +from hashlib import sha256 + +import pytest + +from pineforge_codegen import transpile +from tests._compile import compile_cpp + + +def _ordered_source( + title: str, + left: str, + right: str, + calls: str, + reverse: bool, +) -> str: + definitions = (right, left) if reverse else (left, right) + return ( + f'//@version=6\nstrategy("{title}")\n' + + "\n".join(definitions) + + "\n" + + calls + ) + + +def _body(cpp: str, signature: str) -> str: + start = cpp.index(signature) + end = cpp.index("\n }", start) + len("\n }") + return cpp[start:end] + + +def _pair_source(family: str, reverse: bool) -> str: + if family == "map-elements": + return _ordered_source( + "scope map elements", + '''map_text() => + slot = map.new() + slot.put("k", "text") + slot.remove("k") +''', + '''map_float() => + slot = map.new() + slot.put("k", 2.5) + slot.remove("k") +''', + "text_result = map_text()\nfloat_result = map_float()\n", + reverse, + ) + if family == "array-elements": + return _ordered_source( + "scope array elements", + '''array_text() => + slot = array.from("text") + copied = slot.copy() + copied.size() +''', + '''array_int() => + slot = array.from(7) + copied = slot.copy() + copied.size() +''', + "text_result = array_text()\nint_result = array_int()\n", + reverse, + ) + if family == "map-array": + return _ordered_source( + "scope map array", + '''kind_map() => + slot = map.new() + slot.put("k", "v") + slot.remove("k") +''', + '''kind_array() => + slot = array.from(7) + copied = slot.copy() + copied.size() +''', + "map_result = kind_map()\narray_result = kind_array()\n", + reverse, + ) + if family == "map-matrix": + return _ordered_source( + "scope map matrix", + '''kind_map() => + slot = map.new() + slot.put("k", 2.5) + slot.get("k") +''', + '''kind_matrix() => + slot = matrix.new(1, 1, 7) + slot.get(0, 0) +''', + "map_result = kind_map()\nmatrix_result = kind_matrix()\n", + reverse, + ) + if family == "matrix-elements": + return _ordered_source( + "scope matrix elements", + '''matrix_int() => + slot = matrix.new(1, 1, 7) + slot.get(0, 0) +''', + '''matrix_bool() => + slot = matrix.new(1, 1, true) + slot.get(0, 0) +''', + "int_result = matrix_int()\nbool_result = matrix_bool()\n", + reverse, + ) + raise AssertionError(f"unknown family: {family}") + + +_FAMILIES = ( + "map-elements", + "array-elements", + "map-array", + "map-matrix", + "matrix-elements", +) + + +@pytest.mark.parametrize("reverse", [False, True], ids=["left-first", "right-first"]) +@pytest.mark.parametrize("family", _FAMILIES) +def test_callable_local_collection_specs_are_lexically_isolated( + family: str, reverse: bool +) -> None: + cpp = transpile(_pair_source(family, reverse)) + + if family == "map-elements": + text = _body(cpp, "std::string map_text(") + numeric = _body(cpp, "double map_float(") + assert "std::unordered_map slot" in text + assert 'return std::string("");' in text + assert "std::unordered_map slot" in numeric + assert "return 0.0;" in numeric + elif family == "array-elements": + text = _body(cpp, "double array_text(") + numeric = _body(cpp, "double array_int(") + assert "std::vector copied = std::vector(slot);" in text + assert "std::vector copied = std::vector(slot);" in numeric + elif family == "map-array": + mapped = _body(cpp, "std::string kind_map(") + arrayed = _body(cpp, "double kind_array(") + assert '.find(std::string("k"))' in mapped + assert "std::vector copied = std::vector(slot);" in arrayed + assert ".count(" not in arrayed + elif family == "map-matrix": + mapped = _body(cpp, "double kind_map(") + matrixed = _body(cpp, "int kind_matrix(") + assert 'slot.count(std::string("k"))' in mapped + assert "PineGenericMatrix slot" in matrixed + assert "slot.get((int)(0), (int)(0))" in matrixed + assert "slot.count(" not in matrixed + else: + ints = _body(cpp, "int matrix_int(") + bools = _body(cpp, "bool matrix_bool(") + assert "PineGenericMatrix slot" in ints + assert "PineGenericMatrix slot" in bools + assert "#include " in cpp + + +@pytest.mark.parametrize("reverse", [False, True], ids=["left-first", "right-first"]) +@pytest.mark.parametrize("family", _FAMILIES) +def test_callable_local_collection_collision_pairs_compile( + family: str, reverse: bool +) -> None: + compile_cpp( + transpile(_pair_source(family, reverse)), + label=f"collection_scope_{family}_{int(reverse)}", + ) + + +def _global_local_source(reverse: bool) -> str: + left = '''global_read() => + slot.put("k", "global") + slot.remove("k") +''' + right = '''local_copy() => + slot = array.from(7) + copied = slot.copy() + copied.size() +''' + definitions = (right, left) if reverse else (left, right) + return ( + '//@version=6\nstrategy("scope global local")\n' + "var slot = map.new()\n" + + "\n".join(definitions) + + "\nglobal_result = global_read()\nlocal_result = local_copy()\n" + ) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_callable_local_collection_shadows_same_named_global(reverse: bool) -> None: + cpp = transpile(_global_local_source(reverse)) + assert "std::unordered_map slot;" in cpp + global_body = _body(cpp, "std::string global_read(") + local_body = _body(cpp, "double local_copy(") + assert '.find(std::string("k"))' in global_body + assert "std::vector slot" in local_body + assert "std::vector copied = std::vector(slot);" in local_body + assert ".count(" not in local_body + compile_cpp(cpp, label=f"collection_scope_global_local_{int(reverse)}") + + +def _udf_method_source(reverse: bool) -> str: + udf = '''plain_matrix() => + slot = matrix.new(1, 1, 7) + slot.get(0, 0) +''' + method = '''method read_float(Holder self) => + slot = map.new() + slot.put("k", 2.5) + slot.get("k") +''' + definitions = (method, udf) if reverse else (udf, method) + return ( + '//@version=6\nstrategy("scope udf method")\n' + "type Holder\n int seed\n" + + "\n".join(definitions) + + "\nvar Holder h = Holder.new(1)\n" + "plain_result = plain_matrix()\nmethod_result = h.read_float()\n" + ) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_udf_and_udt_method_collection_locals_are_isolated(reverse: bool) -> None: + cpp = transpile(_udf_method_source(reverse)) + matrixed = _body(cpp, "int plain_matrix(") + mapped = _body(cpp, "double _udt_Holder_read_float(") + assert "PineGenericMatrix slot" in matrixed + assert "slot.get((int)(0), (int)(0))" in matrixed + assert "slot.count(" not in matrixed + assert "std::unordered_map slot" in mapped + assert 'slot.count(std::string("k"))' in mapped + compile_cpp(cpp, label=f"collection_scope_udf_method_{int(reverse)}") + + +_PERSISTENT_SOURCE = '''//@version=6 +strategy("scope persistent collections") +array_state() => + var ints = array.from(1) + ints.size() +map_state() => + var weights = map.new() + weights.put("k", 1) + weights.size() +matrix_state() => + var grid = matrix.new(1, 1, 7) + grid.get(0, 0) +array_a = array_state() +array_b = array_state() +map_a = map_state() +map_b = map_state() +matrix_a = matrix_state() +matrix_b = matrix_state() +''' + + +def test_function_var_collection_members_and_clones_keep_exact_types() -> None: + cpp = transpile(_PERSISTENT_SOURCE) + for name in ("ints", "ints_cs1"): + assert f"std::vector {name};" in cpp + for name in ("weights", "weights_cs1"): + assert f"std::unordered_map {name};" in cpp + for name in ("grid", "grid_cs1"): + assert f"PineGenericMatrix {name};" in cpp + compile_cpp(cpp, label="collection_scope_persistent_clones") + + +_SCALAR_SHADOW_SOURCE = '''//@version=6 +strategy("scope scalar shadow") +var slot = map.new() +local_scalar() => + slot = "local" + slot +global_read() => + slot.put("k", "global") + slot.remove("k") +local_result = local_scalar() +global_result = global_read() +''' + + +def test_scalar_local_does_not_erase_same_named_global_collection() -> None: + cpp = transpile(_SCALAR_SHADOW_SOURCE) + assert "std::unordered_map slot;" in cpp + assert "std::string local_scalar(" in cpp + assert "std::string slot = std::string(\"local\")" in _body( + cpp, "std::string local_scalar(" + ) + assert '.find(std::string("k"))' in _body(cpp, "std::string global_read(") + compile_cpp(cpp, label="collection_scope_scalar_shadow") + + +_TOP_LEVEL_ALIAS_SOURCE = '''//@version=6 +strategy("scope top-level alias") +var seed = array.from(1) +alias = seed +alias.push(2) +observed = alias.size() +''' + + +def test_top_level_collection_alias_keeps_live_registry_inference() -> None: + cpp = transpile(_TOP_LEVEL_ALIAS_SOURCE) + assert "std::vector seed;" in cpp + assert "std::vector alias;" in cpp + assert "alias = seed;" in cpp + assert "alias.push_back(2);" in cpp + compile_cpp(cpp, label="collection_scope_top_level_alias") + + +def _sibling_branch_source(reverse: bool, matrix: bool = False) -> str: + if matrix: + left = ''' slot = matrix.new(1, 1, 7) + slot.set(0, 0, 8) +''' + else: + left = ''' slot = array.from(1) + slot.push(2) +''' + right = ''' slot = map.new() + slot.put("k", 1) +''' + first, second = (right, left) if reverse else (left, right) + return f'''//@version=6 +strategy("scope sibling branches") +mixed_branch(cond) => + if cond +{first} else +{second} 0 +result = mixed_branch(close > open) +''' + + +@pytest.mark.parametrize("reverse", [False, True], ids=["left-first", "right-first"]) +@pytest.mark.parametrize("matrix", [False, True], ids=["array-map", "matrix-map"]) +def test_sibling_blocks_keep_exact_collection_bindings( + reverse: bool, matrix: bool +) -> None: + cpp = transpile(_sibling_branch_source(reverse, matrix)) + body = _body(cpp, "int mixed_branch(") + assert "std::unordered_map slot" in body + assert '(slot[std::string("k")] = 1)' in body + if matrix: + assert "PineGenericMatrix slot" in body + assert "slot.set((int)(0), (int)(0), 8)" in body + assert "#include " in cpp + else: + assert "std::vector slot" in body + assert "slot.push_back(2)" in body + compile_cpp( + cpp, + label=f"collection_scope_sibling_{int(matrix)}_{int(reverse)}", + ) + + +_TEMPORAL_SOURCE = '''//@version=6 +strategy("scope temporal activation") +var slot = map.new() +temporal_array() => + slot.put("before", "global") + slot.remove("before") + slot = array.from(1) + slot.push(2) + slot.size() +temporal_matrix() => + slot.put("k", "v") + slot = matrix.new(slot.contains("k"), 1, 0) + slot.get(0, 0) +array_result = temporal_array() +matrix_result = temporal_matrix() +''' + + +def test_callable_binding_activates_after_declaration_rhs() -> None: + cpp = transpile(_TEMPORAL_SOURCE) + arrayed = _body(cpp, "double temporal_array(") + matrixed = _body(cpp, "int temporal_matrix(") + assert '(slot[std::string("before")] = std::string("global"))' in arrayed + assert '.find(std::string("before"))' in arrayed + assert "std::vector slot" in arrayed + assert "slot.push_back(2)" in arrayed + assert '(slot[std::string("k")] = std::string("v"))' in matrixed + assert "auto& _pf_outer_slot_0 = this->slot" in matrixed + assert '_pf_outer_slot_0.count(std::string("k"))' in matrixed + assert "PineGenericMatrix slot" in matrixed + compile_cpp(cpp, label="collection_scope_temporal_activation") + + +_GLOBAL_REASSIGN_SOURCE = '''//@version=6 +strategy("scope global reassignment") +var slot = map.new() +method_reset() => + slot := map.new() + slot.put("method", "ok") + slot.remove("method") +functional_reset() => + slot := map.new() + map.put(slot, "functional", "ok") + map.remove(slot, "functional") +method_result = method_reset() +functional_result = functional_reset() +''' + + +def test_global_collection_reassignment_is_not_a_local_shadow() -> None: + cpp = transpile(_GLOBAL_REASSIGN_SOURCE) + method = _body(cpp, "std::string method_reset(") + functional = _body(cpp, "std::string functional_reset(") + assert "slot = std::unordered_map()" in method + assert '(slot[std::string("method")] = std::string("ok"))' in method + assert '.find(std::string("method"))' in method + assert '(slot[std::string("functional")] = std::string("ok"))' in functional + assert '.find(std::string("functional"))' in functional + compile_cpp(cpp, label="collection_scope_global_reassignment") + + +_NESTED_SHADOW_SOURCE = '''//@version=6 +strategy("scope nested restore") +var shared = map.new() +restore_local(cond) => + slot = array.from(1) + if cond + slot = map.new() + slot.put("k", 1) + else + slot = "scalar" + copied = slot + slot.push(2) + slot.size() +restore_global(cond) => + if cond + shared = "scalar" + copied = shared + else + marker = 0 + shared.put("after", "global") + shared.remove("after") +local_result = restore_local(close > open) +global_result = restore_global(close > open) +''' + + +def test_nested_scalar_and_collection_shadows_restore_outer_binding() -> None: + cpp = transpile(_NESTED_SHADOW_SOURCE) + local = _body(cpp, "double restore_local(") + globally = _body(cpp, "std::string restore_global(") + assert "std::unordered_map slot" in local + assert 'std::string slot = std::string("scalar")' in local + assert "std::string copied = slot" in local + assert "slot.push_back(2)" in local + assert 'std::string shared = std::string("scalar")' in globally + assert "std::string copied = shared" in globally + assert '(shared[std::string("after")] = std::string("global"))' in globally + assert '.find(std::string("after"))' in globally + compile_cpp(cpp, label="collection_scope_nested_restore") + + +_BLOCK_PERSISTENT_SOURCE = '''//@version=6 +strategy("scope block persistent") +array_state(cond) => + if cond + var ints = array.from(1) + ints.push(2) + 0 +map_state(cond) => + if cond + var weights = map.new() + weights.put("k", 1) + 0 +matrix_state(cond) => + if cond + var grid = matrix.new(1, 1, 7) + grid.set(0, 0, 8) + 0 +array_a = array_state(true) +array_b = array_state(false) +map_a = map_state(true) +map_b = map_state(false) +matrix_a = matrix_state(true) +matrix_b = matrix_state(false) +''' + + +def test_block_scoped_persistent_collections_keep_exact_clone_types() -> None: + cpp = transpile(_BLOCK_PERSISTENT_SOURCE) + for name in ("ints", "ints_cs1"): + assert f"std::vector {name};" in cpp + for name in ("weights", "weights_cs1"): + assert f"std::unordered_map {name};" in cpp + for name in ("grid", "grid_cs1"): + assert f"PineGenericMatrix {name};" in cpp + assert "#include " in cpp + compile_cpp(cpp, label="collection_scope_block_persistent") + + +_PERSISTENT_ALIAS_SOURCE = '''//@version=6 +strategy("scope persistent alias initializer") +copy_state() => + var seed = array.from(1) + var copied = seed.copy() + copied.size() +first = copy_state() +second = copy_state() +''' + + +def test_persistent_collection_initializer_sees_prior_binding() -> None: + cpp = transpile(_PERSISTENT_ALIAS_SOURCE) + for name in ("seed", "copied", "seed_cs1", "copied_cs1"): + assert f"std::vector {name};" in cpp + assert "copied = std::vector(seed);" in cpp + compile_cpp(cpp, label="collection_scope_persistent_alias_initializer") + + +_CALLABLE_DRAWING_COLLECTION_SOURCE = '''//@version=6 +strategy("scope callable drawing collection") +direct_lines() => + xs = array.new() + xs.size() +block_lines(cond) => + if cond + xs = array.new() + xs.size() + 0 +direct_result = direct_lines() +block_result = block_lines(true) +''' + + +def test_callable_drawing_collection_specs_enable_runtime_header() -> None: + cpp = transpile(_CALLABLE_DRAWING_COLLECTION_SOURCE) + assert "#include " in cpp + assert "std::vector xs" in _body(cpp, "double direct_lines(") + assert "std::vector xs" in _body(cpp, "int block_lines(") + compile_cpp(cpp, label="collection_scope_callable_drawing_header") + + +_PARAM_ALIAS_COLLISION_SOURCE = '''//@version=6 +strategy("scope param alias collision") +var slot = map.new() +param_alias(_pf_outer_slot_0) => + slot = matrix.new(slot.contains("k"), 1, _pf_outer_slot_0) + slot.get(0, 0) +result = param_alias(7) +''' + + +def test_temporal_outer_alias_avoids_callable_parameter_name() -> None: + cpp = transpile(_PARAM_ALIAS_COLLISION_SOURCE) + body = _body(cpp, "int param_alias(") + assert "int _pf_outer_slot_0" in body + assert "auto& _pf_outer_slot_1 = this->slot" in body + assert '_pf_outer_slot_1.count(std::string("k"))' in body + compile_cpp(cpp, label="collection_scope_param_alias_collision") + + +_NESTED_EXACT_ARRAY_SOURCE = '''//@version=6 +strategy("scope nested exact array") +nested_exact(cond) => + slot = array.from(1) + if cond + slot = array.from(slot.get(0) + 1) + slot.push(2) + slot.size() +result = nested_exact(true) +''' + + +def test_nested_same_name_array_keeps_analyzer_element_spec() -> None: + cpp = transpile(_NESTED_EXACT_ARRAY_SOURCE) + body = _body(cpp, "double nested_exact(") + assert "auto& _pf_outer_slot_0 = slot" in body + assert "std::vector slot = std::vector" in body + assert "std::vector slot" not in body + assert "slot.push_back(2)" in body + compile_cpp(cpp, label="collection_scope_nested_exact_array") + + +_PARAM_BLOCK_SHADOW_SOURCE = '''//@version=6 +strategy("scope parameter block shadow") +param_block(array slot, bool cond) => + if cond + slot = map.new() + slot.put("k", 1) + slot.get("k") + 0 +var param_arg = array.from(1) +result = param_block(param_arg, true) +''' + + +def test_block_local_collection_shadows_collection_parameter() -> None: + cpp = transpile(_PARAM_BLOCK_SHADOW_SOURCE) + body = _body(cpp, "int param_block(") + assert "std::unordered_map slot" in body + assert '(slot[std::string("k")] = 1)' in body + assert 'slot.count(std::string("k"))' in body + compile_cpp(cpp, label="collection_scope_parameter_block_shadow") + + +_SECURITY_LINEAR_COLLECTION_SOURCE = '''//@version=6 +strategy("scope security linear collection") +helper() => + xs = array.from(7) + xs.get(0) +result = request.security(syminfo.tickerid, "D", helper()) +''' + + +def test_security_linear_helper_activates_local_collection_binding() -> None: + cpp = transpile(_SECURITY_LINEAR_COLLECTION_SOURCE) + assert "std::vector _sec0_helper_" in cpp + assert "((_sec0_helper_" in cpp + compile_cpp(cpp, label="collection_scope_security_linear_helper") + + +_TUPLE_COLLECTION_SOURCE = '''//@version=6 +strategy("scope tuple collection") +tuple_collection() => + slot = array.from(1) + copied = slot.copy() + [copied, 1] +[arr, n] = tuple_collection() +''' + + +def test_tuple_return_prepass_uses_callable_collection_specs() -> None: + cpp = transpile(_TUPLE_COLLECTION_SOURCE) + assert "std::tuple, int> tuple_collection(" in cpp + assert "std::tuple tuple_collection(" not in cpp + compile_cpp(cpp, label="collection_scope_tuple_return") + + +_METHOD_PERSISTENT_COLLECTION_SOURCE = '''//@version=6 +strategy("scope method persistent collection") +type Holder + int seed +method use(Holder self) => + var slot = array.from("x") + slot.push("y") + slot.size() +var Holder h = Holder.new(1) +result = h.use() +''' + + +def test_method_persistent_collection_member_uses_scoped_spec() -> None: + cpp = transpile(_METHOD_PERSISTENT_COLLECTION_SOURCE) + assert "std::vector slot;" in cpp + assert 'slot.push_back(std::string("y"))' in cpp + compile_cpp(cpp, label="collection_scope_method_persistent") + + +_LOOP_PERSISTENT_COLLECTION_SOURCE = '''//@version=6 +strategy("scope loop persistent collection") +loop_persistent() => + for i = 0 to 0 + var slot = array.from("x") + slot.push("y") + slot.size() + 0 +result = loop_persistent() +''' + + +def test_loop_persistent_collection_member_uses_block_owner_spec() -> None: + cpp = transpile(_LOOP_PERSISTENT_COLLECTION_SOURCE) + assert "std::vector slot;" in cpp + assert 'slot.push_back(std::string("y"))' in cpp + compile_cpp(cpp, label="collection_scope_loop_persistent") + + +_GLOBAL_SCALAR_LOCAL_COLLECTION_SOURCE = '''//@version=6 +strategy("scope scalar persistent vs local") +var slot = close +helper() => + slot = array.from("x") + slot.size() +result = helper() +''' + + +def test_nonpersistent_local_collection_cannot_retype_global_scalar_member() -> None: + cpp = transpile(_GLOBAL_SCALAR_LOCAL_COLLECTION_SOURCE) + assert "double slot = na();" in cpp + assert "std::vector slot;" not in cpp + assert 'std::vector slot = std::vector' in cpp + assert "slot = current_bar_.close;" in cpp + compile_cpp(cpp, label="collection_scope_global_scalar_local_collection") + + +_LOOP_SHADOW_SOURCE = '''//@version=6 +strategy("scope loop shadow") +var slot = map.new() +loop_shadow() => + slot.put("before", "global") + for slot = 0 to 1 + local_copy = slot + slot.put("after", "global") + slot.remove("after") +result = loop_shadow() +''' + + +def test_loop_binder_shadows_collection_only_inside_loop() -> None: + cpp = transpile(_LOOP_SHADOW_SOURCE) + body = _body(cpp, "std::string loop_shadow(") + assert "for (int slot = " in body + # Keep general scalar lowering byte-compatible; exact loop TypeSpecs are + # consumed only by collection inference/dispatch in this patch. + assert "double local_copy = slot" in body + assert '(slot[std::string("before")] = std::string("global"))' in body + assert '(slot[std::string("after")] = std::string("global"))' in body + assert '.find(std::string("after"))' in body + compile_cpp(cpp, label="collection_scope_loop_shadow") + + +_LOOP_BINDER_EXACT_SOURCES = { + "range-int": '''//@version=6 +strategy("scope range binder exact") +var slot = array.from(4) +loop_binding() => + for i = 0 to 0 + slot = array.from(slot.get(0) + i) + slot.size() + 0 +result = loop_binding() +''', + "for-in-int-direct": '''//@version=6 +strategy("scope for-in int direct") +var values = array.from(1) +loop_binding() => + for value in values + slot = array.from(value) + slot.size() + 0 +result = loop_binding() +''', + "for-in-int-outer": '''//@version=6 +strategy("scope for-in int outer") +var values = array.from(1) +var slot = array.from(4) +loop_binding() => + for value in values + slot = array.from(slot.get(0) + value) + slot.size() + 0 +result = loop_binding() +''', + "for-in-string": '''//@version=6 +strategy("scope for-in string") +var values = array.from("x") +loop_binding() => + for value in values + slot = array.from(value) + slot.push("y") + 0 +result = loop_binding() +''', + "map-pair": '''//@version=6 +strategy("scope map pair binder") +var pairs = map.new() +var slot = array.from(4) +loop_binding() => + for [key, value] in pairs + slot = array.from(slot.get(0) + value) + slot.size() + 0 +result = loop_binding() +''', +} + + +@pytest.mark.parametrize( + "case", _LOOP_BINDER_EXACT_SOURCES, ids=_LOOP_BINDER_EXACT_SOURCES +) +def test_loop_binder_specs_match_array_from_declaration(case: str) -> None: + cpp = transpile(_LOOP_BINDER_EXACT_SOURCES[case]) + if case == "for-in-string": + assert ( + "std::vector slot = " + "std::vector{value};" + ) in cpp + assert 'slot.push_back(std::string("y"))' in cpp + else: + assert "std::vector slot = std::vector" in cpp + if case == "map-pair": + assert "for (auto [key, value] : pairs)" in cpp + compile_cpp(cpp, label=f"collection_scope_loop_binder_{case}") + + +_OUTER_ALIAS_COLLISION_SOURCE = '''//@version=6 +strategy("scope outer alias collision") +var slot = array.from(4.0) +outer_subscript() => + _pf_outer_slot_0 = 99 + slot = array.from(slot[0]) + slot.size() +result = outer_subscript() +''' + + +def test_temporal_outer_alias_avoids_user_name_and_routes_subscript() -> None: + cpp = transpile(_OUTER_ALIAS_COLLISION_SOURCE) + body = _body(cpp, "double outer_subscript(") + assert "int _pf_outer_slot_0 = 99" in body + assert "auto& _pf_outer_slot_1 = this->slot" in body + assert "std::vector{_pf_outer_slot_1[0]}" in body + compile_cpp(cpp, label="collection_scope_outer_alias_collision") + + +_IDENTITY_SOURCE = '''//@version=6 +strategy("Collection scope identity") +map_probe() => + unique_map = map.new() + unique_map.put("k", 2.5) + unique_map.size() +array_probe() => + unique_array = array.from(1, 2) + unique_array.size() +matrix_probe() => + unique_matrix = matrix.new(1, 1, 7.0) + unique_matrix.get(0, 0) +map_result = map_probe() +array_result = array_probe() +matrix_result = matrix_probe() +''' + + +def test_unique_local_collection_output_stays_byte_identical() -> None: + cpp = transpile(_IDENTITY_SOURCE) + assert len(cpp) == 8892 + assert sha256(cpp.encode()).hexdigest() == ( + "b3cea2eab92d45874cfddb94f2a624e0d7c463cf2dbb3d27edbe06a0a5fbb8d0" + )