diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index ca0d8c3..b161c62 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -603,8 +603,9 @@ def _refresh_direct_terminal_array_temporary_returns(self) -> None: if not changed: break - @staticmethod + @classmethod def _direct_terminal_array_temporary_element_call( + cls, terminal: ASTNode | None, ) -> FuncCall | None: """Return a direct UDF element call from the registered shape.""" @@ -624,15 +625,21 @@ def _direct_terminal_array_temporary_element_call( else: receiver = callee.object - while ( - isinstance(receiver, FuncCall) - and isinstance(receiver.callee, MemberAccess) - and isinstance(receiver.callee.object, Identifier) - and receiver.callee.object.name == "array" - and receiver.callee.member == "copy" - and receiver.args - ): - receiver = receiver.args[0] + while True: + copy_source = cls._direct_namespace_array_copy_source(receiver) + if copy_source is not None: + receiver = copy_source + continue + if ( + isinstance(receiver, FuncCall) + and isinstance(receiver.callee, MemberAccess) + and receiver.callee.member == "copy" + and not receiver.args + and not receiver.kwargs + ): + receiver = receiver.callee.object + continue + break if not ( isinstance(receiver, FuncCall) and isinstance(receiver.callee, MemberAccess) diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index cb8a3be..b74b948 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -325,12 +325,20 @@ def terminal_expr(body): return TypeSpec.array(self._pine_type_to_spec(first)) return TypeSpec.array(TypeSpec.primitive("float")) # Functional-form array element/copy accessors: the receiver is - # the first argument (``array.copy(arr)``), mirroring the - # method-form handling below (``arr.copy()``). - if (ns == "array" and value.args + # the first argument (``array.copy(arr)``), or the exact ``id`` + # keyword for ``array.copy(id=arr)``. The latter deliberately + # uses the shape validator shared by terminal-return recovery so + # duplicate/unknown keyword forms do not acquire a type by + # accident. + if (ns == "array" and func in ("copy", "slice", "get", "first", "last", "pop", "shift", "remove")): - arg_spec = self._type_spec_from_expr(value.args[0]) + receiver = None + if func == "copy": + receiver = self._direct_namespace_array_copy_source(value) + elif value.args: + receiver = value.args[0] + arg_spec = self._type_spec_from_expr(receiver) if arg_spec is not None and arg_spec.kind == "array": if func in ("copy", "slice"): return arg_spec @@ -708,6 +716,35 @@ def _terminal_array_get_receiver( return callee.object return None + @staticmethod + def _direct_namespace_array_copy_source( + value: ASTNode | None, + ) -> ASTNode | None: + """Return the sole receiver of an exact ``array.copy`` call shape. + + Pine v6 accepts either ``array.copy(source)`` or + ``array.copy(id=source)``. Keep this structural helper exact so an + invalid duplicate receiver or an unrelated keyword stays fail closed. + Namespace shadowing is intentionally checked by the callers whose + lexical scope is still live. + """ + if not isinstance(value, FuncCall) or not isinstance( + value.callee, MemberAccess + ): + return None + callee = value.callee + if not ( + isinstance(callee.object, Identifier) + and callee.object.name == "array" + and callee.member == "copy" + ): + return None + if len(value.args) == 1 and not value.kwargs: + return value.args[0] + if not value.args and set(value.kwargs) == {"id"}: + return value.kwargs["id"] + return None + def _is_unshadowed_direct_array_value_producer( self, value: ASTNode | None, @@ -727,19 +764,22 @@ def _is_unshadowed_direct_array_value_producer( if namespace_producer: if callee.member != "copy": return True - if len(value.args) != 1 or value.kwargs: + source = self._direct_namespace_array_copy_source(value) + if source is None: return False - source = value.args[0] if isinstance(source, Identifier): source_spec = self._type_spec_from_expr(source) return source_spec is not None and source_spec.kind == "array" return self._is_unshadowed_direct_array_value_producer(source) - if callee.member != "copy" or not isinstance( - callee.object, Identifier - ): + if callee.member != "copy" or value.args or value.kwargs: return False - receiver_spec = self._type_spec_from_expr(callee.object) - return receiver_spec is not None and receiver_spec.kind == "array" + if isinstance(callee.object, Identifier): + receiver_spec = self._type_spec_from_expr(callee.object) + return receiver_spec is not None and receiver_spec.kind == "array" + # A no-argument method copy preserves the value type of an existing + # direct producer. Recurse only through that already-bounded shape; + # arbitrary UDF/slice/map receivers remain excluded. + return self._is_unshadowed_direct_array_value_producer(callee.object) def _direct_array_value_spec_without_visiting( self, @@ -760,10 +800,10 @@ def _direct_array_value_spec_without_visiting( and callee.object.name == "array" and self._symbols.resolve("array") is None and callee.member == "copy" - and value.args - and isinstance(value.args[0], Identifier) ): - source = value.args[0] + candidate = self._direct_namespace_array_copy_source(value) + if isinstance(candidate, Identifier): + source = candidate elif callee.member == "copy" and isinstance( callee.object, Identifier ): @@ -832,6 +872,11 @@ def _cached_direct_array_value_spec( ): return None callee = value.callee + if callee.member == "copy" and not value.args and not value.kwargs: + # Method syntax carries its source in the callee object rather + # than in ``args``. Peel only a direct producer and stay entirely + # on cached metadata so a stateful element call is never revisited. + return self._cached_direct_array_value_spec(callee.object) if not ( isinstance(callee.object, Identifier) and callee.object.name == "array" @@ -859,8 +904,10 @@ def _cached_direct_array_value_spec( if producer == "from" and value.args: element = self._cached_primitive_expr_spec(value.args[0]) return TypeSpec.array(element) if element is not None else None - if producer == "copy" and value.args: - return self._cached_direct_array_value_spec(value.args[0]) + if producer == "copy": + source = self._direct_namespace_array_copy_source(value) + if source is not None: + return self._cached_direct_array_value_spec(source) return None def _cached_terminal_temporary_array_get_spec( diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index e325405..b0ab4e2 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -569,9 +569,11 @@ def terminal_expr(body): recv_spec = self._type_spec_from_expr(node.callee.object) member_name = node.callee.member if recv_spec is not None and recv_spec.kind == "array": - if func_name in ("get", "first", "last", "pop", "shift", "remove"): + if member_name in ( + "get", "first", "last", "pop", "shift", "remove", + ): return recv_spec.element - if func_name in ("copy", "slice"): + if member_name in ("copy", "slice"): return recv_spec if recv_spec is not None and recv_spec.kind == "map": if member_name in ("put", "get", "remove"): diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 6566af9..b3c4043 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -294,6 +294,16 @@ def _array_method_arg_nodes(self, method: str, node: FuncCall) -> list: def _array_function_arg_nodes(self, method: str, node: FuncCall) -> list: """Merge ``array.method(id=..., ...)`` arguments in signature order.""" + if method == "copy": + if len(node.args) == 1 and not node.kwargs: + return list(node.args) + if not node.args and set(node.kwargs) == {"id"}: + return [node.kwargs["id"]] + self._codegen_error( + node, + "array.copy: expected exactly one receiver 'id'", + hint="Use array.copy(source) or array.copy(id=source).", + ) param_names = CHECKED_ARRAY_METHOD_KWARGS.get(method) if param_names is None: return list(node.args) @@ -1239,7 +1249,9 @@ def _visit_func_call(self, node: FuncCall) -> str: elems = ", ".join(self._visit_expr(a) for a in node.args) return f"{self._type_spec_to_cpp(spec)}{{{elems}}}" # Method calls: array.method(arr, args...) - if func_name in ARRAY_METHODS and (node.args or node.kwargs): + if func_name in ARRAY_METHODS and ( + node.args or node.kwargs or func_name == "copy" + ): all_nodes = self._array_function_arg_nodes(func_name, node) if not all_nodes: return "0" diff --git a/tests/test_array_terminal_returns.py b/tests/test_array_terminal_returns.py index 95ec5c6..b277a9b 100644 --- a/tests/test_array_terminal_returns.py +++ b/tests/test_array_terminal_returns.py @@ -403,6 +403,8 @@ def test_forward_defaulted_parameter_is_not_registered_as_zero_parameter(): ( "array.get(make_values(), 0)", "array.copy(make_values()).get(0)", + "array.copy(id=make_values()).get(0)", + "make_values().copy().get(0)", "array.get(array.slice(values, 0, 1), 0)", ), ) @@ -421,6 +423,34 @@ def test_arbitrary_temporary_array_receivers_remain_fail_closed(terminal: str): assert "double blocked(" in cpp +def test_nested_method_copy_reference_element_remains_fail_closed(): + source = r'''//@version=6 +strategy("Nested method copy reference boundary") +type Item + int value +blocked() => + array.from(Item.new(1)).copy().get(0) +observed = blocked() +''' + cpp = transpile(source) + assert "Item blocked(" not in cpp + assert "double blocked(" in cpp + + +def test_nested_method_copy_temporary_reader_recursion_is_rejected(): + source = r'''//@version=6 +strategy("Nested method copy recursion boundary") +reader() => + array.get(array.from(reader()).copy(), 0) +observed = reader() +''' + with pytest.raises( + CompileError, + match="Recursive direct temporary-array reader cycle", + ): + transpile(source) + + def test_local_map_named_array_is_not_a_builtin_array_producer(): source = r'''//@version=6 strategy("Array producer namespace shadow") @@ -458,6 +488,221 @@ def test_identifier_backed_copy_representations_return_exact_type(terminal: str) compile_cpp(cpp) +@pytest.mark.parametrize( + ("type_name", "literal", "expected_cpp"), + ( + ("string", '"x"', "std::string"), + ("int", "7", "int"), + ), +) +@pytest.mark.parametrize( + "terminal", + ( + "array.copy(id=source).get(0)", + "array.copy(id=source).get(index=0)", + "array.get(array.copy(id=source), 0)", + "array.get(array.copy(id=source), index=0)", + "array.get(id=array.copy(id=source), index=0)", + ), +) +def test_keyword_namespace_copy_representations_return_exact_type( + type_name: str, + literal: str, + expected_cpp: str, + terminal: str, +): + source = f'''//@version=6 +strategy("Direct keyword copy producer representations") +read_value() => + array<{type_name}> source = array.from({literal}) + {terminal} +observed = read_value() +''' + cpp = transpile(source) + assert f"{expected_cpp} read_value(" in cpp + if type_name == "string": + assert "double read_value(" not in cpp + assert f"std::vector<{expected_cpp}>(source)" in cpp + compile_cpp(cpp) + + +@pytest.mark.parametrize( + "copy_call", + ( + "array.copy()", + "array.copy(source, id=other)", + "array.copy(source=source)", + ), +) +def test_invalid_namespace_copy_receiver_shapes_fail_closed(copy_call: str): + source = f'''//@version=6 +strategy("Invalid direct keyword copy shape") +blocked() => + array source = array.from("x") + array other = array.from("y") + {copy_call} +observed = blocked() +''' + with pytest.raises( + CompileError, + match="array.copy: expected exactly one receiver 'id'", + ): + transpile(source) + + +@pytest.mark.parametrize("producer_first", (False, True)) +def test_stateful_keyword_copy_direct_producer_is_exact_in_both_source_orders( + producer_first: bool, +): + producer = '''produce() => + unused = ta.sma(close, 2) + "x" +''' + reader = '''read_value() => + array.get(id=array.copy(id=array.from(produce())), index=0) +''' + definitions = producer + reader if producer_first else reader + producer + source = ( + "//@version=6\n" + 'strategy("Stateful direct keyword copy producer")\n' + f"{definitions}" + "observed = read_value()\n" + ) + + cpp = transpile(source) + assert "std::string read_value_cs0(" in cpp + assert "double read_value_cs0(" not in cpp + assert "std::vector(std::vector{produce_cs0()})" in cpp + assert "produce_cs1" not in cpp + assert cpp.count("ta::SMA _ta_sma_1;") == 1 + compile_cpp(cpp) + + +@pytest.mark.parametrize("producer_first", (False, True)) +def test_keyword_copy_of_nested_method_copy_is_exact_in_both_source_orders( + producer_first: bool, +): + """Exercise the analyzer, keyword emitter, and nested-copy typer together.""" + producer = '''produce() => + unused = ta.sma(close, 2) + "x" +''' + reader = '''read_value() => + array.get(id=array.copy(id=array.from(produce()).copy()), index=0) +''' + definitions = producer + reader if producer_first else reader + producer + source = ( + "//@version=6\n" + 'strategy("Keyword copy of nested method copy")\n' + f"{definitions}" + "observed = read_value()\n" + ) + + cpp = transpile(source) + assert "std::string read_value_cs0(" in cpp + assert "double read_value_cs0(" not in cpp + assert cpp.count("std::vector{produce_cs0()}") == 1 + assert "produce_cs1" not in cpp + assert cpp.count("ta::SMA _ta_sma_1;") == 1 + compile_cpp(cpp) + + +@pytest.mark.parametrize( + "terminal", + ( + 'array.from("x").copy().get(0)', + 'array.from("x").copy().get(index=0)', + 'array.get(array.from("x").copy(), 0)', + 'array.get(id=array.from("x").copy(), index=0)', + ), +) +def test_nested_method_copy_direct_temporary_returns_exact_string( + terminal: str, +): + source = f'''//@version=6 +strategy("Nested method copy direct temporary") +read_value() => + {terminal} +observed = read_value() +''' + cpp = transpile(source) + assert "std::string read_value(" in cpp + assert "double read_value(" not in cpp + compile_cpp(cpp) + + +@pytest.mark.parametrize( + "expression", + ( + 'array.from("x").copy().get(0)', + 'array.copy(id=array.from("x")).get(0)', + 'array.get(id=array.copy(id=array.from("x").copy()), index=0)', + ), +) +def test_copy_temporary_element_type_is_exact_outside_terminal_return( + expression: str, +): + source = f'''//@version=6 +strategy("Copy temporary nonterminal typing") +global_value = {expression} +read_local() => + local_value = {expression} + local_value == "x" +observed = read_local() +''' + cpp = transpile(source) + assert "std::string global_value =" in cpp + assert "bool read_local(" in cpp + assert 'local_value == std::string("x")' in cpp + compile_cpp(cpp) + + +@pytest.mark.parametrize( + ("functional", "keyword", "producer_first"), + product((False, True), repeat=3), +) +def test_nested_method_copy_temporary_stateful_matrix( + functional: bool, + keyword: bool, + producer_first: bool, +): + """All 2^3 get-shape/order cells keep one stateful element call site.""" + producer = '''produce() => + unused = ta.sma(close, 2) + "x" +''' + receiver = 'array.from(produce()).copy()' + if functional: + terminal = ( + f"array.get(id={receiver}, index=0)" + if keyword + else f"array.get({receiver}, 0)" + ) + else: + terminal = ( + f"{receiver}.get(index=0)" + if keyword + else f"{receiver}.get(0)" + ) + reader = f"read_value() =>\n {terminal}\n" + definitions = producer + reader if producer_first else reader + producer + source = ( + "//@version=6\n" + 'strategy("Nested method copy temporary matrix")\n' + f"{definitions}" + "observed = read_value()\n" + ) + + cpp = transpile(source) + assert "std::string read_value_cs0(" in cpp + assert "double read_value_cs0(" not in cpp + assert "std::vector(" in cpp + assert "produce_cs0()" in cpp + assert "produce_cs1" not in cpp + assert cpp.count("ta::SMA _ta_sma_1;") == 1 + compile_cpp(cpp) + + @pytest.mark.parametrize( ("functional", "string", "copy_factory", "udf_element"), product((False, True), repeat=4),