diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 9ef83bd..204363e 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -58,6 +58,8 @@ from __future__ import annotations +from dataclasses import replace + from ..ast_nodes import ( ASTNode, Assignment, BinOp, BoolLiteral, BreakStmt, ContinueStmt, ExprStmt, ForStmt, ForInStmt, FuncCall, FuncDef, Identifier, IfStmt, MemberAccess, @@ -253,12 +255,21 @@ def _substitute_tf_input_reads(self, node, resolving: set[str]): if isinstance(node, Identifier): name = node.name if name in self._timeframe_period_vars: - return MemberAccess(object=Identifier(name="timeframe"), member="period") + return MemberAccess( + object=Identifier(name="timeframe", loc=node.loc), + member="period", + loc=node.loc, + annotations=node.annotations, + ) if name in self._input_backed_vars and name in self._input_var_to_call: return self._input_var_to_call[name] if (name in self._known_vars and name not in self._input_backed_vars and isinstance(self._known_vars[name], str)): - return StringLiteral(value=self._known_vars[name]) + return StringLiteral( + value=self._known_vars[name], + loc=node.loc, + annotations=node.annotations, + ) global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} if name in global_expr_map and name not in resolving: return self._substitute_tf_input_reads( @@ -270,18 +281,18 @@ def _substitute_tf_input_reads(self, node, resolving: set[str]): fv = self._substitute_tf_input_reads(node.false_val, resolving) if cond is node.condition and tv is node.true_val and fv is node.false_val: return node - return Ternary(condition=cond, true_val=tv, false_val=fv) + return replace(node, condition=cond, true_val=tv, false_val=fv) if isinstance(node, BinOp): left = self._substitute_tf_input_reads(node.left, resolving) right = self._substitute_tf_input_reads(node.right, resolving) if left is node.left and right is node.right: return node - return BinOp(left=left, op=node.op, right=right) + return replace(node, left=left, right=right) if isinstance(node, UnaryOp): operand = self._substitute_tf_input_reads(node.operand, resolving) if operand is node.operand: return node - return UnaryOp(op=node.op, operand=operand) + return replace(node, operand=operand) if isinstance(node, FuncCall): new_args = [self._substitute_tf_input_reads(a, resolving) for a in node.args] new_kwargs = { @@ -295,13 +306,19 @@ def _substitute_tf_input_reads(self, node, resolving: set[str]): ) if unchanged: return node - return FuncCall(callee=node.callee, args=new_args, kwargs=new_kwargs) + # Preserve the source span and annotations of the original call. + # Namespace/receiver resolution uses that provenance to decide + # whether a same-named global binding was visible at this call's + # source position. Rebuilding a bare FuncCall here used to turn + # the span into synthetic 1:1 and could let a later ``map`` global + # capture this earlier built-in ``map.get(...)``. + return replace(node, args=new_args, kwargs=new_kwargs) if isinstance(node, Subscript): obj = self._substitute_tf_input_reads(node.object, resolving) idx = self._substitute_tf_input_reads(node.index, resolving) if obj is node.object and idx is node.index: return node - return Subscript(object=obj, index=idx) + return replace(node, object=obj, index=idx) return node def _resolve_param_tf_from_callsites(self, func_name: str, param_name: str): diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index ed62f12..4469a16 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -139,6 +139,7 @@ NaLiteral, TupleLiteral, StringLiteral, + VarDecl, ) from ..symbols import TypeSpec from .. import signatures as sigs @@ -299,12 +300,172 @@ def _array_function_arg_nodes(self, method: str, node: FuncCall) -> list: node.args, node.kwargs, ["id", *param_names], lambda arg: arg ) - def _map_param_method_arg_nodes(self, method: str, node: FuncCall) -> list: - """Merge typed-map parameter method kwargs into signature order.""" - param_names = MAP_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 _map_identifier_is_visible_binding(self, node: FuncCall) -> bool: + """Whether ``map`` is a lexical value at this exact source position. + + Global collection registries are intentionally pre-populated before + code emission, so raw membership in ``_var_names`` / collection maps + cannot answer this question: a later ``map`` declaration must not + capture an earlier built-in ``map.*`` call. Callable/block overlays, + by contrast, are activated in source order and therefore take first + refusal. For a direct top-level binding, compare the containing + top-level statement indexes; the binding becomes visible only after + its declaration statement, never inside its own RHS. + """ + name = "map" + if ( + name in getattr(self, "_current_func_param_types", {}) + or name in getattr(self, "_current_func_collection_specs", {}) + or name in getattr(self, "_current_func_collection_shadows", set()) + or name in getattr(self, "_current_func_local_types", {}) + or name in getattr(self, "_current_loop_vars", set()) + or getattr(self, "_block_map_binding_visible", False) + ): + return True + + top_index_by_node = getattr(self, "_top_level_index_by_ast_node", None) + top_index_by_span = getattr(self, "_top_level_index_by_source_span", None) + map_decl_index = getattr(self, "_top_level_map_decl_index", None) + if top_index_by_node is None or top_index_by_span is None: + top_index_by_node = {} + top_index_by_span = {} + ambiguous_spans = set() + map_decl_index = None + for index, statement in enumerate(self.ctx.ast.body): + for candidate in self._walk_ast(statement): + top_index_by_node[id(candidate)] = index + loc = getattr(candidate, "loc", None) + if loc is not None: + span = ( + type(candidate), loc.file, loc.line, loc.col, loc.end_col, + ) + previous = top_index_by_span.get(span) + if previous is None: + top_index_by_span[span] = index + elif previous != index: + ambiguous_spans.add(span) + if ( + map_decl_index is None + and isinstance(statement, VarDecl) + and statement.name == name + ): + map_decl_index = index + for span in ambiguous_spans: + top_index_by_span.pop(span, None) + self._top_level_index_by_ast_node = top_index_by_node + self._top_level_index_by_source_span = top_index_by_span + self._top_level_map_decl_index = map_decl_index + + if map_decl_index is None: + return False + call_index = top_index_by_node.get(id(node)) + if call_index is None: + # Setup-time rewrites (notably request.security timeframe input + # substitution) may clone an expression. Those clones retain the + # original source span, so recover their containing top-level + # statement from the unmodified AST instead of treating every + # synthetic node as if all globals were already visible. + loc = getattr(node, "loc", None) + if loc is not None: + call_index = top_index_by_span.get( + (type(node), loc.file, loc.line, loc.col, loc.end_col) + ) + if call_index is None: + # With no identity or source ancestry there is no evidence that a + # later global binding was visible. Fail closed to the built-in + # namespace; synthetic lexical receivers must preserve provenance. + return False + return map_decl_index < call_index + + def _map_call_arg_nodes( + self, + method: str, + node: FuncCall, + *, + functional: bool, + allow_keywords: bool, + ) -> list: + """Validate and bind one established map-call form. + + Map lowerings index directly into their argument arrays. Before this + guard, malformed calls therefore either reached a raw ``IndexError`` + or, for keyword-only ``map.*`` functional calls, silently lowered to + ``0``. Keep routing policy separate from signature validation: only + the typed-map UDF-parameter method lane currently accepts canonical + keywords; all other lanes retain their established positional-only + semantics and fail closed when keywords are supplied. + """ + if method == "new": + param_names: list[str] = [] + else: + method_params = MAP_METHOD_KWARGS.get(method) + if method_params is None: + return list(node.args) + param_names = (["id", *method_params] if functional + else list(method_params)) + + signature = f"map.{method}({', '.join(param_names)})" + unknown = sorted(set(node.kwargs) - set(param_names)) + if unknown: + name = unknown[0] + hint = f"Expected {signature}." + if method == "put_all" and name == "from": + hint = f"Use 'id2=' for the source map. Expected {signature}." + self._codegen_error( + node, + f"map.{method}: unknown keyword argument '{name}'", + hint=hint, + ) + + if len(node.args) > len(param_names): + self._codegen_error( + node, + ( + f"map.{method}: too many positional arguments " + f"(expected {len(param_names)}, got {len(node.args)})" + ), + hint=f"Expected {signature}.", + ) + + for name in param_names[:len(node.args)]: + if name in node.kwargs: + self._codegen_error( + node, + ( + f"map.{method}: argument '{name}' passed both " + "positionally and by keyword" + ), + hint=f"Expected {signature}; bind each argument once.", + ) + + bound: list = [None] * len(param_names) + for index, arg in enumerate(node.args): + bound[index] = arg + for name, value in node.kwargs.items(): + bound[param_names.index(name)] = value + + missing = [ + name for name, value in zip(param_names, bound) if value is None + ] + if missing: + self._codegen_error( + node, + f"map.{method}: missing required argument '{missing[0]}'", + hint=f"Expected {signature}.", + ) + + if node.kwargs and not allow_keywords: + form = "functional" if functional else "receiver-method" + self._codegen_error( + node, + ( + f"map.{method}: keyword arguments are not supported " + f"for this {form} call form" + ), + hint=f"Use the established positional form: {signature}.", + ) + + return bound def _map_param_method_expr( self, map_expr: str, method: str, arg_nodes: list, spec: TypeSpec, @@ -407,13 +568,40 @@ def _visit_func_call(self, node: FuncCall) -> str: obj = callee.object if isinstance(obj, MemberAccess): root = obj.object - if not (isinstance(root, Identifier) and root.name in ( + root_is_builtin_namespace = ( + isinstance(root, Identifier) + and root.name in ( "strategy", "ta", "math", "input", "str", "timeframe", "syminfo", "barstate", "color", "request", "runtime", "array", "matrix", "map", - )): + ) + ) + if ( + root_is_builtin_namespace + and root.name == "map" + and self._map_identifier_is_visible_binding(node) + ): + root_is_builtin_namespace = False + if not root_is_builtin_namespace: recv_spec = self._type_spec_from_expr(obj) recv = self._visit_expr(obj) meth = callee.member + if ( + recv_spec is not None + and recv_spec.kind == "map" + and meth in MAP_METHODS + ): + arg_nodes = self._map_call_arg_nodes( + meth, + node, + functional=False, + allow_keywords=False, + ) + return self._map_method_expr( + recv, + meth, + [self._visit_expr(arg) for arg in arg_nodes], + recv_spec, + ) 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) @@ -423,8 +611,6 @@ def _visit_func_call(self, node: FuncCall) -> str: 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) args = ", ".join(raw_args) if meth == "delete": meth = "_delete_" @@ -432,7 +618,11 @@ def _visit_func_call(self, node: FuncCall) -> str: # obj.method() where obj is a user var/param — not namespace::method if isinstance(obj, Identifier): oname = obj.name - if ( + receiver_is_visible = ( + oname != "map" + or self._map_identifier_is_visible_binding(node) + ) + if receiver_is_visible and ( oname in self._var_names or oname in self._current_func_param_types or oname in self._current_loop_vars @@ -476,8 +666,11 @@ def _visit_func_call(self, node: FuncCall) -> str: and meth_raw in MAP_METHODS ): m = self._collection_receiver_expr(oname) - arg_nodes = self._map_param_method_arg_nodes( - meth_raw, node + arg_nodes = self._map_call_arg_nodes( + meth_raw, + node, + functional=False, + allow_keywords=True, ) return self._map_param_method_expr( m, meth_raw, arg_nodes, param_spec @@ -493,7 +686,13 @@ def _visit_func_call(self, node: FuncCall) -> str: and meth_raw in MAP_METHODS ): m = self._collection_receiver_expr(oname) - margs = [self._visit_expr(a) for a in node.args] + arg_nodes = self._map_call_arg_nodes( + meth_raw, + node, + functional=False, + allow_keywords=False, + ) + margs = [self._visit_expr(a) for a in arg_nodes] return self._map_method_expr(m, meth_raw, margs, recv_spec) if ( recv_spec is not None @@ -642,17 +841,40 @@ def _visit_func_call(self, node: FuncCall) -> str: if namespace is not None else None ) + if ( + namespace == "map" + and not self._map_identifier_is_visible_binding(node) + ): + # A later global named ``map`` is already present in the flat + # collection registry, but is not yet a lexical receiver here. + namespace_spec = 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] + arg_nodes = self._map_call_arg_nodes( + func_name, + node, + functional=False, + allow_keywords=False, + ) + args = [self._visit_expr(a) for a in arg_nodes] return self._map_method_expr(m, func_name, args, namespace_spec) # map.method(m, args...) — functional form if namespace == "map": + if func_name == "new" or func_name in MAP_METHODS: + # This dispatch point comes after lexical receiver routing, so + # a parameter/local/global named ``map`` retains precedence + # over the built-in namespace. + self._map_call_arg_nodes( + func_name, + node, + functional=True, + allow_keywords=False, + ) if func_name == "new": spec = self._type_spec_from_expr(node) or TypeSpec.map(TypeSpec.primitive("string"), TypeSpec.primitive("float")) return f"{self._type_spec_to_cpp(spec)}()" diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index f4ab67c..59320cd 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -200,6 +200,15 @@ def _visit_stmt(self, node: ASTNode, lines: list[str], indent: int) -> None: node.name, activation_spec, ) + if ( + node.name == "map" + and getattr(self, "_block_map_visibility_depth", 0) > 0 + ): + # The declaration RHS was emitted against the outer lexical + # state. Only subsequent statements in this exact block see + # the new ``map`` value; block pop restores sibling/outside + # visibility. + self._block_map_binding_visible = True elif isinstance(node, Assignment): self._visit_assignment(node, lines, pad) elif isinstance(node, TupleAssign): @@ -828,10 +837,21 @@ def _push_block_var_remap(self, owner): block can reuse the same raw name without either pre-shadowing earlier statements or controlling dispatch in the other branch. """ + previous_map_visible = getattr( + self, "_block_map_binding_visible", False + ) + previous_map_depth = getattr(self, "_block_map_visibility_depth", 0) + self._block_map_visibility_depth = previous_map_depth + 1 + renames = self._block_var_renames.get(id(owner)) collection_specs = self._block_collection_types.get(id(owner)) if not renames and collection_specs is None: - return _NO_BLOCK_REMAP + return ( + _NO_BLOCK_REMAP, + None, + previous_map_visible, + previous_map_depth, + ) saved_remap = self._active_var_remap if renames: self._active_var_remap = {**saved_remap, **renames} @@ -856,22 +876,35 @@ def _push_block_var_remap(self, owner): 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 + return ( + saved_remap, + saved_collections, + previous_map_visible, + previous_map_depth, + ) def _pop_block_var_remap(self, saved) -> None: - 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 + ( + saved_remap, + saved_collections, + previous_map_visible, + previous_map_depth, + ) = saved + try: + if saved_remap is not _NO_BLOCK_REMAP: + 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 + finally: + self._block_map_binding_visible = previous_map_visible + self._block_map_visibility_depth = previous_map_depth def _visit_block_statements(self, body: list, lines: list[str], indent: int) -> None: diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index 1c7659a..f9864c7 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -14,7 +14,7 @@ import re from .lexer import Token, TokenType -from .errors import SourceLocation +from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation from .ast_nodes import ( ASTNode, Program, StrategyDecl, ImportStmt, @@ -1228,6 +1228,24 @@ def _parse_call_args(self) -> tuple[list, dict]: and self._peek().type == TokenType.EQUALS and self._peek(2).type != TokenType.EQUALS): key_tok = self._advance() + if key_tok.value in kwargs: + raise CompileError( + [ + Diagnostic( + level=Level.ERROR, + phase=Phase.PARSER, + location=self._loc(key_tok), + message=( + "duplicate keyword argument " + f"'{key_tok.value}'" + ), + hint=( + f"Remove one '{key_tok.value}=' binding; " + "a keyword argument may be specified only once." + ), + ) + ] + ) self._advance() # consume = val = self._parse_expression() kwargs[key_tok.value] = val diff --git a/tests/test_map_call_diagnostics.py b/tests/test_map_call_diagnostics.py new file mode 100644 index 0000000..d5718bf --- /dev/null +++ b/tests/test_map_call_diagnostics.py @@ -0,0 +1,342 @@ +"""Fail-closed diagnostics for duplicate kwargs and malformed map calls.""" + +from __future__ import annotations + +from hashlib import sha256 + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError, Phase +from tests.test_map_param_methods import _compile_and_run + + +def test_duplicate_keyword_argument_is_a_parser_compile_error(): + source = '''//@version=6 +strategy("duplicate", overlay=true, overlay=false) +''' + + with pytest.raises(CompileError) as caught: + transpile(source, filename="duplicate-keyword.pine") + + assert str(caught.value) == ( + "duplicate-keyword.pine:2:37: duplicate keyword argument 'overlay'" + ) + assert len(caught.value.diagnostics) == 1 + diagnostic = caught.value.diagnostics[0] + assert diagnostic.phase is Phase.PARSER + assert diagnostic.location.line == 2 + assert diagnostic.location.col == 37 + assert diagnostic.hint == ( + "Remove one 'overlay=' binding; " + "a keyword argument may be specified only once." + ) + + +_VALID_EXISTING_FORMS = '''//@version=6 +strategy("valid map calls") +var map global_values = map.new() +var map global_source = map.new() + +typed_keywords(map target, map source) => + target.put(key="a", value=1) + first = target.get(key="a") + present = target.contains(key="a") + removed = target.remove(key="a") + target.put_all(id2=source) + target.clear() + first + removed + (present ? 1 : 0) + +map.put(global_source, "source", 2) +map.put(global_values, "global", 3) +global_read = map.get(global_values, "global") +global_has = map.contains(global_values, "global") +global_size = map.size(global_values) +global_keys = map.keys(global_values) +global_vals = map.values(global_values) +global_copy = map.copy(global_values) +map.put_all(global_values, global_source) +local_read = global_values.get("global") +observed = typed_keywords(global_values, global_source) +''' + + +def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): + cpp = transpile(_VALID_EXISTING_FORMS) + assert sha256(cpp.encode()).hexdigest() == ( + "c39c46c7044b95e61648bdbd2029df583ff1f06cb6f65cbc44b6dcb4adc7d846" + ) + + +@pytest.mark.parametrize( + ("source", "expected_hash"), + [ + ( + '''//@version=6 +strategy("map parameter shadow") +probe(map map) => + map.get("key") +observed = probe(map.new()) +''', + "518fd354e1a1365d7957f71f69c7356b4cb0704f796c01cdef5e40f8a44d5aed", + ), + ( + '''//@version=6 +strategy("local map shadow") +probe() => + map = map.new() + map.put("key", 1) + map.get("key") +observed = probe() +''', + "cfc158b435ea5a9eadaf5c054bea35efa24d6dced16491d61fb5897544b24d3f", + ), + ( + '''//@version=6 +strategy("global map shadow") +map map = map.new() +map.put("key", 1) +observed = map.get("key") +''', + "b1b548564467c9161250d34f1f0eeeccda8c27bccb7881f3860452797fde6aff", + ), + ], +) +def test_lexical_identifier_named_map_remains_a_receiver( + source: str, + expected_hash: str, +): + cpp = transpile(source) + assert sha256(cpp.encode()).hexdigest() == expected_hash + + +_LATER_GLOBAL_MAP_SOURCE = '''//@version=6 +strategy("source order map") +target = map.new() +map.put(target, "before", 3) +before = map.get(target, "before") +map map = map.new() +map.put("after", 4) +after = map.get("after") +observed = before * 10 + after +''' + + +_SECURITY_TF_CLONED_MAP_CALL_SOURCE = '''//@version=6 +strategy("synthetic map source order") +lookup = map.new() +map.put(lookup, "D", "D") +tfInput = input.string("D", "TF") +tf = map.get(lookup, tfInput) +foreign = request.security(syminfo.tickerid, tf, close) +map map = map.new() +observed = foreign +''' + + +def test_security_timeframe_clone_keeps_preceding_map_namespace_source_order(): + cpp = transpile( + _SECURITY_TF_CLONED_MAP_CALL_SOURCE, + filename="synthetic-map-source-order.pine", + ) + + assert sha256(cpp.encode()).hexdigest() == ( + "a996c517c7365761c42184cc803d8db66620e6f2517523b4f30a69a64be9c786" + ) + + +def test_security_timeframe_clone_keeps_visible_map_receiver_source_order(): + source = '''//@version=6 +strategy("synthetic lexical map source order") +map map = map.new() +map.put("D", "D") +tfInput = input.string("D", "TF") +tf = map.get(tfInput) +foreign = request.security(syminfo.tickerid, tf, close) +observed = foreign +''' + + cpp = transpile(source, filename="synthetic-lexical-map-source-order.pine") + + assert sha256(cpp.encode()).hexdigest() == ( + "27a8da4b19d535a5fffe75acb1618ff878f3e2544ef93cc76289402af3f7dbdc" + ) + + +_NESTED_LEXICAL_MAP_ROOT_SOURCE = '''//@version=6 +strategy("nested map root") +type Holder + map values +Holder map = Holder.new(map.new()) +map.values.put("key", 7) +observed = map.values.get("key") +''' + + +_BLOCK_LOCAL_MAP_ISOLATION_SOURCE = '''//@version=6 +strategy("block map isolation") +target = map.new() +observed = 0 +if bar_index >= 0 + map = map.new() + map.put("local", 9) + observed := map.get("local") +if bar_index >= 0 + map.put(target, "sibling", 2) + observed := observed * 10 + map.get(target, "sibling") +map.put(target, "outside", 3) +observed := observed * 10 + map.get(target, "outside") +''' + + +_FOR_BLOCK_LOCAL_MAP_SOURCE = '''//@version=6 +strategy("for block map") +observed = 0 +for i = 0 to 0 + map = map.new() + map.put("key", 8) + observed := map.get("key") +''' + + +@pytest.mark.parametrize( + ("source", "expected_hash", "expected_value"), + [ + ( + _LATER_GLOBAL_MAP_SOURCE, + "a840df36bd97818e0dfc74d9fac484b8ca194d979ee0e120c091f9ad53c9ea32", + 34.0, + ), + ( + _NESTED_LEXICAL_MAP_ROOT_SOURCE, + "f8ae41ee93773775d6e1008f9890593271c71815a14681ed047787644bea2a48", + 7.0, + ), + ( + _BLOCK_LOCAL_MAP_ISOLATION_SOURCE, + "5324c737eafc26029b64442ea245e7ef576c37353f18716c265f7e1fcb5b427c", + 923.0, + ), + ( + _FOR_BLOCK_LOCAL_MAP_SOURCE, + "a68ce941c1d1d32ccbbc99dc81720c44e645844ab136eace2dcfa2f0bffa7e5e", + 8.0, + ), + ], +) +def test_map_namespace_resolution_respects_source_order_and_lexical_roots( + source: str, + expected_hash: str, + expected_value: float, +): + cpp = transpile(source) + assert sha256(cpp.encode()).hexdigest() == expected_hash + 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)) == expected_value + + +def _typed_method_source(expression: str) -> str: + return f'''//@version=6 +strategy("invalid typed map call") +probe(map target, map source) => + {expression} +observed = probe(map.new(), map.new()) +''' + + +def _functional_source(expression: str) -> str: + return f'''//@version=6 +strategy("invalid functional map call") +target = map.new() +source = map.new() +observed = {expression} +''' + + +def _local_method_source(expression: str) -> str: + return f'''//@version=6 +strategy("invalid local map call") +probe() => + target = map.new() + {expression} +observed = probe() +''' + + +@pytest.mark.parametrize( + ("source", "message"), + [ + ( + _typed_method_source("target.put_all(from=source)"), + "map.put_all: unknown keyword argument 'from'", + ), + ( + _functional_source("map.put_all(id=target, from=source)"), + "map.put_all: unknown keyword argument 'from'", + ), + ( + _typed_method_source('target.get(foo="x")'), + "map.get: unknown keyword argument 'foo'", + ), + ( + _typed_method_source("target.get()"), + "map.get: missing required argument 'key'", + ), + ( + _functional_source("target.get()"), + "map.get: missing required argument 'key'", + ), + ( + _local_method_source('target.get("x", "y")'), + "map.get: too many positional arguments (expected 1, got 2)", + ), + ( + _typed_method_source('target.get("x", key="y")'), + "map.get: argument 'key' passed both positionally and by keyword", + ), + ( + _typed_method_source('target.get("x", "y")'), + "map.get: too many positional arguments (expected 1, got 2)", + ), + ( + _functional_source('map.get(id=target, key="x")'), + ( + "map.get: keyword arguments are not supported " + "for this functional call form" + ), + ), + ( + _functional_source('map.get(target, key="x")'), + ( + "map.get: keyword arguments are not supported " + "for this functional call form" + ), + ), + ( + _functional_source('target.get(key="x")'), + ( + "map.get: keyword arguments are not supported " + "for this receiver-method call form" + ), + ), + ], +) +def test_invalid_map_bindings_raise_stable_compile_errors(source: str, message: str): + with pytest.raises(CompileError) as caught: + transpile(source, filename="invalid-map-call.pine") + + assert caught.value.diagnostics[0].phase is Phase.CODEGEN + assert caught.value.diagnostics[0].message == message + assert str(caught.value).startswith("invalid-map-call.pine:") diff --git a/tests/test_map_terminal_returns.py b/tests/test_map_terminal_returns.py index 44c90de..6405d68 100644 --- a/tests/test_map_terminal_returns.py +++ b/tests/test_map_terminal_returns.py @@ -7,6 +7,7 @@ import pytest from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError from tests._compile import compile_cpp @@ -393,31 +394,32 @@ def test_nonterminal_map_output_stays_byte_identical(): ) -def test_keyword_only_map_void_residuals_stay_byte_identical(): +def test_keyword_only_namespace_map_residuals_fail_closed(): expected = { - "clear": "85c4764c212b3199040e8ef49df06a45102bc53c62aa15b0ddf22c7ace8b823a", - "put_all": "cfa61dfc7aee7ef5ed01e5f26d2f641f8392d07d2246825d8647f5ed954c5218", + "clear": "keyword arguments are not supported", + "put_all": "unknown keyword argument 'from'", } for name, source in _KEYWORD_RESIDUAL_SOURCES.items(): - cpp = transpile(source) - assert sha256(cpp.encode()).hexdigest() == expected[name] + with pytest.raises(CompileError, match=expected[name]): + transpile(source) -def test_mixed_keyword_namespace_map_residuals_keep_failure_parity(): +def test_mixed_keyword_map_residuals_raise_compile_errors(): for source in _MIXED_KEYWORD_RESIDUAL_SOURCES.values(): - with pytest.raises(IndexError, match="list index out of range"): + with pytest.raises(CompileError, match=r"map\."): transpile(source) -def test_invalid_terminal_map_shapes_stay_byte_identical(): - for name, (expression, expected) in _INVALID_TERMINAL_EXPRESSIONS.items(): +def test_invalid_terminal_map_shapes_raise_compile_errors(): + for name, (expression, _old_output_hash) in _INVALID_TERMINAL_EXPRESSIONS.items(): source = f"""//@version=6 strategy("Map invalid terminal {name}") f(map m) => {expression} observed = f(map.new()) """ - assert sha256(transpile(source).encode()).hexdigest() == expected + with pytest.raises(CompileError, match=r"map\."): + transpile(source) def test_unresolved_parameter_keeps_lexical_precedence_over_global_map():