diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index daa5557..44c58e7 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -458,18 +458,126 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: # Array / Map / Matrix method dispatch # --------------------------------------------------------------------------- + +def _checked_array_index_prelude() -> str: + """C++ body shared by Pine v6 checked single-index operations.""" + return ( + "using __pf_raw_index_type=std::decay_t; " + "if constexpr(!std::is_same_v<__pf_raw_index_type,bool>) { " + "if(is_na(__pf_raw_index_value)) " + "pine_runtime_error(std::string(\"Index na is out of bounds. Array size is \")+" + "std::to_string((int64_t)__pf_array.size())); } " + "if constexpr(std::is_floating_point_v<__pf_raw_index_type>) { " + "if(!std::isfinite(__pf_raw_index_value)) { " + "std::string __pf_raw_index_text=__pf_raw_index_value>0?\"inf\":\"-inf\"; " + "pine_runtime_error(std::string(\"Index \")+__pf_raw_index_text+" + "\" is out of bounds. Array size is \"+" + "std::to_string((int64_t)__pf_array.size())); } " + "long double __pf_raw_index_wide=(long double)__pf_raw_index_value; " + "if(__pf_raw_index_wide<(long double)std::numeric_limits::min()||" + "__pf_raw_index_wide>(long double)std::numeric_limits::max()) " + "pine_runtime_error(std::string(\"Index \")+" + "std::to_string((double)__pf_raw_index_value)+" + "\" is out of bounds. Array size is \"+" + "std::to_string((int64_t)__pf_array.size())); } " + "int64_t __pf_raw_index=(int64_t)__pf_raw_index_value; " + "int64_t __pf_array_size=(int64_t)__pf_array.size(); " + "int64_t __pf_array_index=__pf_raw_index<0?" + "__pf_raw_index+__pf_array_size:__pf_raw_index; " + "if(__pf_array_index<0||__pf_array_index>=__pf_array_size) " + "pine_runtime_error(std::string(\"Index \")+std::to_string(__pf_raw_index)+" + "\" is out of bounds. Array size is \"+std::to_string(__pf_array_size)); " + ) + + +def _checked_array_get(a: str, args: list[str]) -> str: + check = _checked_array_index_prelude() + return ( + "[&](auto&& __pf_array)->decltype(auto){ " + "return [&](auto&& __pf_raw_index_value)->decltype(auto){ " + f"{check}" + "if constexpr(std::is_lvalue_reference_v) " + "return (__pf_array[(size_t)__pf_array_index]); " + "else { using __pf_array_value_type=typename " + "std::decay_t::value_type; " + "return __pf_array_value_type(__pf_array[(size_t)__pf_array_index]); } " + f"}}(({args[0]})); }}(({a}))" + ) + + +def _checked_array_set(a: str, args: list[str]) -> str: + check = _checked_array_index_prelude() + return ( + "[&](auto&& __pf_array){ " + "return [&](auto&& __pf_raw_index_value){ " + "return [&](auto&& __pf_array_value){ " + f"{check}" + "__pf_array[(size_t)__pf_array_index]=__pf_array_value; " + f"}}(({args[1]})); }}(({args[0]})); }}(({a}))" + ) + + +def _checked_array_remove(a: str, args: list[str]) -> str: + check = _checked_array_index_prelude() + return ( + "[&](auto&& __pf_array){ " + "return [&](auto&& __pf_raw_index_value){ " + f"{check}" + "using __pf_array_value_type=typename " + "std::decay_t::value_type; " + "__pf_array_value_type __pf_array_value=" + "__pf_array[(size_t)__pf_array_index]; " + "__pf_array.erase(__pf_array.begin()+(size_t)__pf_array_index); " + "return __pf_array_value; " + f"}}(({args[0]})); }}(({a}))" + ) + + +def _checked_array_end_get(a: str, method: str) -> str: + access = "front" if method == "first" else "back" + return ( + "[&](auto&& __pf_array)->decltype(auto){ " + f"if(__pf_array.empty()) pine_runtime_error(\"Cannot use {method}() " + "if array is empty.\"); " + "if constexpr(std::is_lvalue_reference_v) " + f"return (__pf_array.{access}()); " + "else { using __pf_array_value_type=typename " + "std::decay_t::value_type; " + f"return __pf_array_value_type(__pf_array.{access}()); }} " + f"}}(({a}))" + ) + + +def _checked_array_end_remove(a: str, method: str) -> str: + access = "back" if method == "pop" else "front" + mutation = ( + "__pf_array.pop_back();" + if method == "pop" + else "__pf_array.erase(__pf_array.begin());" + ) + return ( + "[&](auto&& __pf_array){ " + f"if(__pf_array.empty()) pine_runtime_error(\"Cannot use {method}() " + "if array is empty.\"); " + "using __pf_array_value_type=typename " + "std::decay_t::value_type; " + f"__pf_array_value_type __pf_array_value=__pf_array.{access}(); " + f"{mutation} return __pf_array_value; }}(({a}))" + ) + + # Methods called as ``array.method(arr, ...)`` or ``arr.method(...)``. ARRAY_METHODS = { - "get": lambda a, args: f"{a}[({args[0]})]", - "set": lambda a, args: f"{a}[({args[0]})] = {args[1]}", + "get": _checked_array_get, + "set": _checked_array_set, "push": lambda a, args: f"{a}.push_back({args[0]})", "unshift": lambda a, args: f"{a}.insert({a}.begin(), {args[0]})", "insert": lambda a, args: f"{a}.insert({a}.begin() + (int)({args[0]}), {args[1]})", - "pop": lambda a, args: f"[&](){{ auto v={a}.back(); {a}.pop_back(); return v; }}()", - "shift": lambda a, args: f"[&](){{ auto v={a}.front(); {a}.erase({a}.begin()); return v; }}()", - "remove": lambda a, args: f"[&](){{ auto v={a}[({args[0]})]; {a}.erase({a}.begin()+(int)({args[0]})); return v; }}()", - "first": lambda a, args: f"{a}.front()", - "last": lambda a, args: f"{a}.back()", + "pop": lambda a, args: _checked_array_end_remove(a, "pop"), + "shift": lambda a, args: _checked_array_end_remove(a, "shift"), + "remove": _checked_array_remove, + "first": lambda a, args: _checked_array_end_get(a, "first"), + "last": lambda a, args: _checked_array_end_get(a, "last"), "size": lambda a, args: f"(double){a}.size()", "clear": lambda a, args: f"{a}.clear()", "fill": lambda a, args: f"std::fill({a}.begin(), {a}.end(), {args[0]})" if len(args) == 1 @@ -523,6 +631,19 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: "sort_indices": lambda a, args: f"[&](){{ std::vector idx({a}.size()); std::iota(idx.begin(),idx.end(),0); std::sort(idx.begin(),idx.end(),[&](int i,int j){{return {a}[i]<{a}[j];}}); return idx; }}()", } +# Pine parameter order for the checked-access subset. Keeping the receiver +# (``id``) separate lets method syntax merge ``a.get(index = i)`` while the +# namespace-functional path merges ``array.get(id = a, index = i)``. +CHECKED_ARRAY_METHOD_KWARGS: dict[str, list[str]] = { + "get": ["index"], + "set": ["index", "value"], + "remove": ["index"], + "first": [], + "last": [], + "pop": [], + "shift": [], +} + MAP_METHODS = { "put": lambda m, args: f"({m}[{args[0]}] = {args[1]})", "get": lambda m, args: f"({m}.count({args[0]}) ? {m}[{args[0]}] : na())", diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 54d4dde..f38430d 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -230,6 +230,12 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: if sym is not None and getattr(sym, "type_spec", None) is not None: return sym.type_spec return None + if isinstance(node, Ternary): + true_spec = self._type_spec_from_expr(node.true_val) + false_spec = self._type_spec_from_expr(node.false_val) + if true_spec is not None and true_spec == false_spec: + return true_spec + return None if isinstance(node, MemberAccess): owner = self._type_spec_from_expr(node.object) if owner is not None and owner.kind == "udt" and owner.name: @@ -237,6 +243,11 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: return None if isinstance(node, FuncCall): func_name, namespace = self._resolve_callee(node.callee) + if namespace is None: + func_info = self._func_info_map.get(func_name) + return_spec = getattr(func_info, "return_type_spec", None) + if return_spec is not None: + return return_spec # ticker.* constructors (inherit/standard/heikinashi) return a symbol # string; without this the member-type inference defaults to double # and a ``haTicker = ticker.heikinashi(...)`` global mis-declares as @@ -278,10 +289,11 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: # Functional-form array element/copy accessors: the receiver is # the first argument (``array.copy(arr)``), mirroring the # method-form handling below (``arr.copy()``). - if (namespace == "array" and node.args + if (namespace == "array" and func_name in ("copy", "slice", "get", "first", "last", "pop", "shift", "remove")): - arg_spec = self._type_spec_from_expr(node.args[0]) + receiver_node = node.args[0] if node.args else node.kwargs.get("id") + arg_spec = self._type_spec_from_expr(receiver_node) if arg_spec is not None and arg_spec.kind == "array": if func_name in ("copy", "slice"): return arg_spec @@ -646,6 +658,46 @@ def _na_reassign_cpp_type(self, name: str) -> str | None: # BUG C: user-defined-UDT lvalue aliasing # ------------------------------------------------------------------ + def _is_stable_lvalue_expr(self, expr) -> bool: + """Whether ``expr`` denotes storage that can safely back a C++ ref. + + Checked array access deliberately returns by reference for lvalue + receivers and by value for temporary receivers. The UDT alias pass + must make the same distinction or it can emit ``T&`` bound to the + checked helper's safe rvalue copy. + """ + if isinstance(expr, Identifier): + return True + if isinstance(expr, MemberAccess): + return self._is_stable_lvalue_expr(expr.object) + if isinstance(expr, FuncCall): + # An element selected from a stable array is itself stable. This + # must recurse independently of the element type so nested + # ``array>`` access keeps the inner array lvalue and the + # eventual UDT element can still alias it. Constructors, user + # function returns, matrix.row(), and other temporary producers do + # not enter this checked array-access shape. + func_name, namespace = self._resolve_callee(expr.callee) + receiver = None + if namespace == "array" and func_name in ("get", "first", "last"): + receiver = expr.args[0] if expr.args else expr.kwargs.get("id") + elif (isinstance(expr.callee, MemberAccess) + and func_name in ("get", "first", "last")): + candidate = expr.callee.object + candidate_spec = self._type_spec_from_expr(candidate) + if candidate_spec is not None and candidate_spec.kind == "array": + receiver = candidate + return ( + receiver is not None + and self._is_stable_lvalue_expr(receiver) + ) + if isinstance(expr, Ternary): + return ( + self._is_stable_lvalue_expr(expr.true_val) + and self._is_stable_lvalue_expr(expr.false_val) + ) + return False + def _is_udt_lvalue(self, expr) -> str | None: """If ``expr`` is a *user-defined* UDT lvalue (a bare ``Identifier`` that names a class-scope ``var``/global UDT member, e.g. ``wyckoffSwingLow``, @@ -659,12 +711,14 @@ def _is_udt_lvalue(self, expr) -> str | None: callee = expr.callee func_name, namespace = self._resolve_callee(callee) receiver = None - if namespace == "array" and func_name in ("get", "first", "last") and expr.args: - receiver = expr.args[0] + if namespace == "array" and func_name in ("get", "first", "last"): + receiver = expr.args[0] if expr.args else expr.kwargs.get("id") elif (isinstance(callee, MemberAccess) and func_name in ("get", "first", "last")): receiver = callee.object if receiver is not None: + if not self._is_stable_lvalue_expr(receiver): + return None spec = self._type_spec_from_expr(receiver) elem = spec.element if spec is not None and spec.kind == "array" else None if (elem is not None and elem.kind == "udt" and elem.name in self._udt_defs diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 5150784..f2264f4 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -148,6 +148,7 @@ ARRAY_METHODS, BAR_FIELDS, BAR_SERIES_PUSH, + CHECKED_ARRAY_METHOD_KWARGS, DRAWING_NS, DRAWING_TYPE_TO_CPP, MAP_METHODS, @@ -281,6 +282,22 @@ def _array_method_args( for idx, arg in enumerate(arg_nodes) ] + def _array_method_arg_nodes(self, method: str, node: FuncCall) -> list: + """Merge checked-array method kwargs into Pine signature order.""" + param_names = CHECKED_ARRAY_METHOD_KWARGS.get(method) + if param_names is None: + return list(node.args) + return _merge_kwargs(node.args, node.kwargs, param_names, lambda arg: arg) + + def _array_function_arg_nodes(self, method: str, node: FuncCall) -> list: + """Merge ``array.method(id=..., ...)`` arguments in signature order.""" + param_names = CHECKED_ARRAY_METHOD_KWARGS.get(method) + if param_names is None: + return list(node.args) + return _merge_kwargs( + node.args, node.kwargs, ["id", *param_names], lambda arg: arg + ) + def _visit_func_call(self, node: FuncCall) -> str: callee = node.callee if isinstance(callee, MemberAccess): @@ -320,6 +337,21 @@ def _visit_func_call(self, node: FuncCall) -> str: list(node.args), node, ) + # Array method on an arbitrary expression receiver, e.g. + # ``make_array().get(-1)``, ``m.row(0).last()``, or + # ``(cond ? a : b).get(-1)``. Identifier and member receivers have + # dedicated paths below; other receiver shapes used to fall through to + # ``None(...)`` despite carrying a known array spec. + if (isinstance(callee, MemberAccess) + and not isinstance(callee.object, (Identifier, MemberAccess)) + and callee.member in ARRAY_METHODS): + recv_spec = self._type_spec_from_expr(callee.object) + if recv_spec is not None and recv_spec.kind == "array": + recv = self._visit_expr(callee.object) + arg_nodes = self._array_method_arg_nodes(callee.member, node) + args = self._array_method_args(callee.member, arg_nodes, recv_spec) + return self._array_method_expr(recv, callee.member, args, recv_spec) + # chart.point.now/new/from_index/from_time/copy — REAL data (a ChartPoint # aggregate). Routed here BEFORE the obj.field.method receiver logic, # which would otherwise mis-treat ``chart.point`` as a receiver object @@ -342,8 +374,12 @@ def _visit_func_call(self, node: FuncCall) -> str: meth = callee.member raw_args = [self._visit_expr(a) for a in node.args] if recv_spec is not None and recv_spec.kind == "array" and meth in ARRAY_METHODS: + arg_nodes = self._array_method_arg_nodes(meth, node) return self._array_method_expr( - recv, meth, self._array_method_args(meth, node.args, recv_spec), recv_spec + recv, + meth, + self._array_method_args(meth, arg_nodes, recv_spec), + recv_spec, ) if recv_spec is not None and recv_spec.kind == "map" and meth in MAP_METHODS: return self._map_method_expr(recv, meth, raw_args, recv_spec) @@ -368,8 +404,9 @@ def _visit_func_call(self, node: FuncCall) -> str: 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) + arg_nodes = self._array_method_arg_nodes(meth_raw, node) margs = self._array_method_args( - meth_raw, node.args, self._array_spec_for_name(oname) + meth_raw, arg_nodes, self._array_spec_for_name(oname) ) 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: @@ -538,7 +575,8 @@ def _visit_func_call(self, node: FuncCall) -> str: 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) - args = self._array_method_args(func_name, node.args, 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) # Array operations — emit proper C++ vector operations @@ -561,10 +599,13 @@ 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: - arr = self._visit_expr(node.args[0]) - spec = self._type_spec_from_expr(node.args[0]) - rest = self._array_method_args(func_name, node.args[1:], spec) + if func_name in ARRAY_METHODS and (node.args or node.kwargs): + all_nodes = self._array_function_arg_nodes(func_name, node) + if not all_nodes: + return "0" + arr = self._visit_expr(all_nodes[0]) + spec = self._type_spec_from_expr(all_nodes[0]) + rest = self._array_method_args(func_name, all_nodes[1:], spec) return self._array_method_expr(arr, func_name, rest, spec) return "0" diff --git a/tests/test_array_checked_access.py b/tests/test_array_checked_access.py new file mode 100644 index 0000000..72b7604 --- /dev/null +++ b/tests/test_array_checked_access.py @@ -0,0 +1,549 @@ +"""Pine v6 checked array access and negative-index regression coverage.""" + +from __future__ import annotations + +import os +import re +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from pineforge_codegen import transpile +from tests import _compile as compile_env + + +def _generate(body: str) -> str: + return transpile(f'//@version=6\nstrategy("T")\n{body}\n') + + +@pytest.mark.parametrize( + "body", + [ + "a = array.from(1, 2, 3)\nx = array.get(a, -1)", + "a = array.from(1, 2, 3)\nx = a.get(-1)", + "a = array.from(1, 2, 3)\nx = array.get(id=a, index=-1)", + "a = array.from(1, 2, 3)\nx = a.get(index=-1)", + "a = array.from(1, 2, 3)\narray.set(a, -2, 9)", + "a = array.from(1, 2, 3)\na.set(-2, 9)", + "a = array.from(1, 2, 3)\narray.set(id=a, index=-2, value=9)", + "a = array.from(1, 2, 3)\na.set(index=-2, value=9)", + "a = array.from(1, 2, 3)\narray.set(a, index=-2, value=9)", + "a = array.from(1, 2, 3)\na.set(-2, value=9)", + "a = array.from(1, 2, 3)\nx = array.remove(a, -3)", + "a = array.from(1, 2, 3)\nx = a.remove(-3)", + "a = array.from(1, 2, 3)\nx = array.remove(id=a, index=-3)", + "a = array.from(1, 2, 3)\nx = a.remove(index=-3)", + "a = array.from(1)\nx = array.first(a)", + "a = array.from(1)\nx = array.first(id=a)", + "a = array.from(1)\nx = a.last()", + "a = array.from(1)\nx = array.last(id=a)", + "a = array.from(1)\nx = array.pop(a)", + "a = array.from(1)\nx = array.pop(id=a)", + "a = array.from(1)\nx = a.shift()", + "a = array.from(1)\nx = array.shift(id=a)", + ], +) +def test_checked_methods_route_through_runtime_error_path(body: str): + cpp = _generate(body) + assert "pine_runtime_error" in cpp + + +def test_checked_get_binds_temporary_receiver_and_index_once(): + cpp = _generate( + "values = array.from(10, 20, 30)\n" + "idx() =>\n" + " -1\n" + "x = array.get(array.slice(values, 0, 3), idx())" + ) + assignment = next( + line for line in cpp.splitlines() if line.startswith(" x =") + ) + assert assignment.count("std::vector(values.begin()") == 1 + assert assignment.count("idx()") == 1 + assert "pine_runtime_error" in assignment + + +@pytest.mark.parametrize( + ("setup", "receiver"), + [ + ("make_values() =>\n array.from(10, 20, 30)\n", "make_values()"), + ("", "array.from(10, 20, 30)"), + ("m = matrix.new(1, 3, 10)\n", "m.row(0)"), + ( + "a = array.from(10, 20)\nb = array.from(30, 40)\n", + "(close > open ? a : b)", + ), + ], +) +def test_checked_get_supports_temporary_method_receiver(setup: str, receiver: str): + cpp = _generate(f"{setup}x = {receiver}.get(-1)") + assignment = next( + line for line in cpp.splitlines() if line.startswith(" x =") + ) + assert "None(" not in assignment + assert "pine_runtime_error" in assignment + + +def test_checked_set_emission_preserves_receiver_index_value_order(): + cpp = _generate( + "values = array.from(10, 20, 30)\n" + "recv() =>\n" + " array.copy(values)\n" + "idx() =>\n" + " -1\n" + "val() =>\n" + " 99\n" + "array.set(recv(), idx(), val())" + ) + statement = next( + line for line in cpp.splitlines() if "recv()" in line and "idx()" in line + ) + assert statement.count("recv()") == 1 + assert statement.count("idx()") == 1 + assert statement.count("val()") == 1 + assert statement.index("[&](auto&& __pf_array)") < statement.index( + "[&](auto&& __pf_raw_index_value)" + ) < statement.index("[&](auto&& __pf_array_value)") + # The calls close in reverse textual order because the outermost receiver + # lambda executes first, then invokes the index lambda, which invokes the + # value lambda. The executable order probe below locks the semantics. + assert statement.index("}((val()))") < statement.index("}((idx()))") + assert statement.index("}((idx()))") < statement.index("}((recv()))") + + +def test_checked_index_rejects_na_nonfinite_and_range_before_integer_cast(): + cpp = _generate("a = array.from(1, 2, 3)\nx = a.get(close)") + assignment = next( + line for line in cpp.splitlines() if line.startswith(" x =") + ) + integer_cast = assignment.index( + "int64_t __pf_raw_index=(int64_t)__pf_raw_index_value" + ) + assert assignment.index("is_na(__pf_raw_index_value)") < integer_cast + assert assignment.index("std::isfinite(__pf_raw_index_value)") < integer_cast + assert assignment.index("std::numeric_limits::min()") < integer_cast + assert assignment.index("std::numeric_limits::max()") < integer_cast + + +@pytest.mark.parametrize( + "access", + ["array.get(pivots, i)", "array.first(pivots)", "pivots.last()"], +) +def test_checked_udt_lvalue_access_preserves_alias(access: str): + cpp = _generate( + "type Pivot\n" + " float level\n" + "var array pivots = array.new()\n" + "update(int i) =>\n" + f" Pivot p = {access}\n" + " p.level := close\n" + " 0\n" + "if array.size(pivots) == 0\n" + " array.push(pivots, Pivot.new(na))\n" + "update(0)" + ) + assert re.search(r"Pivot& p = .*pine_runtime_error", cpp) + assert "Pivot p =" not in cpp + + +_VALID_NEGATIVE_SOURCE = """//@version=6 +strategy("Checked array valid negative indices") +make_values() => + array.from(7, 8) +values = array.from(10, 20, 30) +get_last = values.get(-1) +array.set(values, -2, 99) +removed = array.remove(values, -3) +first_after = values.first() +last_after = array.last(values) +popped = values.pop() +array.push(values, 40) +shifted = array.shift(values) +remaining = values.get(0) +temporary = array.get(array.slice(values, 0, 1), -1) +keyword_function = array.get(id=values, index=-1) +keyword_method = values.get(index=-1) +temporary_method = make_values().get(-1) +keyword_values = array.from(1, 2, 3) +array.set(id=keyword_values, index=-1, value=77) +array.set(keyword_values, index=-2, value=66) +keyword_values.set(index=-3, value=55) +keyword_values.set(-1, value=88) +keyword_set_first = keyword_values.get(0) +keyword_set_middle = keyword_values.get(1) +keyword_set_last = keyword_values.get(2) +keyword_removed_function = array.remove(id=keyword_values, index=-2) +keyword_removed_method = keyword_values.remove(index=-1) +keyword_set_remaining = keyword_values.get(0) +""" + + +_ORDER_SOURCE = """//@version=6 +strategy("Checked array evaluation order") +var values = array.from(10, 20, 30) +var order = array.new() + +receiver() => + array.push(order, 1) + array.copy(values) + +index() => + array.push(order, 2) + -1 + +value() => + array.push(order, 3) + 99 + +array.set(receiver(), index(), value()) +order_code = array.get(order, 0) * 100 + array.get(order, 1) * 10 + array.get(order, 2) +order_size = array.size(order) +""" + + +_ERROR_ORDER_SOURCE = """//@version=6 +strategy("Checked array error evaluation order") +var values = array.from(10, 20, 30) +var order = array.new() +var after = 0 + +receiver() => + array.push(order, 1) + array.copy(values) + +bad_index() => + array.push(order, 2) + 3 + +value() => + array.push(order, 3) + 99 + +array.set(receiver(), bad_index(), value()) +after := 1 +""" + + +_BOOL_ACCESS_SOURCE = """//@version=6 +strategy("Checked bool array access") +values = array.from(true, false, true) +got = values.get(-1) +array.set(values, -2, true) +removed = array.remove(values, -3) +first_value = values.first() +last_value = values.last() +popped = values.pop() +array.push(values, false) +shifted = values.shift() +remaining = values.get(0) +""" + + +_ERROR_MATRIX_SOURCE = """//@version=6 +strategy("Checked array error matrix") +local_na_get(array source) => + int missing = na + array.get(source, missing) +selector = close +values = array.from(1, 2, 3) +empty = array.new(0) +int global_missing = na +sink = 0 + +if selector == 1 + sink := array.get(values, 3) +else if selector == 2 + sink := values.get(-4) +else if selector == 3 + array.set(values, 3, 9) +else if selector == 4 + values.set(-4, 9) +else if selector == 5 + sink := array.remove(values, 3) +else if selector == 6 + sink := values.remove(-4) +else if selector == 7 + sink := array.first(empty) +else if selector == 8 + sink := empty.last() +else if selector == 9 + sink := array.pop(empty) +else if selector == 10 + sink := empty.shift() +else if selector == 11 + sink := local_na_get(values) +else if selector == 12 + sink := array.get(values, global_missing) +else if selector == 13 + sink := array.get(values, math.pow(10, 400)) +""" + + +def _find_engine_library() -> Path | None: + explicit = os.environ.get("PINEFORGE_ENGINE_LIB") + if explicit: + path = Path(explicit).expanduser().resolve() + return path if path.is_file() else None + if compile_env._ENGINE_INC is None: + return None + candidates: list[Path] = [] + for pattern in ("build*/lib/libpineforge.a", "build*/lib/libpineforge.dylib"): + candidates.extend(sorted(compile_env._ENGINE_INC.parent.glob(pattern))) + return candidates[0].resolve() if candidates else None + + +def _compile_and_run(cpp_source: str) -> str: + compile_env.skip_if_no_compile_env() + engine_lib = _find_engine_library() + if engine_lib is None: + pytest.skip("built libpineforge not found; set PINEFORGE_ENGINE_LIB") + compiler = compile_env._COMPILER + engine_inc = compile_env._ENGINE_INC + eigen_inc = compile_env._EIGEN_INC + assert compiler is not None and engine_inc is not None and eigen_inc is not None + + with tempfile.TemporaryDirectory(prefix="pineforge-array-access-") as tmp: + cpp_path = Path(tmp) / "probe.cpp" + exe_path = Path(tmp) / "probe" + cpp_path.write_text(cpp_source) + command = [ + compiler, + "-std=c++17", + "-O0", + "-I", + str(engine_inc), + "-I", + str(eigen_inc), + ] + if compile_env._GENERATED_INC is not None: + command += ["-I", str(compile_env._GENERATED_INC)] + command += [str(cpp_path), str(engine_lib), "-pthread", "-o", str(exe_path)] + built = subprocess.run(command, capture_output=True, text=True, timeout=120) + if built.returncode != 0: + raise AssertionError( + "checked-array runtime probe failed to link\n" + + "\n".join((built.stderr or built.stdout).splitlines()[:100]) + ) + ran = subprocess.run( + [str(exe_path)], capture_output=True, text=True, timeout=30 + ) + if ran.returncode != 0: + raise AssertionError( + f"checked-array runtime probe exited {ran.returncode}\n" + f"stdout:\n{ran.stdout}\nstderr:\n{ran.stderr}" + ) + return ran.stdout + + +@pytest.mark.parametrize( + "access", + [ + "array.get(array.from(Pivot.new(1), Pivot.new(2)), -1)", + "array.first(array.from(Pivot.new(1), Pivot.new(2)))", + "array.from(Pivot.new(1), Pivot.new(2)).last()", + ], +) +def test_temporary_udt_access_returns_safe_value(access: str): + source = f'''//@version=6 +strategy("Temporary UDT checked access") +type Pivot + float level +probe() => + Pivot p = {access} + p.level := 7 + p.level +observed = probe() +''' + cpp = transpile(source) + assert "Pivot& p =" not in cpp + assert re.search(r"Pivot p = .*pine_runtime_error", cpp) + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) { + std::cerr << strategy.last_error() << "\n"; + return 2; + } + std::cout << strategy.observed << "\n"; +} +""" + assert float(_compile_and_run(cpp + driver)) == 7.0 + + +def test_nested_array_udt_access_keeps_write_through_alias(): + source = '''//@version=6 +strategy("Nested UDT checked access") +type Pivot + float level +var inner = array.from(Pivot.new(1)) +var outer = array.from(inner) +mutate() => + Pivot p = array.get(array.get(outer, 0), 0) + p.level := 9 + 0 +mutate() +observed = array.get(array.get(outer, 0), 0).level +''' + cpp = transpile(source) + assert re.search(r"Pivot& p = .*pine_runtime_error", cpp) + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) { + std::cerr << strategy.last_error() << "\n"; + return 2; + } + std::cout << strategy.observed << "\n"; +} +""" + assert float(_compile_and_run(cpp + driver)) == 9.0 + + +def test_valid_negative_indices_and_end_operations_runtime(): + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) { + std::cerr << strategy.last_error() << "\n"; + return 2; + } + std::cout << strategy.get_last << " " << strategy.removed << " " + << strategy.first_after << " " << strategy.last_after << " " + << strategy.popped << " " << strategy.shifted << " " + << strategy.remaining << " " << strategy.temporary << " " + << strategy.keyword_function << " " << strategy.keyword_method << " " + << strategy.temporary_method << " " + << strategy.keyword_set_first << " " + << strategy.keyword_set_middle << " " + << strategy.keyword_set_last << " " + << strategy.keyword_removed_function << " " + << strategy.keyword_removed_method << " " + << strategy.keyword_set_remaining << "\n"; +} +""" + output = _compile_and_run(transpile(_VALID_NEGATIVE_SOURCE) + driver) + assert tuple(int(value) for value in output.split()) == ( + 30, + 10, + 99, + 30, + 30, + 99, + 40, + 40, + 40, + 40, + 8, + 55, + 66, + 88, + 66, + 88, + 55, + ) + + +def test_receiver_index_value_runtime_order_and_one_evaluation(): + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) { + std::cerr << strategy.last_error() << "\n"; + return 2; + } + std::cout << strategy.order_code << " " << strategy.order_size << "\n"; +} +""" + output = _compile_and_run(transpile(_ORDER_SOURCE) + driver) + order_code, order_size = (int(float(value)) for value in output.split()) + assert (order_code, order_size) == (123, 3) + + +def test_oob_set_evaluates_receiver_index_and_value_once_before_error(): + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + std::cout << strategy.last_error() << "\n"; + for (auto value : strategy.order) std::cout << value; + std::cout << " " << strategy.after << "\n"; +} +""" + lines = _compile_and_run(transpile(_ERROR_ORDER_SOURCE) + driver).splitlines() + assert lines == ["Index 3 is out of bounds. Array size is 3", "123 0"] + + +def test_bool_array_checked_operations_do_not_dangle_vector_bool_proxies(): + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) { + std::cerr << strategy.last_error() << "\n"; + return 2; + } + std::cout << strategy.got << " " << strategy.removed << " " + << strategy.first_value << " " << strategy.last_value << " " + << strategy.popped << " " << strategy.shifted << " " + << strategy.remaining << "\n"; +} +""" + output = _compile_and_run(transpile(_BOOL_ACCESS_SOURCE) + driver) + assert tuple(int(value) for value in output.split()) == (1, 1, 1, 1, 1, 1, 0) + + +def test_oob_and_empty_methods_surface_deterministic_last_error(): + driver = r""" +#include +int main() { + for (int selector = 1; selector <= 13; ++selector) { + GeneratedStrategy strategy; + double value = static_cast(selector); + Bar bar{value, value, value, value, 1.0, selector}; + strategy.run(&bar, 1); + std::cout << selector << "\t" << strategy.last_error() << "\n"; + } +} +""" + output = _compile_and_run(transpile(_ERROR_MATRIX_SOURCE) + driver) + observed = { + int(selector): message + for selector, message in ( + line.split("\t", 1) for line in output.splitlines() + ) + } + positive = "Index 3 is out of bounds. Array size is 3" + negative = "Index -4 is out of bounds. Array size is 3" + assert observed == { + 1: positive, + 2: negative, + 3: positive, + 4: negative, + 5: positive, + 6: negative, + 7: "Cannot use first() if array is empty.", + 8: "Cannot use last() if array is empty.", + 9: "Cannot use pop() if array is empty.", + 10: "Cannot use shift() if array is empty.", + 11: "Index na is out of bounds. Array size is 3", + 12: "Index na is out of bounds. Array size is 3", + 13: "Index inf is out of bounds. Array size is 3", + } diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index e515679..0cc6620 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -389,9 +389,9 @@ def test_comma_separated_statements_and_array_fill_emit_all_side_effects(): assert "ys = std::vector((size_t)(2), na());" in cpp assert "lbs = std::vector