diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 606535f..870e844 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -1497,6 +1497,20 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: inferred_param_specs[i] = spec self._func_param_type_specs[node.name] = inferred_param_specs + # Capture direct terminal map-call metadata before leaving the lexical + # function scope. Local map variables and typed map parameters are no + # longer resolvable from the symbol table after ``exit_scope()``. Keep + # this narrow to map terminals so general/nonterminal inference and + # generated output remain unchanged. + terminal_ret_expr = self._direct_terminal_return_expr(node) + terminal_map_return = self._terminal_map_call_return( + terminal_ret_expr, + { + name: spec + for name, spec in zip(node.params, inferred_param_specs) + }, + ) + self._symbols.exit_scope() # Detect if function returns a tuple (last stmt is TupleLiteral) @@ -1522,13 +1536,7 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: # to propagate UDT typing onto the caller's local. Probe: # data/validation/udt-method-probe-20-udt-return-from-func. if node.body: - last_stmt = node.body[-1] - ret_expr = None - if isinstance(last_stmt, ExprStmt): - ret_expr = last_stmt.expr - elif not isinstance(last_stmt, (TupleLiteral,)): - # last_stmt is itself an expression node (single-expr funcs) - ret_expr = last_stmt if hasattr(last_stmt, "loc") else None + ret_expr = terminal_ret_expr udt_ret = self._udt_name_from_ctor(ret_expr) if ret_expr is not None else None if udt_ret is None: # Drawing-handle returns wrapped in an if-statement terminal @@ -1548,6 +1556,11 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: if ret_spec is not None and ret_spec.kind == "array": self._func_return_type_specs[node.name] = ret_spec + if terminal_map_return is not None: + body_type, terminal_spec = terminal_map_return + if terminal_spec is not None: + self._func_return_type_specs[node.name] = terminal_spec + # Store return type self._func_return_types[node.name] = body_type diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 22d6794..c64b44a 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -1165,7 +1165,6 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: # Forward UDT-return inference (set in _visit_FuncDef) so codegen can # emit the struct return type. Probe: udt-method-probe-20. udt_ret = self._func_udt_return_types.get(func_name) - ret_spec = getattr(self, "_func_return_type_specs", {}).get(func_name) # Per-param TypeSpec: declared hints are authoritative; for untyped # params, infer from the call-site argument's type_spec (so an untyped # ``s`` used as a string, or a UDT passed by value, emits correctly). @@ -1178,6 +1177,32 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: if param_specs[i] is None and i < len(arg_specs): param_specs[i] = arg_specs[i] existing = [fi for fi in self._func_infos if fi.name == func_name] + + # A direct terminal map call on an untyped parameter cannot be typed + # while the function definition is first visited: its map TypeSpec is + # learned only here, from the call-site argument. Re-run only the + # terminal-map classifier with those established parameter specs; do + # not re-analyze the body or participate in general return inference. + effective_param_specs = list(param_specs) + if existing and existing[0].param_type_specs: + for i, spec in enumerate(existing[0].param_type_specs): + if i < len(effective_param_specs) and spec is not None: + effective_param_specs[i] = spec + terminal_map_return = self._terminal_map_call_return( + self._direct_terminal_return_expr(func_def), + { + name: spec + for name, spec in zip(func_def.params, effective_param_specs) + }, + ) + ret_spec = getattr(self, "_func_return_type_specs", {}).get(func_name) + if terminal_map_return is not None: + return_type, inferred_ret_spec = terminal_map_return + self._func_return_types[func_name] = return_type + if inferred_ret_spec is not None: + self._func_return_type_specs[func_name] = inferred_ret_spec + ret_spec = inferred_ret_spec + if not existing: fi = FuncInfo( name=func_name, @@ -1194,7 +1219,10 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: else: # Update with better type info if available fi = existing[0] - if fi.return_type in (PineType.UNKNOWN, PineType.VOID) and return_type not in (PineType.UNKNOWN, PineType.VOID): + if terminal_map_return is not None: + fi.return_type = return_type + fi.return_type_spec = ret_spec + elif fi.return_type in (PineType.UNKNOWN, PineType.VOID) and return_type not in (PineType.UNKNOWN, PineType.VOID): fi.return_type = return_type for i, pt in enumerate(param_types): if i < len(fi.param_types) and fi.param_types[i] == PineType.UNKNOWN: diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index 5e9ce72..f826e2a 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -89,9 +89,9 @@ class FuncInfo: # untyped ``s`` used as a string emits as ``std::string s``. param_type_specs: list = field(default_factory=list) # ``TypeSpec`` of the function's return value when it is a collection the - # coarse ``return_type`` (PineType) cannot represent — today this covers - # array-returning functions (``buildPDLevels() => array.from(...)`` -> - # ``std::vector``). UDT / drawing-handle returns use + # coarse ``return_type`` (PineType) cannot represent — arrays and maps + # (e.g. ``buildPDLevels() => array.from(...)`` -> ``std::vector``). + # UDT / drawing-handle returns use # ``udt_return_type``; tuple returns use ``returns_tuple``. return_type_spec: Any = None @@ -231,7 +231,7 @@ class AnalyzerContext: # Per-function var_members + series_vars (used when emitting per-function call-site variants): func_var_members: dict = field(default_factory=dict) func_series_vars: dict = field(default_factory=dict) - # Per-function array-return TypeSpec (see FuncInfo.return_type_spec). + # Per-function collection-return TypeSpec (see FuncInfo.return_type_spec). func_return_type_specs: dict = field(default_factory=dict) # var_name -> UDT type name for variables instantiated via TypeName.new(...) udt_var_types: dict[str, str] = field(default_factory=dict) diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index 05388ad..3271fa8 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -40,8 +40,8 @@ from typing import Any from ..ast_nodes import ( - ASTNode, BoolLiteral, FuncCall, Identifier, MemberAccess, - NaLiteral, NumberLiteral, StringLiteral, UnaryOp, + ASTNode, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, + NaLiteral, NumberLiteral, StringLiteral, TupleLiteral, UnaryOp, ) from ..symbols import PineType, TypeSpec @@ -257,6 +257,145 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: return (self._udt_field_type_specs.get(owner.name) or {}).get(value.member) return None + @staticmethod + def _direct_terminal_return_expr(func_def) -> ASTNode | None: + """Return a UDF's direct terminal expression, if it has one.""" + if not func_def.body: + return None + last_stmt = func_def.body[-1] + if isinstance(last_stmt, ExprStmt): + return last_stmt.expr + if not isinstance(last_stmt, TupleLiteral) and hasattr(last_stmt, "loc"): + return last_stmt + return None + + def _terminal_map_call_return( + self, + value: ASTNode | None, + parameter_specs: dict[str, TypeSpec | None] | None = None, + ) -> tuple[PineType, TypeSpec | None] | None: + """Return metadata for a direct terminal map call in a UDF. + + This deliberately does not participate in general expression or + declaration inference. During function-definition analysis it uses + the active lexical scope for locals and typed parameters; for untyped + parameters it may be called again with call-site ``parameter_specs``. + Arbitrary-expression receivers stay out of scope because their map + method routing is not supported yet. + """ + if not isinstance(value, FuncCall) or not isinstance( + value.callee, MemberAccess + ): + return None + + callee = value.callee + method = callee.member + receiver = None + if isinstance(callee.object, Identifier) and callee.object.name == "map": + # Functional form: map.method(id, ...). Keyword-only receiver + # routing remains a separate residual, so require the established + # positional receiver here. + functional_arity = { + "clear": 1, + "keys": 1, + "values": 1, + "copy": 1, + "put_all": 2, + "get": 2, + "remove": 2, + "put": 3, + }.get(method) + if ( + functional_arity is not None + and len(value.args) == functional_arity + and not value.kwargs + ): + receiver = value.args[0] + elif isinstance(callee.object, Identifier): + # Global/local/typed-parameter method forms only. Do not infer an + # arbitrary expression receiver that codegen cannot route. + is_parameter = ( + parameter_specs is not None + and callee.object.name in parameter_specs + ) + valid_method_shape = False + expected_arity = { + "clear": 0, + "keys": 0, + "values": 0, + "copy": 0, + "put_all": 1, + "get": 1, + "remove": 1, + "put": 2, + }.get(method) + if expected_arity is not None: + valid_method_shape = ( + len(value.args) == expected_arity and not value.kwargs + ) + if is_parameter and not valid_method_shape: + if method == "put_all": + valid_method_shape = ( + not value.args and set(value.kwargs) == {"id2"} + ) + elif method in ("get", "remove"): + valid_method_shape = ( + not value.args and set(value.kwargs) == {"key"} + ) + elif method == "put": + valid_method_shape = ( + ( + len(value.args) == 1 + and set(value.kwargs) == {"value"} + ) + or ( + not value.args + and set(value.kwargs) == {"key", "value"} + ) + ) + if valid_method_shape: + receiver = callee.object + if receiver is None: + return None + + recv_spec = None + if ( + isinstance(receiver, Identifier) + and parameter_specs is not None + and receiver.name in parameter_specs + ): + recv_spec = parameter_specs.get(receiver.name) + # An unresolved parameter still shadows any same-named global. + if recv_spec is None: + return None + else: + recv_spec = self._type_spec_from_expr(receiver) + if recv_spec is None or recv_spec.kind != "map": + return None + + if method in ("clear", "put_all"): + return PineType.VOID, None + if method == "keys": + return ( + PineType.VOID, + TypeSpec.array(recv_spec.key or TypeSpec.primitive("string")), + ) + if method == "values": + return ( + PineType.VOID, + TypeSpec.array(recv_spec.value or TypeSpec.primitive("float")), + ) + if method == "copy": + return PineType.VOID, recv_spec + if ( + method in ("put", "get", "remove") + and recv_spec.value is not None + and recv_spec.value.kind == "primitive" + and recv_spec.value.name == "string" + ): + return PineType.STRING, None + return None + @staticmethod def _pine_type_to_spec(pine_type: PineType) -> TypeSpec: mapping = { diff --git a/pineforge_codegen/codegen/drawing.py b/pineforge_codegen/codegen/drawing.py index 6c9c19b..cad80ff 100644 --- a/pineforge_codegen/codegen/drawing.py +++ b/pineforge_codegen/codegen/drawing.py @@ -22,6 +22,8 @@ from ..ast_nodes import FuncCall, Identifier, MemberAccess from .tables import DRAWING_TYPE_TO_CPP, DRAWING_ARENA, ARRAY_VOID_METHODS as _ARRAY_VOID_METHODS +_MAP_VOID_METHODS = frozenset({"clear", "put_all"}) + # --------------------------------------------------------------------------- # Canonical Pine v6 constructor param-name lists (positional order). # Only the GEOMETRY names are consumed downstream; every other (visual) name is @@ -478,11 +480,11 @@ def _call_is_void(self, node) -> bool: expression and so cannot be used as a function's return value / RHS. Covers drawing setters/delete/visual-noop (delegated to - ``_drawing_call_is_void``) AND the Pine array MUTATOR methods whose C++ + ``_drawing_call_is_void``), the Pine array MUTATOR methods whose C++ lowering is void / an iterator (``array.push/insert/clear/set/fill/ - sort/reverse/concat/unshift``) — a function ending in - ``array.clear(x)`` returns void in Pine, so it must emit as a statement - with a default return, not ``return x.clear();``. + sort/reverse/concat/unshift``), and terminal map ``clear``/``put_all`` + calls. A function ending in one of these calls must emit it as a + statement with a default return, never ``return ;``. """ if self._drawing_call_is_void(node): return True @@ -490,14 +492,43 @@ def _call_is_void(self, node) -> bool: return False method = node.callee.member _fn, ns = self._resolve_callee(node.callee) - if method not in _ARRAY_VOID_METHODS: + if method in _ARRAY_VOID_METHODS: + # array.(arr, ...) namespace form OR arr.(...) + # method form on a std::vector receiver. + if ns == "array": + return True + recv_spec = self._type_spec_from_expr(node.callee.object) + if recv_spec is not None and recv_spec.kind == "array": + return True + + if method not in _MAP_VOID_METHODS: return False - # array.(arr, ...) namespace form OR arr.(...) method - # form on a std::vector receiver. - if ns == "array": - return True - recv_spec = self._type_spec_from_expr(node.callee.object) - return recv_spec is not None and recv_spec.kind == "array" + # Preserve the unsupported arbitrary-map-receiver boundary. Namespace + # functional calls are established; method calls must use a named + # global/local/parameter receiver. + if ns == "map": + # The established namespace-functional form has a positional map + # receiver. Keyword-only receiver routing remains unsupported + # and must retain its pre-KI-48g fallback output. + expected_arity = 1 if method == "clear" else 2 + return len(node.args) == expected_arity and not node.kwargs + if not isinstance(node.callee.object, Identifier): + return False + name = node.callee.object.name + param_spec = getattr(self, "_current_func_param_specs", {}).get(name) + if method == "clear": + if node.args or node.kwargs: + return False + elif param_spec is not None and param_spec.kind == "map": + if not ( + (len(node.args) == 1 and not node.kwargs) + or (not node.args and set(node.kwargs) == {"id2"}) + ): + return False + elif len(node.args) != 1 or node.kwargs: + return False + recv_spec = param_spec or self._type_spec_from_expr(node.callee.object) + return recv_spec is not None and recv_spec.kind == "map" # ------------------------------------------------------------------ # _uses_drawing detection + arena caps diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 29a38fa..da2f95f 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -1048,8 +1048,8 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # struct (Line/Box/Label/Linefill), not the unknown lowercase name. ret_type = DRAWING_TYPE_TO_CPP.get(fi.udt_return_type, fi.udt_return_type) elif getattr(fi, "return_type_spec", None) is not None: - # Array-returning function (``f() => array.from(...)``) — emit the - # vector type from the inferred element TypeSpec. + # Collection-returning function (array or map terminal) — emit the + # concrete C++ collection type from its inferred TypeSpec. ret_type = self._type_spec_to_cpp(fi.return_type_spec) else: ret_type = PINE_TYPE_TO_CPP.get(fi.return_type, "double") diff --git a/tests/test_map_terminal_returns.py b/tests/test_map_terminal_returns.py new file mode 100644 index 0000000..44c90de --- /dev/null +++ b/tests/test_map_terminal_returns.py @@ -0,0 +1,427 @@ +"""Terminal UDF return inference for established map call forms.""" + +from __future__ import annotations + +from hashlib import sha256 + +import pytest + +from pineforge_codegen import transpile +from tests._compile import compile_cpp + + +_TERMINAL_SOURCE = """//@version=6 +strategy("Map terminal returns") +var global_int = map.new() +var global_string = map.new() + +clear_functional(map values) => + map.clear(values) + +clear_global() => + global_int.clear() + +clear_local() => + values = map.new() + values.clear() + +clear_param(map values) => + values.clear() + +put_all_functional(map target, map source) => + map.put_all(target, source) + +put_all_global(map source) => + global_int.put_all(source) + +put_all_local(map source) => + target = map.new() + target.put_all(source) + +put_all_param(map target, map source) => + target.put_all(id2=source) + +clear_inferred(inferred_int_map) => + map.clear(inferred_int_map) + +put_all_inferred(inferred_target, inferred_source) => + inferred_target.put_all(inferred_source) + +keys_functional(map values) => + map.keys(values) + +keys_global() => + global_int.keys() + +keys_local() => + values = map.new() + values.keys() + +keys_param(map values) => + values.keys() + +values_functional(map values) => + map.values(values) + +values_global() => + global_int.values() + +values_local() => + values = map.new() + values.values() + +values_param(map values) => + values.values() + +copy_functional(map values) => + map.copy(values) + +copy_global() => + global_int.copy() + +copy_local() => + values = map.new() + values.copy() + +copy_param(map values) => + values.copy() + +keys_inferred(inferred_int_map) => + map.keys(inferred_int_map) + +values_inferred(inferred_int_map) => + inferred_int_map.values() + +copy_inferred(inferred_int_map) => + inferred_int_map.copy() + +get_functional(map string_map) => + map.get(string_map, "key") + +get_global() => + global_string.get("key") + +get_local() => + local_strings = map.new() + local_strings.get("key") + +get_param(map string_map) => + string_map.get(key="key") + +put_functional(map string_map) => + map.put(string_map, "key", "value") + +put_global() => + global_string.put("key", "value") + +put_local() => + local_strings = map.new() + local_strings.put("key", "value") + +put_param(map string_map) => + string_map.put(key="key", value="value") + +remove_functional(map string_map) => + map.remove(string_map, "key") + +remove_global() => + global_string.remove("key") + +remove_local() => + local_strings = map.new() + local_strings.remove("key") + +remove_param(map string_map) => + string_map.remove(key="key") + +get_inferred(inferred_string_map) => + map.get(inferred_string_map, "key") + +put_inferred(inferred_string_map) => + inferred_string_map.put("key", "value") + +remove_inferred(inferred_string_map) => + inferred_string_map.remove("key") + +int_values = map.new() +int_source = map.new() +string_values = map.new() +map.put(int_source, "source", 8) +map.put(global_string, "key", "global") +clear_functional(int_values) +clear_global() +clear_local() +clear_param(int_values) +put_all_functional(int_values, int_source) +put_all_global(int_source) +put_all_local(int_source) +put_all_param(int_values, int_source) +clear_inferred(int_values) +put_all_inferred(int_values, int_source) +keys_a = keys_functional(int_values) +keys_b = keys_global() +keys_c = keys_local() +keys_d = keys_param(int_values) +values_a = values_functional(int_values) +values_b = values_global() +values_c = values_local() +values_d = values_param(int_values) +copy_a = copy_functional(int_values) +copy_b = copy_global() +copy_c = copy_local() +copy_d = copy_param(int_values) +keys_e = keys_inferred(int_values) +values_e = values_inferred(int_values) +copy_e = copy_inferred(int_values) +string_a = get_functional(string_values) +string_b = get_global() +string_c = get_local() +string_d = get_param(string_values) +string_e = put_functional(string_values) +string_f = put_global() +string_g = put_local() +string_h = put_param(string_values) +string_i = remove_functional(string_values) +string_j = remove_global() +string_k = remove_local() +string_l = remove_param(string_values) +string_m = get_inferred(string_values) +string_n = put_inferred(string_values) +string_o = remove_inferred(string_values) +""" + + +_NONTERMINAL_SOURCE = """//@version=6 +strategy("Map nonterminal identity") +var global_values = map.new() +map.put(global_values, "top", "functional") +top_functional = map.get(global_values, "top") +global_values.put("global", "method") +top_method = global_values.get("global") + +probe(map values) => + first = values.get("x") + values.put("x", "y") + removed = values.remove("x") + key_list = values.keys() + value_list = values.values() + copied = values.copy() + values.clear() + local_values = map.new() + local_values.put("local", "value") + local_read = local_values.get("local") + first == removed and local_read == "value" + +observed = probe(global_values) +""" + +_KEYWORD_RESIDUAL_SOURCES = { + "clear": """//@version=6 +strategy("Map keyword residual clear") +f(map m) => + map.clear(id=m) +observed = f(map.new()) +""", + "put_all": """//@version=6 +strategy("Map keyword residual put all") +f(map target, map source) => + map.put_all(id=target, from=source) +observed = f(map.new(), map.new()) +""", +} + +_MIXED_KEYWORD_RESIDUAL_SOURCES = { + "put_all": """//@version=6 +strategy("Map mixed keyword residual put all") +f(map target, map source) => + map.put_all(target, from=source) +observed = f(map.new(), map.new()) +""", + "get": """//@version=6 +strategy("Map mixed keyword residual get") +f(map values) => + map.get(values, key="x") +observed = f(map.new()) +""", + "global_put_all": """//@version=6 +strategy("Map named method keyword residual put all") +var target = map.new() +f(map source) => + target.put_all(id2=source) +observed = f(map.new()) +""", + "global_get": """//@version=6 +strategy("Map named method keyword residual get") +var values = map.new() +f() => + values.get(key="x") +observed = f() +""", +} + +_INVALID_TERMINAL_EXPRESSIONS = { + "method_keys_extra_pos": ( + "m.keys(1)", + "cab58e9c278f14d10dab6de813c3ba4fd91b82d7c28ba0291f4bacfabf1d72b4", + ), + "method_keys_unknown_kw": ( + "m.keys(extra=1)", + "fdfce1ceb98aa0c7c5d22aa526ea32d7ac475e406c7a50a337dbebc29d489e50", + ), + "method_get_unknown_kw": ( + 'm.get("x", extra=1)', + "5ca1c09c43fc99b29b734c45b19f4fba49452948ef1fced680dd77143649f3c3", + ), + "namespace_keys_extra_pos": ( + "map.keys(m, 1)", + "fdfce1ceb98aa0c7c5d22aa526ea32d7ac475e406c7a50a337dbebc29d489e50", + ), + "namespace_get_extra_pos": ( + 'map.get(m, "x", "y")', + "aadff040891ea04bf99b2e8ec7babfeaa55514f1b4d8634be425d4ba6ea85271", + ), + "method_clear_extra_pos": ( + "m.clear(1)", + "bb649b247b7f1b4507f3a40cc06d3337b70c74d0d8ea30139ba7bfeaf86357e8", + ), + "namespace_clear_extra_pos": ( + "map.clear(m, 1)", + "e9143f691ddbf2c4094e331e2e9163ec1b2fdd704b1a6b501701c8bf02e51e48", + ), +} + +_SHADOWED_UNRESOLVED_PARAM_SOURCE = """//@version=6 +strategy("Map param shadows global get") +var values = map.new() +f(values) => + values.get("x") +observed = f(1) +""" + + +def _body(cpp: str, signature: str) -> str: + start = cpp.index(signature) + end = cpp.index("\n }", start) + len("\n }") + return cpp[start:end] + + +def test_void_map_terminals_emit_statements_with_default_return(): + cpp = transpile(_TERMINAL_SOURCE) + for name in ( + "clear_functional", + "clear_global", + "clear_local", + "clear_param", + "put_all_functional", + "put_all_global", + "put_all_local", + "put_all_param", + "clear_inferred", + "put_all_inferred", + ): + body = _body(cpp, f"double {name}(") + assert "return 0.0;" in body + udf_return_lines = [ + line.strip() for line in body.splitlines() + if line.startswith(" return ") + ] + assert udf_return_lines == ["return 0.0;"] + assert "m.clear" not in cpp + + +def test_collection_map_terminals_infer_exact_types_across_forms(): + cpp = transpile(_TERMINAL_SOURCE) + for name in ( + "keys_functional", "keys_global", "keys_local", "keys_param", + "keys_inferred", + ): + assert f"std::vector {name}(" in cpp + for name in ( + "values_functional", + "values_global", + "values_local", + "values_param", + "values_inferred", + ): + assert f"std::vector {name}(" in cpp + for name in ( + "copy_functional", "copy_global", "copy_local", "copy_param", + "copy_inferred", + ): + assert f"std::unordered_map {name}(" in cpp + + for name in ("keys_a", "keys_b", "keys_c", "keys_d", "keys_e"): + assert f"std::vector {name};" in cpp + for name in ("values_a", "values_b", "values_c", "values_d", "values_e"): + assert f"std::vector {name};" in cpp + for name in ("copy_a", "copy_b", "copy_c", "copy_d", "copy_e"): + assert f"std::unordered_map {name};" in cpp + + +def test_string_map_terminals_infer_string_across_forms(): + cpp = transpile(_TERMINAL_SOURCE) + for name in ( + "get_functional", + "get_global", + "get_local", + "get_param", + "put_functional", + "put_global", + "put_local", + "put_param", + "remove_functional", + "remove_global", + "remove_local", + "remove_param", + "get_inferred", + "put_inferred", + "remove_inferred", + ): + assert f"std::string {name}(" in cpp + for suffix in "abcdefghijklmno": + assert f'std::string string_{suffix} = std::string("");' in cpp + + +def test_map_terminal_return_forms_compile(): + compile_cpp(transpile(_TERMINAL_SOURCE), label="map_terminal_returns") + + +def test_nonterminal_map_output_stays_byte_identical(): + cpp = transpile(_NONTERMINAL_SOURCE) + assert sha256(cpp.encode()).hexdigest() == ( + "d207acf7c4f53761cebd1ef6637380131f140ca5566c00d9436303af80a1cf40" + ) + + +def test_keyword_only_map_void_residuals_stay_byte_identical(): + expected = { + "clear": "85c4764c212b3199040e8ef49df06a45102bc53c62aa15b0ddf22c7ace8b823a", + "put_all": "cfa61dfc7aee7ef5ed01e5f26d2f641f8392d07d2246825d8647f5ed954c5218", + } + for name, source in _KEYWORD_RESIDUAL_SOURCES.items(): + cpp = transpile(source) + assert sha256(cpp.encode()).hexdigest() == expected[name] + + +def test_mixed_keyword_namespace_map_residuals_keep_failure_parity(): + for source in _MIXED_KEYWORD_RESIDUAL_SOURCES.values(): + with pytest.raises(IndexError, match="list index out of range"): + transpile(source) + + +def test_invalid_terminal_map_shapes_stay_byte_identical(): + for name, (expression, expected) 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 + + +def test_unresolved_parameter_keeps_lexical_precedence_over_global_map(): + cpp = transpile(_SHADOWED_UNRESOLVED_PARAM_SOURCE) + assert sha256(cpp.encode()).hexdigest() == ( + "331d8306eb6d8868e5a25542cbacedcab77b10a13c18daf8dd523a3e283e196d" + )