From 526512867d34afbd47c5563d7d28976a9bbd5913 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 30 Jun 2026 01:54:39 +0800 Subject: [PATCH] fix(codegen): harden validation edge cases --- pineforge_codegen/analyzer/base.py | 165 ++++++- pineforge_codegen/analyzer/call_handlers.py | 56 ++- pineforge_codegen/analyzer/contracts.py | 23 + pineforge_codegen/analyzer/types.py | 22 + pineforge_codegen/codegen/__init__.py | 4 + pineforge_codegen/codegen/base.py | 56 ++- pineforge_codegen/codegen/drawing.py | 60 ++- pineforge_codegen/codegen/emit_top.py | 135 ++++- pineforge_codegen/codegen/input.py | 14 + pineforge_codegen/codegen/security.py | 87 +++- pineforge_codegen/codegen/tables.py | 22 + pineforge_codegen/codegen/types.py | 31 +- pineforge_codegen/codegen/visit_call.py | 18 +- pineforge_codegen/codegen/visit_expr.py | 41 ++ pineforge_codegen/codegen/visit_stmt.py | 17 +- pineforge_codegen/parser.py | 72 ++- pineforge_codegen/support_checker.py | 4 +- tests/test_codegen_validation_fixes.py | 520 ++++++++++++++++++++ tests/test_official_surface.py | 5 +- 19 files changed, 1270 insertions(+), 82 deletions(-) create mode 100644 tests/test_codegen_validation_fixes.py diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 671bdaa..4fe4e4c 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -141,6 +141,7 @@ def __init__(self, ast: Program, filename: str = "") -> None: # expression (``=> Sample.new(...)`` or last stmt ``Sample.new(...)``). # Probe: data/validation/udt-method-probe-20-udt-return-from-func. self._func_udt_return_types: dict[str, str] = {} + self._func_return_type_specs: dict[str, "TypeSpec"] = {} # Per-function var_members and series_vars (for call-site cloning) self._func_var_members: dict[str, list] = {} # func_name -> [(name, PineType, init_str)] self._func_series_vars: dict[str, set] = {} # func_name -> set[str] @@ -232,6 +233,11 @@ def analyze(self) -> AnalyzerContext: # can call g_csK with isolated state. self._propagate_call_site_counts() + # Reject (loudly) a request.security whose timeframe is a UDF parameter + # called with multiple distinct literal timeframes — a single evaluator + # cannot serve them and per-callsite specialization is not yet wired. + self._check_mixed_callsite_security_tf() + # Keep only truly pure global expressions for request.security rebinding. # Globals later reassigned with := become series/stateful variables and # must not be rebound to their declaration-time initializer. @@ -273,6 +279,7 @@ def analyze(self) -> AnalyzerContext: global_mutable_infos=mutable_global_infos, func_var_members=self._func_var_members, func_series_vars=self._func_series_vars, + func_return_type_specs=dict(self._func_return_type_specs), udt_var_types=dict(self._udt_var_types), collection_types=dict(self._collection_types), udt_field_type_specs=dict(self._udt_field_type_specs), @@ -418,6 +425,98 @@ def _find_calls(node, known_funcs: set[str]) -> set[str]: self._func_call_site_count[sub] = count changed = True + # ------------------------------------------------------------------ + # Mixed-callsite UDF timeframe-param security rejection. + # + # A ``request.security`` whose ``timeframe`` is a parameter of its + # containing UDF maps to ONE evaluator regardless of how many times the + # UDF is called. When the UDF is called from >= 2 sites with DISTINCT + # literal timeframes, a single evaluator cannot faithfully serve them + # all and the resolver would silently collapse onto the chart timeframe + # (``input_tf_``). Per-callsite evaluator specialization (cloning the + # evaluator + UDF) is the correct fix but is not wired in this iteration, + # so we reject deterministically instead of emitting wrong semantics. + # ------------------------------------------------------------------ + def _check_mixed_callsite_security_tf(self) -> None: + sec_calls = getattr(self, "_security_calls", None) + if not sec_calls: + return + # Build user-function definitions lookup once. + func_defs: dict[str, FuncDef] = {} + for stmt in self._ast.body: + if isinstance(stmt, FuncDef): + func_defs[stmt.name] = stmt + + for sec in sec_calls: + containing = getattr(sec, "containing_func", "") or "" + if not containing: + continue + tf_node = getattr(sec, "timeframe", None) + if not isinstance(tf_node, Identifier): + continue + param_name = tf_node.name + fdef = func_defs.get(containing) + if fdef is None or param_name not in fdef.params: + continue + pidx = fdef.params.index(param_name) + literals: set[str] = set() + found_call = False + for call in self._iter_user_func_calls(containing): + found_call = True + arg = call.args[pidx] if pidx < len(call.args) else None + lit = self._callsite_tf_literal_value(arg) + if lit is not None: + literals.add(lit) + if not found_call: + continue # dead code — evaluator result never read + if len(literals) >= 2: + self._error( + "request.security timeframe parameter '" + + param_name + + "' of function '" + + containing + + "' is called with multiple distinct literal timeframes (" + + ", ".join(sorted(literals)) + + "). A single request.security evaluator cannot serve " + "them all and would silently collapse onto the chart " + "timeframe. Pass a single timeframe, or inline a separate " + "request.security call at each call site.", + tf_node.loc, + ) + + def _iter_user_func_calls(self, func_name: str): + """Yield every ``func_name(...)`` call anywhere in the AST (top-level + and nested inside function bodies).""" + def _walk(node): + if node is None: + return + if (isinstance(node, FuncCall) and isinstance(node.callee, Identifier) + and node.callee.name == func_name): + yield node + for attr_val in vars(node).values(): + if isinstance(attr_val, list): + for item in attr_val: + if hasattr(item, "__dict__"): + yield from _walk(item) + elif attr_val is not None and hasattr(attr_val, "__dict__"): + yield from _walk(attr_val) + yield from _walk(self._ast) + + def _callsite_tf_literal_value(self, arg) -> str | None: + """Resolve a UDF call-site timeframe argument to a literal string + value when it is statically known: a string literal, or a known + constant / input-backed variable whose stored value is a string. + Returns None for anything that is not a compile-time string.""" + if isinstance(arg, StringLiteral): + return arg.value + if isinstance(arg, Identifier): + sym = self._symbols.resolve(arg.name) + if sym is not None and getattr(sym, "const_value", None) is not None: + val = sym.const_value + if isinstance(val, str): + return val + return None + def _is_static_expression(self, node: ASTNode | None) -> bool: if node is None: return True @@ -535,19 +634,28 @@ def _visit_ImportStmt(self, node: ImportStmt) -> PineType: # ------------------------------------------------------------------ def _udt_name_from_ctor(self, value: ASTNode) -> str | None: - """If value is ``TypeName.new(...)`` for a user-defined type, return TypeName.""" + """If value is ``TypeName.new(...)`` for a user-defined type OR a + drawing handle (``label.new``/``line.new``/``box.new``/``linefill.new``), + return the type name.""" if not isinstance(value, FuncCall): return None cal = value.callee if not isinstance(cal, MemberAccess) or not isinstance(cal.object, Identifier): return None owner = cal.object.name - if owner not in self._udt_fields: - return None m = cal.member - if m == "new" or (isinstance(m, str) and m.startswith("new")): + if not (m == "new" or (isinstance(m, str) and m.startswith("new"))): + return None + # Drawing-objects-as-data: label.new(...)/line.new(...)/... return a + # handle of the self-type. These are not in _udt_fields (they are not + # user UDTs) but must still be recognised so a function whose body ends + # in label.new(...) emits a ``Label`` (not ``double``) return type. + from .types import _DRAWING_TYPE_NAMES + if owner in _DRAWING_TYPE_NAMES: return owner - return None + if owner not in self._udt_fields: + return None + return owner def _visit_VarDecl(self, node: VarDecl) -> PineType: # Infer type from the value expression @@ -732,6 +840,20 @@ def _visit_TupleAssign(self, node: TupleAssign) -> PineType: setattr(sym, "is_static_series", True) self._symbols.define(sym) + # Track global-scope tuple-assign targets (e.g. + # ``[pdH, pdL] = request.security(...)``) as class members so user + # functions / later references resolve — mirroring _visit_VarDecl. + # Without this the names are never declared and the C++ errors with + # "use of undeclared identifier". + if (self._global_scope + and self._symbols.current_scope.name == "global" + and name not in self._series_vars): + self._global_var_decls.append((name, PineType.FLOAT)) + self._global_expr_map[name] = node.value + self._record_global_binding_stmt( + name, PineType.FLOAT, False, decl_node=node, + ) + return val_type # ------------------------------------------------------------------ @@ -745,18 +867,28 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: # Enter function scope self._symbols.enter_scope(f"func_{node.name}") - # Define parameters (type unknown until called) + # Define parameters. The type is UNKNOWN until inferred from a call + # site, BUT a declared type hint (``string tf``, ``pivot hi``, ``line[] arr``) + # is authoritative — record it as the symbol's ``type_spec`` / ``pine_type`` + # so (a) the param emits with the right C++ type and (b) callers passing + # this param into another function can infer that function's param type + # (e.g. ``getLineStyle(styleStr)`` where ``styleStr`` is a ``string`` param). loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1) - for param in node.params: + param_hints = (node.annotations or {}).get("param_type_hints", []) + for i, param in enumerate(node.params): + hint = param_hints[i] if i < len(param_hints) else None + pspec = self._type_spec_from_hint(hint) if hint else None + ptype = self._type_hint_to_pine(hint) if hint else PineType.UNKNOWN sym = Symbol( name=param, - pine_type=PineType.UNKNOWN, + pine_type=ptype, is_series=False, is_var=False, is_const=False, const_value=None, scope=f"func_{node.name}", loc=loc, + type_spec=pspec, ) self._symbols.define(sym) @@ -809,6 +941,14 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: udt_ret = self._udt_name_from_ctor(ret_expr) if ret_expr is not None else None if udt_ret is not None: self._func_udt_return_types[node.name] = udt_ret + # Array-return inference: a function whose body ends in + # ``array.from(...)`` / ``array.new(...)`` / a UDT method + # returning an array returns a ``std::vector<...>``. The coarse + # PineType return can't represent this, so carry the TypeSpec. + if ret_expr is not None: + ret_spec = self._type_spec_from_expr(ret_expr) + if ret_spec is not None and ret_spec.kind == "array": + self._func_return_type_specs[node.name] = ret_spec # Store return type self._func_return_types[node.name] = body_type @@ -880,12 +1020,14 @@ def _visit_MethodDef(self, node) -> PineType: loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1) param_hints = (node.annotations or {}).get("param_type_hints", []) param_types: list[PineType] = [] + param_specs: list = [] for i, p in enumerate(node.params): udt_self = node.type_name if i == 0 else None hint = param_hints[i] if i < len(param_hints) else None ptype = self._type_hint_to_pine(hint) if hint else PineType.FLOAT pspec = self._type_spec_from_hint(hint) if hint else None param_types.append(ptype) + param_specs.append(pspec) self._symbols.define(Symbol( name=p, pine_type=ptype, is_series=False, is_var=False, is_const=False, const_value=None, @@ -939,6 +1081,7 @@ def _visit_MethodDef(self, node) -> PineType: returns_tuple=returns_tuple, tuple_element_count=tuple_element_count, param_defaults=param_defaults, + param_type_specs=param_specs, ) self._func_infos.append(fi) return PineType.VOID @@ -1151,6 +1294,12 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: if isinstance(obj, Identifier) and obj.name == "str": for arg in node.args: self._visit(arg) + # Most str.* return a string, but a few don't: + # str.tonumber -> float, str.length -> int + if member == "tonumber": + return PineType.FLOAT + if member == "length": + return PineType.INT return PineType.STRING # request.* calls diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 5116d67..4833836 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -66,7 +66,7 @@ from typing import Any from ..ast_nodes import ( - ASTNode, BoolLiteral, FuncCall, Identifier, MemberAccess, + ASTNode, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, NumberLiteral, StringLiteral, TupleLiteral, ) from ..symbols import PineType @@ -293,6 +293,10 @@ def _handle_request_call(self, func_name: str, node: FuncCall) -> PineType: lookahead_node = all_args[4] if len(all_args) > 4 else None mutable_globals = tuple(sorted(self._collect_security_mutable_globals(expr_node))) + # Capture the user function (if any) whose body contains this call, + # so the codegen can resolve a parameter ``tf`` via the call sites. + scope_name = self._symbols.current_scope.name + containing_func = scope_name[5:] if scope_name.startswith("func_") else "" self._security_calls.append(SecurityCallInfo( sec_id=sec_id, timeframe=tf_node, @@ -304,6 +308,7 @@ def _handle_request_call(self, func_name: str, node: FuncCall) -> PineType: ta_range=security_ta_range, depends_on_mutable_globals=bool(mutable_globals), mutable_globals=mutable_globals, + containing_func=containing_func, )) return PineType.FLOAT @@ -783,14 +788,28 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: # For now, use the cached return type from initial analysis return_type = self._func_return_types.get(func_name, PineType.FLOAT) - # If the return type was UNKNOWN or VOID, infer from param types + # If the return type was UNKNOWN or VOID, infer it ONLY when the body + # is a single bare identifier that returns a parameter directly + # (``f(s) => s``). Inferring from params for arbitrary bodies misfires + # when a function merely HAS a string param but returns something else + # (e.g. ``getLineStyle(s) => switch s ... => line.style_solid`` or a + # body ending in ``label.new(...)``). Other cases rely on the cached + # body type plus udt_return_type / tuple inference. if return_type in (PineType.UNKNOWN, PineType.VOID): - if any(t == PineType.STRING for t in param_types): - return_type = PineType.STRING - elif any(t == PineType.FLOAT for t in param_types): - return_type = PineType.FLOAT - elif any(t == PineType.INT for t in param_types): - return_type = PineType.INT + if (func_def.is_single_expr and func_def.body + and isinstance(func_def.body[0], ExprStmt) + and isinstance(func_def.body[0].expr, Identifier)): + ret_name = func_def.body[0].expr.name + for idx, pname in enumerate(func_def.params): + if pname == ret_name and idx < len(param_types): + pt = param_types[idx] + if pt == PineType.STRING: + return_type = PineType.STRING + elif pt == PineType.INT: + return_type = PineType.INT + elif pt == PineType.FLOAT: + return_type = PineType.FLOAT + break # If this function has series params, ensure bar-field arguments # passed at the call site are registered as series_bar_fields so that @@ -869,6 +888,15 @@ def _subst_params(arg: str, pmap: dict[str, str]) -> str: # 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). + param_specs = self._param_type_specs_from_def(func_def) + arg_specs = [self._type_spec_from_expr(arg) for arg in node.args] + for i in range(len(param_specs)): + 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] if not existing: fi = FuncInfo( @@ -879,6 +907,8 @@ def _subst_params(arg: str, pmap: dict[str, str]) -> str: returns_tuple=is_tuple, tuple_element_count=tuple_count, udt_return_type=udt_ret, + param_type_specs=param_specs, + return_type_spec=ret_spec, ) self._func_infos.append(fi) else: @@ -889,7 +919,17 @@ def _subst_params(arg: str, pmap: dict[str, str]) -> str: for i, pt in enumerate(param_types): if i < len(fi.param_types) and fi.param_types[i] == PineType.UNKNOWN: fi.param_types[i] = pt + # Merge per-param TypeSpecs: keep declared hints (authoritative), + # fill untyped slots from this call site if still unknown. + if not fi.param_type_specs: + fi.param_type_specs = list(param_specs) + else: + for i in range(len(param_specs)): + if i < len(fi.param_type_specs) and fi.param_type_specs[i] is None: + fi.param_type_specs[i] = param_specs[i] if fi.udt_return_type is None and udt_ret is not None: fi.udt_return_type = udt_ret + if fi.return_type_spec is None and ret_spec is not None: + fi.return_type_spec = ret_spec return return_type diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index 8e3de1f..9752cee 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -65,6 +65,21 @@ class FuncInfo: # ``Sample s = build_sample(...)`` then ``s.score()`` dispatches # correctly. Probe: data/validation/udt-method-probe-20-udt-return-from-func. udt_return_type: str | None = None + # Parallel to ``node.params``; each entry is a ``TypeSpec`` (or ``None``) + # carrying UDT / drawing-handle / precise-scalar typing that the coarse + # ``param_types`` (PineType) cannot represent. Populated from the + # function's declared parameter type hints (authoritative) and, for + # untyped params, from the call-site argument type. The codegen prefers + # this over ``param_types`` when emitting each parameter's C++ type so a + # ``pivot hi`` parameter emits as ``pivot hi`` (not ``double hi``) and an + # 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 + # ``udt_return_type``; tuple returns use ``returns_tuple``. + return_type_spec: Any = None @dataclass @@ -107,6 +122,12 @@ class SecurityCallInfo: depends_on_mutable_globals: bool = False mutable_globals: tuple[str, ...] = () is_lower_tf_array: bool = False + # Name of the user function whose body contains this call ("" at global + # scope). A ``request.security(sym, tf, ...)`` whose ``tf`` is that + # function's parameter cannot be resolved at class scope (the security + # evaluator is a class method, not the function body) — the codegen resolves + # such a param-tf from the function's call sites instead. + containing_func: str = "" @dataclass @@ -145,6 +166,8 @@ 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). + 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) # var_name -> structured collection/UDT type metadata diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index 2096e16..05388ad 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -115,6 +115,19 @@ def _type_spec_from_hint(self, hint: str | None) -> TypeSpec | None: return TypeSpec.udt(hint) return None + def _param_type_specs_from_def(self, func_def) -> list: + """Per-parameter ``TypeSpec`` (or ``None``) from a function's DECLARED + parameter type hints — the authoritative source for typed params + (``pivot hi``, ``string tf``, ``line[] arr``). Untyped params are + ``None`` here so regular-function call-site inference can fill them. + """ + hints = (getattr(func_def, "annotations", None) or {}).get("param_type_hints", []) + specs: list = [] + for i in range(len(func_def.params)): + hint = hints[i] if i < len(hints) else None + specs.append(self._type_spec_from_hint(hint) if hint else None) + return specs + def _template_args_from_call(self, node: FuncCall) -> list[str]: callee = node.callee ann = getattr(callee, "annotations", None) or {} @@ -229,6 +242,15 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: sym = self._symbols.resolve(value.name) if sym is not None and sym.type_spec is not None: return sym.type_spec + if isinstance(value, FuncCall): + # User-function return spec (e.g. an array-returning + # ``buildPDLevels() => array.from(...)``), so a caller's + # ``allLevels = buildPDLevels()`` infers an array TypeSpec. + cal = value.callee + fname = cal.member if isinstance(cal, MemberAccess) else ( + cal.name if isinstance(cal, Identifier) else None) + if fname and fname in getattr(self, "_func_return_type_specs", {}): + return self._func_return_type_specs[fname] if isinstance(value, MemberAccess): owner = self._type_spec_from_expr(value.object) if owner is not None and owner.kind == "udt" and owner.name: diff --git a/pineforge_codegen/codegen/__init__.py b/pineforge_codegen/codegen/__init__.py index a629e5c..9deca9f 100644 --- a/pineforge_codegen/codegen/__init__.py +++ b/pineforge_codegen/codegen/__init__.py @@ -41,6 +41,8 @@ SYMINFO_MEMBER_MAP, COLOR_CONST_MAP, ARRAY_METHODS, + ARRAY_DRAWING_NEW_CTORS, + ARRAY_NEW_CTORS, MAP_METHODS, MATRIX_METHODS, MATRIX_METHOD_KWARGS, @@ -69,6 +71,8 @@ "SYMINFO_MEMBER_MAP", "COLOR_CONST_MAP", "ARRAY_METHODS", + "ARRAY_DRAWING_NEW_CTORS", + "ARRAY_NEW_CTORS", "MAP_METHODS", "MATRIX_METHODS", "MATRIX_METHOD_KWARGS", diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index f995e5b..e2bc854 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -184,6 +184,14 @@ def __init__(self, ctx: AnalyzerContext) -> None: # Set of var/series member names that belong to user functions (need cloning) self._func_var_members_set: set[str] = set() self._precalc_loop_active: bool = False + # Names of ``var`` members that live in a FUNCTION scope (not global). + # These are initialized once-per-function-variant on first call (a + # function-local static equivalent), NOT in the constructor / on_bar + # preamble. See ``_emit_func_var_init_block``. + self._func_local_var_names: set[str] = set() + for _vlist in ctx.func_var_members.values(): + for _n, _, _ in _vlist: + self._func_local_var_names.add(_n) # Build per-function var/series name lists for cloning. # For each function with call-site variants, collect ALL function-scoped @@ -397,15 +405,11 @@ def __init__(self, ctx: AnalyzerContext) -> None: lookahead_node = item.get("lookahead_node") ta_range = item.get("ta_range") - tf_str = None - if isinstance(tf_node, StringLiteral): - tf_str = tf_node.value - elif (isinstance(tf_node, Identifier) - and tf_node.name in self._known_vars - and tf_node.name not in self._input_backed_vars): - val = self._known_vars[tf_node.name] - if isinstance(val, str): - tf_str = val + # Resolve the timeframe: a literal/const/global gives a static tf; + # a function-parameter tf is resolved from the call sites (the + # evaluator is a class method, so the param is not in scope there). + tf_str, tf_expr = self._resolve_security_tf( + tf_node, item.get("containing_func", "")) is_lookahead_on = False if lookahead_node is not None: @@ -456,6 +460,7 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._security_eval_info.append({ "sec_id": sec_id, "tf": tf_str, + "tf_expr": tf_expr, "tf_node": tf_node, "gaps_on": is_gaps_on, "lookahead_on": is_lookahead_on, @@ -911,6 +916,12 @@ def generate(self) -> str: continue spec = field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name) cpp_type = self._type_spec_to_cpp(spec) + # Pine ``int`` is 64-bit (it routinely holds UNIX-ms timestamps + # and large bar indices); emit UDT int fields as ``int64_t`` so a + # field initialised from ``time``/``current_bar_.timestamp`` does + # not truncate / narrow-init. + if cpp_type == "int": + cpp_type = "int64_t" if f.default: default = self._visit_expr(f.default) else: @@ -1206,6 +1217,15 @@ def generate(self) -> str: lines.append(f" {self._type_spec_to_cpp(self._array_spec_for_name(vname))} {cloned_safe};") elif vname in self._map_vars: lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(vname))} {cloned_safe};") + elif vname in self._udt_var_types: + # Drawing handle / UDT var clone must match the + # original's type (Line/Label/Box/), not the + # coarse PineType default (double) — otherwise the + # clone can't hold the handle and drawing access on + # it reads a garbage / na id. + udt_t = self._udt_var_types[vname] + handle_cpp = DRAWING_TYPE_TO_CPP.get(udt_t, udt_t) + lines.append(f" {handle_cpp} {cloned_safe} = {handle_cpp}{{}};") else: lines.append(f" {cpp_type} {cloned_safe};") found = True @@ -1233,6 +1253,24 @@ def generate(self) -> str: if self.ctx.var_members: lines.append(" bool _var_initialized = false;") + # 9a. Per-function-variant ``var`` init flags. A function-scoped + # ``var`` (Pine "init once" semantics) is a function-local static: + # its initializer runs on the FIRST call to that function variant + # (with the first bar's values the function actually sees) and the + # result persists for the strategy's lifetime. Each clone (cs0, + # cs1, ...) is an independent instance with its own flag. + # ``func_var_members`` is keyed by the plain Pine function name + # (``fi.name``), so this matches both plain UDFs and UDT methods. + for fi in self.ctx.func_infos: + if fi.name not in self.ctx.func_var_members: + continue + total_cs = self.ctx.func_call_site_counts.get(fi.name, 0) + if total_cs > 0: + for cs_idx in range(total_cs): + lines.append(f" bool _fvinit_{self._func_safe_name(fi.name)}_cs{cs_idx} = false;") + else: + lines.append(f" bool _fvinit_{self._func_safe_name(fi.name)} = false;") + # 9b. _ta_initialized_ flag for runtime TA re-sizing (first on_bar only). if self.ctx.ta_call_sites: lines.append(" bool _ta_initialized_ = false;") diff --git a/pineforge_codegen/codegen/drawing.py b/pineforge_codegen/codegen/drawing.py index 7d8674a..474deb2 100644 --- a/pineforge_codegen/codegen/drawing.py +++ b/pineforge_codegen/codegen/drawing.py @@ -20,8 +20,7 @@ from __future__ import annotations from ..ast_nodes import FuncCall, Identifier, MemberAccess -from .tables import DRAWING_TYPE_TO_CPP, DRAWING_ARENA - +from .tables import DRAWING_TYPE_TO_CPP, DRAWING_ARENA, ARRAY_VOID_METHODS as _ARRAY_VOID_METHODS # --------------------------------------------------------------------------- # Canonical Pine v6 constructor param-name lists (positional order). @@ -443,6 +442,63 @@ def _drawing_call_return_cpp(self, node: FuncCall) -> str | None: return None return _DRAWING_GETTER_RET.get((dtype, method)) + def _drawing_call_is_void(self, node) -> bool: + """True if ``node`` is a drawing call that lowers to a VOID C++ + expression (setters, ``delete``, visual-noop) — i.e. it cannot be used + as a return value / RHS. Constructors (``new``), ``copy`` and getters + return a value. Used by UDF last-expression lowering so a function whose + final statement is e.g. ``label.set_text(lb, ...)`` emits it as a + statement instead of ``return pf_label_set_text(...);``. + """ + if not isinstance(node, FuncCall) or not isinstance(node.callee, MemberAccess): + return False + method = node.callee.member + _fn, ns = self._resolve_callee(node.callee) + from .tables import DRAWING_NS + dtype = None + if ns in DRAWING_NS: + dtype = ns + else: + recv_spec = self._type_spec_from_expr(node.callee.object) + if (recv_spec is not None and recv_spec.kind == "udt" + and recv_spec.name in DRAWING_TYPE_TO_CPP): + dtype = recv_spec.name + if dtype is None: + return False + if method in ("new", "copy"): + return False # constructors / copy return a handle + if (dtype, method) in _DRAWING_GETTER_RET: + return False # getters return a scalar + geometry, noop = DRAWING_METHODS_BY_TYPE.get(dtype, (frozenset(), frozenset())) + # any other recognised drawing method is a setter / delete / visual-noop + return method in geometry or method in noop + + def _call_is_void(self, node) -> bool: + """True if ``node`` is a call that lowers to a VOID (or non-scalar) C++ + 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++ + 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();``. + """ + if self._drawing_call_is_void(node): + return True + if not isinstance(node, FuncCall) or not isinstance(node.callee, MemberAccess): + return False + method = node.callee.member + _fn, ns = self._resolve_callee(node.callee) + if method not in _ARRAY_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" + # ------------------------------------------------------------------ # _uses_drawing detection + arena caps # ------------------------------------------------------------------ diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 192506f..6109d8e 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -246,6 +246,13 @@ def _emit_constructor(self, lines: list[str]) -> None: safe = self._safe_name(name) if name in self._array_vars or name in self._map_vars: continue + # Function-scoped ``var`` members are initialized once-per-variant + # on first call (see _emit_func_var_init_block); exclude them from + # the constructor so they are not double-initialized and so their + # (possibly bar-dependent) initializer is lowered in the function's + # own scope (with its active var remap for clones). + if name in getattr(self, "_func_local_var_names", ()): + continue # UDT-typed var members (``var SDZone z = na``) default-construct to # na via the struct's in-class ``__pf_na = true``; a ctor init like # ``z(na())`` would not type-match the struct member. @@ -374,17 +381,15 @@ def _emit_constructor(self, lines: list[str]) -> None: lines.append(" security_eval_states_.clear();") for info in self._security_eval_info: tf = info.get("tf") - tf_expr = None + tf_expr = info.get("tf_expr") if tf: tf_expr = f'"{tf}"' - else: - tf_node = info.get("tf_node") - if tf_node is not None: - if isinstance(tf_node, Identifier) and tf_node.name in self._timeframe_period_vars: - tf_expr = "script_tf_" - else: - raw_tf_expr = self._visit_expr(tf_node) - tf_expr = self._runtime_ctor_arg_for_reset(raw_tf_expr) or raw_tf_expr + elif not tf_expr: + # No static tf and no resolvable runtime expression — fall + # back to the chart timeframe so registration still compiles + # (e.g. a request.security inside a dead-code UDF, or one + # whose tf is a function param called with mixed timeframes). + tf_expr = "input_tf_" if tf_expr: la = "true" if info["lookahead_on"] else "false" go = "true" if info.get("gaps_on") else "false" @@ -457,6 +462,10 @@ def _emit_on_bar(self, lines: list[str]) -> None: if self.ctx.var_members: lines.append(" if (!_var_initialized) {") for name, ptype, init_expr in self.ctx.var_members: + # Function-scoped ``var`` members are init'd once-per-variant + # on first call (see _emit_func_var_init_block), not here. + if name in getattr(self, "_func_local_var_names", ()): + continue safe = self._safe_name(name) if name in self._array_vars: for stmt in self.ctx.ast.body: @@ -557,6 +566,7 @@ def _emit_on_bar(self, lines: list[str]) -> None: default_cpp = self._visit_expr(default) if default is not None else "0" title = self._get_input_title(stmt.value, var_name=stmt.name) getter = self._input_type_to_getter(func_name_i, namespace_i) + default_cpp = self._coerce_string_input_default(getter, default_cpp) cpp_val = f'{getter}("{title}", {default_cpp})' static_vars.append(f"{safe} = {cpp_val};") @@ -756,6 +766,24 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # This param uses history access (e.g. src[1]) — pass as Series cpp_t = "const Series&" self._current_func_series_params.add(p) + elif i < len(getattr(fi, "param_type_specs", [])) and fi.param_type_specs[i] is not None: + # Precise per-param TypeSpec (declared hint or call-site inference): + # ``pivot hi`` -> ``pivot&``, ``line ln`` -> ``Line&``, an untyped + # ``s`` used as a string -> ``std::string``. UDT / collection + # params pass by reference (Pine UDTs/arrays are reference types, + # so mutations propagate and member access compiles). + spec = fi.param_type_specs[i] + cpp_t = self._type_spec_to_cpp(spec) + if spec.kind == "udt": + self._udt_param_udt[p] = spec.name + self._udt_param_udt[self._safe_name(p)] = spec.name + cpp_t = f"{cpp_t}&" + elif spec.kind in ("array", "map"): + elem = spec.element if spec.kind == "array" else spec.value + if elem is not None and elem.kind == "udt": + self._udt_param_udt[p] = elem.name + self._udt_param_udt[self._safe_name(p)] = elem.name + cpp_t = f"{cpp_t}&" elif i < len(fi.param_types): pt = fi.param_types[i] cpp_t = PINE_TYPE_TO_CPP.get(pt, "double") @@ -777,6 +805,10 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # A function returning a drawing handle must emit the C++ handle # 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. + ret_type = self._type_spec_to_cpp(fi.return_type_spec) else: ret_type = PINE_TYPE_TO_CPP.get(fi.return_type, "double") @@ -805,17 +837,34 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No lines.append(f" {ret_type} {func_name}({', '.join(param_strs)}) {{") + # Function-scoped ``var`` one-shot initializer: Pine ``var`` inside a + # function is a function-local static — its initializer runs exactly + # once, on the first call to THIS variant, with the first bar's values + # the function actually sees. Each clone (cs0/cs1/…) is independent. + self._emit_func_var_init_block(fi, call_site_idx, lines) + emitted_return = False if node.is_single_expr and node.body: expr = node.body[0].expr if isinstance(node.body[0], ExprStmt) else None - if expr: + if expr and self._call_is_void(expr): + # void setter as the sole body expr — emit as statement, fall + # through to the default return. + self._visit_stmt(node.body[0], lines, indent=2) + elif expr: lines.append(f" return {self._visit_expr(expr)};") emitted_return = True else: for i, s in enumerate(node.body): if i == len(node.body) - 1 and isinstance(s, ExprStmt): - lines.append(f" return {self._visit_expr(s.expr)};") - emitted_return = True + # A void drawing setter / delete / visual-noop used as the + # last statement cannot be the return value (it lowers to a + # void C++ call). Emit it as a plain statement and let the + # default-return path below supply the function's result. + if self._call_is_void(s.expr): + self._visit_stmt(s, lines, indent=2) + else: + lines.append(f" return {self._visit_expr(s.expr)};") + emitted_return = True elif i == len(node.body) - 1 and isinstance(s, (SwitchStmt, IfStmt)): # Switch/if as last statement = return expression in PineScript # Emit as: double _ret = 0; if/switch assigns _ret; return _ret; @@ -853,6 +902,68 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._in_ta_func_variant = False self._active_call_site_idx = None + def _func_var_init_flag_name(self, fname: str, call_site_idx: int | None) -> str: + suffix = f"_cs{call_site_idx}" if call_site_idx is not None else "" + return f"_fvinit_{self._func_safe_name(fname)}{suffix}" + + def _emit_func_var_init_block(self, fi: FuncInfo, call_site_idx: int | None, + lines: list[str]) -> None: + """Emit the one-shot initializer block for a function's ``var`` members. + + Pine ``var`` declared inside a function is a function-local static: + the initializer runs exactly once on the FIRST call to this variant + (using the first bar's values the function actually sees) and the + result persists for the strategy's lifetime. Each per-call-site clone + is an independent instance with its own flag and its own set of + (remapped) members. + + This closes the gap where a function-scoped ``var line x = line.new(...)`` + (or any non-compile-time initializer — drawing handles, UDT ctors, + arrays, runtime expressions) was declared as a default-constructed + class member but its initializer was dropped, leaving the member ``na`` + / uninitialised and causing "drawing access on na handle" at runtime. + """ + members = self.ctx.func_var_members.get(fi.name) + if not members: + return + flag = self._func_var_init_flag_name(fi.name, call_site_idx) + # ``_active_var_remap`` is already set for this variant by the caller, + # so lowering each init expression here correctly resolves references + # to sibling var members (which are themselves remapped for clones). + init_lines: list[str] = [] + for name, ptype, _init_str in members: + init_ast = self.ctx.var_member_init_exprs.get(name) + safe = self._safe_name(name) + target = self._active_var_remap.get(safe, safe) + if name in self.ctx.series_vars: + if init_ast is None: + continue + init_cpp = self._visit_expr(init_ast) + init_cpp = self._typed_na_init(init_cpp, name, ptype) + init_lines.append(f" {target}.push({init_cpp});") + continue + if init_ast is None: + # No initializer to lower (e.g. bare ``var box b``); leave the + # member at its default-constructed value. + continue + # Skip a plain ``na`` initializer for drawing handles / UDTs whose + # default-constructed member is already the na sentinel; assigning + # ``na()`` would not type-match the handle / struct. + udt_t = self._udt_var_types.get(name) + is_drawing = udt_t in DRAWING_TYPE_TO_CPP if udt_t else False + is_udt = udt_t in self._udt_defs if udt_t else False + from ..ast_nodes import NaLiteral + if (is_drawing or is_udt) and isinstance(init_ast, NaLiteral): + continue + init_cpp = self._visit_expr(init_ast) + init_lines.append(f" {target} = {init_cpp};") + if not init_lines: + return + lines.append(f" if (!{flag}) {{") + lines.extend(init_lines) + lines.append(f" {flag} = true;") + lines.append(" }") + def _emit_precalculate_and_run(self, lines: list[str]) -> None: has_static_ta = any(getattr(site, "is_static", False) for site in self.ctx.ta_call_sites) if not has_static_ta: diff --git a/pineforge_codegen/codegen/input.py b/pineforge_codegen/codegen/input.py index 4133275..58858cd 100644 --- a/pineforge_codegen/codegen/input.py +++ b/pineforge_codegen/codegen/input.py @@ -206,8 +206,22 @@ def _render_input_value(self, node: FuncCall, func_name: str | None, default = self._get_input_default(node) default_cpp = self._visit_expr(default) if default is not None else "0" getter = self._input_type_to_getter(func_name, namespace) + default_cpp = self._coerce_string_input_default(getter, default_cpp) return f'{getter}("{title}", {default_cpp})' + def _coerce_string_input_default(self, getter: str, default_cpp: str) -> str: + """String getters take a ``std::string`` default. An unresolved default + (e.g. ``input.string(size.tiny, ...)`` where ``size.tiny`` lowers to + ``0``) would pass a null char* and crash ``strlen`` inside the getter, + so coerce any non-string default to a safe empty string. (Label size is + a visual no-op in the backtest, so the exact default does not affect + trading logic.)""" + if getter == "get_input_string": + stripped = default_cpp.strip() + if not stripped.startswith("std::string(") and not stripped.startswith('"'): + return 'std::string("")' + return default_cpp + def _enforce_enum_declared_before_input_enum(self, node: FuncCall) -> None: """Mirror the analyzer: ``input.enum(Enum.member)`` requires Enum to be defined above the call. diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 5a18c2d..59ac866 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -60,8 +60,8 @@ from ..ast_nodes import ( ASTNode, Assignment, BinOp, BreakStmt, ContinueStmt, ExprStmt, ForStmt, - ForInStmt, FuncCall, Identifier, IfStmt, NumberLiteral, Subscript, - SwitchStmt, Ternary, TupleAssign, TupleLiteral, UnaryOp, VarDecl, + ForInStmt, FuncCall, FuncDef, Identifier, IfStmt, NumberLiteral, StringLiteral, + Subscript, SwitchStmt, Ternary, TupleAssign, TupleLiteral, UnaryOp, VarDecl, WhileStmt, ) from ..analyzer import ( @@ -77,6 +77,87 @@ class SecurityEmitter: Mixed into ``CodeGen``; not intended to be instantiated standalone.""" + def _resolve_security_tf(self, tf_node, containing_func: str): + """Resolve a ``request.security`` timeframe argument to ``(tf_str, tf_expr)``. + + ``tf_str`` is a compile-time string literal value; ``tf_expr`` is a runtime + C++ expression used at evaluator-registration time. Exactly one is non-None + for a usable tf (both None is acceptable only as an explicit "unknown"). + + A function-parameter tf (e.g. ``f(tf) => request.security(sym, tf, ...)``) + is not visible at class scope (the evaluator is a class method), so it is + resolved from the function's call sites. A dead-code UDF (never called) + falls back to the chart timeframe — its evaluator result is never read. + """ + if isinstance(tf_node, StringLiteral): + return tf_node.value, None + if isinstance(tf_node, Identifier): + name = tf_node.name + if name in self._timeframe_period_vars: + return None, "script_tf_" + if (name in self._known_vars and name not in self._input_backed_vars + and isinstance(self._known_vars[name], str)): + return self._known_vars[name], None + # class-scope resolvable (global / input member)? + if self._ident_is_resolvable(name): + try: + return None, self._visit_expr(tf_node) + except Exception: + pass + # function-parameter tf -> resolve from the call sites + if containing_func: + resolved = self._resolve_param_tf_from_callsites(containing_func, name) + if resolved is not None: + return resolved + # graceful fallback so transpile does not hard-fail + return None, "input_tf_" + # any other expression — visit if it resolves at class scope + try: + return None, self._visit_expr(tf_node) + except Exception: + return None, "input_tf_" + + def _resolve_param_tf_from_callsites(self, func_name: str, param_name: str): + """For a ``request.security`` whose tf is function parameter ``param_name`` + of user function ``func_name``, return ``(tf_str, tf_expr)`` resolved from + the call sites, or None. If every call passes the same literal/member tf, + that tf is used; mixed timeframes or a never-called (dead-code) function + fall back to the chart timeframe (``input_tf_``).""" + fdef = None + for node in self._walk_ast(self.ctx.ast): + if isinstance(node, FuncDef) and node.name == func_name: + fdef = node + break + if fdef is None or param_name not in fdef.params: + return None + pidx = fdef.params.index(param_name) + resolved: list = [] + found_call = False + for node in self._walk_ast(self.ctx.ast): + if (isinstance(node, FuncCall) and isinstance(node.callee, Identifier) + and node.callee.name == func_name): + found_call = True + arg = node.args[pidx] if pidx < len(node.args) else None + if arg is None: + continue + # Resolve the call-site arg (no further containing func — these + # are global-scope / input args). + resolved.append(self._resolve_security_tf(arg, "")) + if not found_call: + # dead code — evaluator never read; register with chart tf. + return (None, "input_tf_") + valid = [r for r in resolved if r is not None] + if not valid: + return (None, "input_tf_") + strs = {r[0] for r in valid} + exprs = {r[1] for r in valid} + if len(strs) == 1 and next(iter(strs), None) is not None: + return (next(iter(strs)), None) + if len(exprs) == 1 and next(iter(exprs), None) is not None: + return (None, next(iter(exprs))) + # mixed timeframes across call sites — cannot pick one statically + return (None, "input_tf_") + def _normalize_security_call(self, item) -> dict: if hasattr(item, "sec_id"): return { @@ -91,6 +172,7 @@ def _normalize_security_call(self, item) -> dict: "depends_on_mutable_globals": bool(getattr(item, "depends_on_mutable_globals", False)), "mutable_globals": list(getattr(item, "mutable_globals", ()) or ()), "is_lower_tf_array": bool(getattr(item, "is_lower_tf_array", False)), + "containing_func": getattr(item, "containing_func", "") or "", } return { "sec_id": item[0], @@ -104,6 +186,7 @@ def _normalize_security_call(self, item) -> dict: "depends_on_mutable_globals": False, "mutable_globals": [], "is_lower_tf_array": False, + "containing_func": "", } def _security_state_name(self, sec_id: int, name: str) -> str: diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 7c90aa2..1466803 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -291,6 +291,28 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: "label": "_pf_labels_", "linefill": "_pf_linefills_", } +# Pine typed array constructors for drawing handles — ``array.new_line(...)`` +# is the typed alias of ``array.new(...)`` (and likewise for box/label/ +# linefill). Maps the constructor name to the drawing element type name, so the +# element TypeSpec resolves to the C++ handle struct (Line/Box/Label/Linefill). +ARRAY_DRAWING_NEW_CTORS = { + "new_line": "line", "new_box": "box", + "new_label": "label", "new_linefill": "linefill", +} +# Pine array methods whose C++ lowering is void (or a non-scalar iterator) and +# whose Pine semantics is mutate-in-place: a function whose body ends in one of +# these returns void, so the codegen must emit it as a statement + default +# return rather than ``return arr.clear();`` / ``return arr.insert(...);``. +ARRAY_VOID_METHODS = frozenset({ + "push", "unshift", "insert", "clear", "set", "fill", + "sort", "reverse", "concat", +}) +# All recognised ``array.new_*`` scalar+drawing constructor names (``new`` itself +# is template-form and handled separately). +ARRAY_NEW_CTORS = frozenset( + {"new_float", "new_int", "new_bool", "new_string"} | set(ARRAY_DRAWING_NEW_CTORS) +) + SKIP_FUNC_NAMES = { "plot", "plotshape", "plotchar", "plotcandle", "plotbar", "plotarrow", "fill", "hline", "barcolor", "bgcolor", "alert", "alertcondition", diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 80b94b9..0f5afd5 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -41,6 +41,7 @@ from ..symbols import PineType, TypeSpec from .. import signatures as sigs from .tables import ( + ARRAY_DRAWING_NEW_CTORS, ARRAY_METHODS, BAR_BUILTINS, BAR_FIELDS, @@ -227,7 +228,7 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: return TypeSpec.array(TypeSpec.primitive("string")) if namespace == "array" and func_name in ( "new", "new_float", "new_int", "new_bool", "new_string", "from", - ): + ) or (namespace == "array" and func_name in ARRAY_DRAWING_NEW_CTORS): if func_name == "new_int": return TypeSpec.array(TypeSpec.primitive("int")) if func_name == "new_bool": @@ -236,6 +237,10 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: return TypeSpec.array(TypeSpec.primitive("string")) if func_name == "new_float": return TypeSpec.array(TypeSpec.primitive("float")) + if func_name in ARRAY_DRAWING_NEW_CTORS: + # array.new_line()/new_box()/new_label()/new_linefill() -> + # std::vector (typed alias of new). + return TypeSpec.array(TypeSpec.udt(ARRAY_DRAWING_NEW_CTORS[func_name])) if targs: return TypeSpec.array(self._type_spec_from_hint_name(targs[0]) or TypeSpec.udt(targs[0])) if func_name == "from" and node.args: @@ -553,6 +558,10 @@ def _infer_type(self, node) -> str: if namespace == "str": if func_name == "split": return "std::vector" + if func_name == "tonumber": + return "double" + if func_name == "length": + return "int" return "std::string" if namespace == "ta" and func_name == "pivot_point_levels": return "std::vector" @@ -637,16 +646,24 @@ def _infer_type(self, node) -> str: def _infer_tuple_types(self, func_node: FuncDef, count: int) -> list[str]: """Infer the C++ type of each element returned by a tuple-returning function. - Builds a lightweight local-type map from the function's - ``VarDecl``s so identifiers referenced inside the final - ``[a, b, c]`` literal resolve precisely; falls back to - ``_infer_type`` when no local declaration matches.""" + Builds a lightweight local-type map from the function's ``VarDecl``s + (including ones nested inside if/for/switch blocks) so identifiers + referenced inside the final ``[a, b, c]`` literal resolve precisely. + An explicit type hint wins (``string tag = na`` -> ``std::string``, + not the ``double`` implied by ``na``); otherwise the initializer + expression is inferred. Falls back to ``_infer_type`` when no local + declaration matches.""" if not func_node.body: return ["double"] * count local_types: dict[str, str] = {} - for stmt in func_node.body: - if isinstance(stmt, VarDecl) and stmt.value is not None: + for stmt in self._walk_ast(func_node): + if isinstance(stmt, VarDecl) and stmt.value is not None and stmt.name: + if stmt.type_hint: + spec = self._type_spec_from_hint_name(stmt.type_hint) + if spec is not None: + local_types[stmt.name] = self._type_spec_to_cpp(spec) + continue local_types[stmt.name] = self._infer_type(stmt.value) last_stmt = func_node.body[-1] diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 6e7fbfe..ec4cbec 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -143,6 +143,7 @@ from .. import signatures as sigs from .drawing import ALL_DRAWING_METHODS from .tables import ( + ARRAY_DRAWING_NEW_CTORS, ARRAY_METHODS, BAR_FIELDS, BAR_SERIES_PUSH, @@ -443,7 +444,7 @@ def _visit_func_call(self, node: FuncCall) -> str: # Array operations — emit proper C++ vector operations if namespace == "array": - if func_name in ("new", "new_float", "new_int", "new_bool", "new_string"): + if func_name in ("new", "new_float", "new_int", "new_bool", "new_string") or func_name in ARRAY_DRAWING_NEW_CTORS: spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float")) cpp_type = self._type_spec_to_cpp(spec) init_default = self._default_for_spec(spec.element if spec.element is not None else TypeSpec.primitive("float")) @@ -954,10 +955,19 @@ def _visit_func_call(self, node: FuncCall) -> str: elif f.default: val = self._visit_expr(f.default) if val is not None: - # Fix narrowing: cast na() to correct type for int fields + # Fix narrowing: brace-init (``T{.field = v}``) disallows + # narrowing. Pine ``int`` UDT fields are emitted as + # ``int64_t`` (see base.py) but are initialised from + # ``na()`` / doubles in places, so cast to the + # field's type. ``na()`` for an int field → 0. f_cpp_type = self._type_spec_to_cpp(field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name)) - if f_cpp_type == "int" and "na" in val: - val = val.replace("na()", "0") + if f_cpp_type == "int": + f_cpp_type = "int64_t" + if f_cpp_type == "int64_t": + if "na" in val: + val = val.replace("na()", "na()") + else: + val = f"(int64_t)({val})" field_inits.append(f".{f.name} = {val}") # Mark the constructed object non-na (the struct's ``__pf_na`` is the # last declared field, so this designator stays in declaration order). diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 738adbb..6b2eb30 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -112,6 +112,7 @@ DAYOFWEEK_MAP, DISPLAY_MAP, DRAWING_STYLE_NS, + DRAWING_TYPE_TO_CPP, ON_OFF_INHERIT_MAP, ORDER_DIRECTION_MAP, SKIP_NAMESPACES, @@ -210,6 +211,44 @@ def _visit_expr(self, node: ASTNode | None) -> str: return f"std::make_tuple({elems})" return "/* unknown */" + # ------------------------------------------------------------------ + # Target-typed RHS lowering (drawing handles are C++ structs, not doubles) + # ------------------------------------------------------------------ + def _is_na_expr(self, node) -> bool: + """True for a bare ``na`` (keyword NaLiteral or ``na`` identifier).""" + return (isinstance(node, NaLiteral) + or (isinstance(node, Identifier) and node.name == "na")) + + def _drawing_na_default(self, target_name: str | None) -> str | None: + """If ``target_name`` is a drawing-handle variable (line/box/label/ + linefill/chart.point), return its na default literal (e.g. ``Box{}`` — + a default-constructed handle whose id == -1 == na); otherwise None.""" + if not target_name: + return None + udt = self._udt_var_types.get(target_name) + if udt in DRAWING_TYPE_TO_CPP: + return f"{DRAWING_TYPE_TO_CPP[udt]}{{}}" + return None + + def _visit_rhs_value(self, value_node, target_name: str | None = None, + target_cpp_type: str | None = None) -> str: + """Visit an assignment / declaration RHS. + + A bare ``na`` lowers to a type-appropriate initializer for the target + instead of ``na()``: drawing handles brace-init to their na + handle (``Box{}`` / ``Line{}`` / …); ``std::string``/``int``/``int64_t``/ + ``bool`` use ``na()``. Without this, ``Box b = na;`` and + ``string s = na;`` would both emit ``na()`` and fail to compile + (no viable ``operator=`` / conversion). Every other RHS lowers unchanged. + """ + if target_name and self._is_na_expr(value_node): + draw_default = self._drawing_na_default(target_name) + if draw_default is not None: + return draw_default + if target_cpp_type in ("std::string", "int", "int64_t", "bool"): + return f"na<{target_cpp_type}>()" + return self._visit_expr(value_node) + def _visit_ident(self, node: Identifier) -> str: name = node.name # Bare 'na' identifier → na() @@ -669,6 +708,8 @@ def _visit_member_access(self, node: MemberAccess) -> str: or name in self._var_names or name in self._current_loop_vars or name in self._current_func_param_types + or name in self._current_func_locals + or name in self._udt_var_types ): safe = self._safe_name(name) if self._active_var_remap and safe in self._active_var_remap: diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index fd09b7e..81a2fc9 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -100,6 +100,7 @@ ) from ..symbols import TypeSpec from .tables import ( + ARRAY_NEW_CTORS, TA_RETURNS_BOOL, TA_TUPLE_FIELDS, MATRIX_RETURNING_METHODS, @@ -291,7 +292,7 @@ def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: # array.new_float() etc., plus array-returning copy/slice. if isinstance(node.value, FuncCall): func_name, namespace = self._resolve_callee(node.value.callee) - if namespace == "array" and func_name in ("new", "new_float", "new_int", "new_bool", "new_string", "from", "copy", "slice"): + if namespace == "array" and func_name in ARRAY_NEW_CTORS | {"new", "from", "copy", "slice"}: self._array_vars.add(node.name) spec = self._type_spec_from_expr(node.value) or self._array_spec_for_name(node.name) self._collection_types.setdefault(node.name, spec) @@ -402,11 +403,11 @@ def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: return # General declaration - cpp_val = self._visit_expr(node.value) + cpp_type = self._type_for_decl(node) if not is_global_member else None + cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type) if is_global_member: lines.append(f"{pad}{safe} = {cpp_val};") else: - cpp_type = self._type_for_decl(node) lines.append(f"{pad}{cpp_type} {safe} = {cpp_val};") @staticmethod @@ -509,7 +510,7 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non f"expected {self._type_spec_to_cpp(lhs_spec)}, " f"got {self._type_spec_to_cpp(rhs_spec)}", ) - val_cpp = self._visit_expr(node.value) + val_cpp = self._visit_rhs_value(node.value, target_name) if node.op == ":=": lines.append(f"{pad}{safe} = {val_cpp};") else: @@ -519,7 +520,7 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non else: lines.append(f"{pad}{safe} {node.op} {val_cpp};") else: - val_cpp = self._visit_expr(node.value) + val_cpp = self._visit_rhs_value(node.value, target_name) if node.op == ":=": lines.append(f"{pad}{safe} = {val_cpp};") else: @@ -727,6 +728,12 @@ def _emit_body_with_assign(self, body: list, target: str, # Check if it's a skip expr if self._is_skip_expr(stmt.expr): return + # A void drawing setter / delete / visual-noop cannot be the + # branch's value (it lowers to a void C++ call) — emit it as + # a statement and leave ``target`` at its default. + if self._call_is_void(stmt.expr): + self._visit_stmt(stmt, lines, indent) + return cpp = self._visit_expr(stmt.expr) pad = " " * indent lines.append(f"{pad}{target} = {cpp};") diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index bf8600f..3b2a2b4 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -576,39 +576,60 @@ def _parse_tuple_assign(self) -> TupleAssign: # -- Function definition -- + def _parse_param_type_annotation(self) -> str | None: + """Consume an optional function-parameter type annotation and return it + as a canonical hint string ('float', 'string', 'array', 'pivot', + 'chart.point', ...), or ``None`` for a bare untyped param. + + Leaves the parser positioned at the parameter name. Supports the Pine + qualifier prefixes (``series`` / ``simple`` / ``const`` — consumed but + not part of the C++ type), builtin types, user-defined / drawing type + names, and the ``T[]`` postfix-array shorthand (normalised to + ``array`` by ``_parse_type_hint_string``). + """ + TYPE_TOKENS = {TokenType.TYPE_INT, TokenType.TYPE_FLOAT, + TokenType.TYPE_BOOL, TokenType.TYPE_STRING} + # Optional qualifiers — they do not affect the C++ param type. + while self._check(TokenType.IDENT) and self._current().value in ( + "series", "simple", "const", + ): + self._advance() + # Is there a type annotation before the parameter name? A builtin type + # token always is; an IDENT is a type only if followed by another IDENT + # (``line ln``) or by ``[`` (``line[] arr``). + has_type = False + if self._current().type in TYPE_TOKENS: + has_type = True + elif self._check(TokenType.IDENT): + nxt = self._peek().type + if nxt == TokenType.IDENT or nxt == TokenType.LBRACKET: + has_type = True + if not has_type: + return None + return self._parse_type_hint_string() + def _parse_func_def(self) -> FuncDef: """Parse: name(param1, param2) => expr_or_block""" start_tok = self._current() name = self._consume(TokenType.IDENT).value self._consume(TokenType.LPAREN) - TYPE_TOKENS = {TokenType.TYPE_INT, TokenType.TYPE_FLOAT, - TokenType.TYPE_BOOL, TokenType.TYPE_STRING} params = [] + param_type_hints: list = [] + param_defaults: list = [] while not self._check(TokenType.RPAREN): - # Consume optional Pine parameter type qualifiers, e.g. `series float x`, - # `simple string maType`, `const int n`. Each is one qualifier in front - # of the (optional) type and the name — NOT a separate parameter. - while self._check(TokenType.IDENT) and self._current().value in ( - "series", - "simple", - "const", - ): - self._advance() - # Handle optional type annotation: type param (e.g., int len, float src, - # string s). Built-in types are dedicated tokens; user-defined types are - # IDENTs and handled by the "next is IDENT" check below. - if self._current().type in TYPE_TOKENS: - self._advance() # skip the type annotation + # Consume the optional type annotation (builtin / user / drawing / + # ``T[]``), returning the canonical hint string. Handles ``float[] arr``, + # ``line[] ln``, ``color c``, ``SDZone z``, ``string tf``, as well as + # the untyped bare-name case. + hint = self._parse_param_type_annotation() param_name = self._consume(TokenType.IDENT).value - if self._check(TokenType.IDENT): - # 'param_name' was actually a (user-defined) type name parsed as IDENT, - # next is the real name. - param_name = self._consume(TokenType.IDENT).value - # Skip default value: param = expr + pdefault = None if self._check(TokenType.EQUALS): self._advance() # consume '=' - self._parse_expression() # consume default value (discarded) + pdefault = self._parse_expression() # default value params.append(param_name) + param_type_hints.append(hint) + param_defaults.append(pdefault) self._match(TokenType.COMMA) self._consume(TokenType.RPAREN) self._skip_newlines() @@ -625,6 +646,13 @@ def _parse_func_def(self) -> FuncDef: expr = self._parse_expression() node = FuncDef(name=name, params=params, body=[ExprStmt(expr=expr)], is_single_expr=True) + # Record per-param type hints + defaults (mirrors _parse_method_def) so + # the analyzer can type UDT/string/array params and the codegen can emit + # them with the correct C++ type (``pivot hi``, ``std::string s``). + node.annotations = { + "param_type_hints": param_type_hints, + "param_defaults": param_defaults, + } return self._set_loc(node, start_tok) def _parse_type_or_enum_decl(self): diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 56a1251..193f6bb 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -50,7 +50,7 @@ from .analyzer import TA_CLASS_MAP from .codegen import ( MATH_FUNC_MAP, STR_FUNC_MAP, - ARRAY_METHODS, MAP_METHODS, MATRIX_METHODS, + ARRAY_METHODS, ARRAY_DRAWING_NEW_CTORS, MAP_METHODS, MATRIX_METHODS, SYMINFO_MEMBER_MAP, COLOR_CONST_MAP, SKIP_FUNC_NAMES, SKIP_NAMESPACES, BAR_BUILTINS, BAR_FIELDS, @@ -78,7 +78,7 @@ ) SUPPORTED_STR: frozenset[str] = frozenset(set(STR_FUNC_MAP) | {"format_time"}) SUPPORTED_INPUT: frozenset[str] = frozenset(sigs.INPUT_FUNCTIONS) -SUPPORTED_ARRAY: frozenset[str] = frozenset(set(ARRAY_METHODS) | {"new", "new_float", "new_int", "new_bool", "new_string", "from"}) +SUPPORTED_ARRAY: frozenset[str] = frozenset(set(ARRAY_METHODS) | {"new", "new_float", "new_int", "new_bool", "new_string", "from"} | set(ARRAY_DRAWING_NEW_CTORS)) SUPPORTED_MAP: frozenset[str] = frozenset(set(MAP_METHODS) | {"new"}) SUPPORTED_MATRIX: frozenset[str] = frozenset(set(MATRIX_METHODS) | {"new"}) SUPPORTED_SYMINFO: frozenset[str] = frozenset(SYMINFO_MEMBER_MAP) diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py new file mode 100644 index 0000000..0d6ae5e --- /dev/null +++ b/tests/test_codegen_validation_fixes.py @@ -0,0 +1,520 @@ +"""Regression tests for codegen bugs found by pinescript-scrapper validation. + +Covers five fix families: + 1. drawing-handle ``na`` reset/assignment (Box{}/Line{}/... not na()), + plus typed ``na`` for string/int/bool declaration init. + 2. void drawing setter used as a UDF's last expression / if-branch value. + 3. ``request.security`` whose timeframe is a UDF parameter (resolved from the + function's call sites; dead-code UDFs fall back to the chart timeframe). + 4. parser handling of ``T[]`` array-typed function parameters (``float[] arr``, + ``line[] ln``) — previously the whole function was dropped. + 5. typed drawing array constructors ``array.new_line/box/label/linefill``. +""" + +from pineforge_codegen import transpile + + +def _cpp(body: str) -> str: + return transpile('//@version=6\nstrategy("t")\n' + body + "\n") + + +# --------------------------------------------------------------------------- +# 1. drawing-handle na reset + typed scalar na init +# --------------------------------------------------------------------------- +def test_drawing_handle_na_reset_emits_brace_init(): + cpp = _cpp( + "var box riskBox = na\n" + "var line stopLine = na\n" + "var label lb = na\n" + "if bar_index == 0\n" + " riskBox := box.new(bar_index, high, bar_index + 1, low)\n" + " stopLine := line.new(bar_index, low, bar_index + 1, low)\n" + "if bar_index == 10\n" + " riskBox := na\n" + " stopLine := na\n" + " lb := na\n" + "plot(close)" + ) + # Resets lower to the handle's na default, NOT na(). + assert "riskBox = Box{}" in cpp + assert "stopLine = Line{}" in cpp + assert "lb = Label{}" in cpp + # The bad form must never appear for a handle reset. + assert "riskBox = na()" not in cpp + assert "stopLine = na()" not in cpp + + +def test_string_and_bool_na_init_uses_typed_na(): + cpp = _cpp( + "f() =>\n" + " string tag = na\n" + " bool flag = na\n" + " tag := \"x\"\n" + " tag\n" + "_z = f()\n" + "plot(close)" + ) + assert "std::string tag = na()" in cpp + assert "bool flag = na()" in cpp + assert "std::string tag = na()" not in cpp + + +# --------------------------------------------------------------------------- +# 2. void drawing setter as last expression / branch value +# --------------------------------------------------------------------------- +def test_void_setter_as_last_udf_expr_does_not_assign_to_retval(): + cpp = _cpp( + "var label lb = na\n" + "setTag(string t) =>\n" + " label.set_text(lb, t)\n" + "if bar_index == 0\n" + " lb := label.new(bar_index, close, \"x\")\n" + "setTag(\"hello\")\n" + "plot(close)" + ) + # The void setter is emitted as a statement; the function does NOT assign + # the void call to its return slot. The broken + # ``_func_ret = pf_label_set_text(...)`` must NOT appear. + assert "pf_label_set_text(_pf_labels_, lb" in cpp + assert "_func_ret = pf_label_set_text" not in cpp + assert "= pf_label_set_text" not in cpp + + +def test_void_setter_in_if_branch_value_does_not_assign_to_target(): + cpp = _cpp( + "var label lb = na\n" + "f(bool c) =>\n" + " x = if c\n" + " label.set_text(lb, \"a\")\n" + " else\n" + " label.set_text(lb, \"b\")\n" + " x\n" + "_z = f(true)\n" + "plot(close)" + ) + # Branch's void setter is a statement, not ``__ret = pf_label_set_text(...)``. + assert "__ret = pf_label_set_text" not in cpp + assert "= pf_label_set_text" not in cpp or "pf_label_set_text(_pf_labels_" in cpp + + +# --------------------------------------------------------------------------- +# 3. request.security with a UDF-parameter timeframe +# --------------------------------------------------------------------------- +def test_security_param_tf_single_call_resolves_from_callsite(): + # The tf param is an input member at the (single) call site -> resolved. + cpp = _cpp( + "htf = input.timeframe(\"240\", \"HTF\")\n" + "f(string tf) =>\n" + " request.security(syminfo.tickerid, tf, close)\n" + "b = f(htf)\n" + "plot(b)" + ) + # The evaluator registers with the resolved member, not input_tf_ fallback + # only, and crucially does not raise "Unknown variable 'tf'". + assert "register_security_eval" in cpp + assert "Unknown variable" not in cpp + + +def test_security_param_tf_dead_code_falls_back_to_chart_tf(): + # The UDF is never called -> dead code; registration must not crash. + cpp = _cpp( + "f(tf) =>\n" + " request.security(syminfo.tickerid, tf, close)\n" + "plot(close)" + ) + assert "register_security_eval" in cpp + assert "Unknown variable" not in cpp + + +def test_security_param_tf_mixed_literals_rejected(): + # Called with two different literal tfs -> a single evaluator cannot serve + # both; the analyzer now rejects loudly instead of silently collapsing onto + # the chart timeframe (the masayanfx multi-time-score root cause). + import pytest + with pytest.raises(Exception, match="multiple distinct literal timeframes"): + _cpp( + "f(tf) =>\n" + " request.security(syminfo.tickerid, tf, close)\n" + "a = f(\"5\")\n" + "b = f(\"15\")\n" + "plot(a + b)" + ) + + +def test_security_param_tf_same_literal_across_callsites_accepted(): + # Same literal tf at every call site -> unambiguous, single evaluator OK. + cpp = _cpp( + "f(tf) =>\n" + " request.security(syminfo.tickerid, tf, close)\n" + "a = f(\"60\")\n" + "b = f(\"60\")\n" + "plot(a + b)" + ) + assert "register_security_eval" in cpp + + +def test_security_param_tf_six_distinct_literals_rejected(): + # The masayanfx shape: one param-tf security UDF called with six different + # literals. Must reject with the mixed-literals error. + import pytest + with pytest.raises(Exception, match="multiple distinct literal timeframes"): + _cpp( + "scoreFromRange(tf) =>\n" + " request.security(syminfo.tickerid, tf, close)\n" + "a = scoreFromRange(\"5\")\n" + "b = scoreFromRange(\"15\")\n" + "c = scoreFromRange(\"60\")\n" + "d = scoreFromRange(\"240\")\n" + "e = scoreFromRange(\"D\")\n" + "g = scoreFromRange(\"W\")\n" + "plot(a + b + c + d + e + g)" + ) + + +# --------------------------------------------------------------------------- +# 4. T[] array-typed function parameters +# --------------------------------------------------------------------------- +def test_float_array_param_not_dropped(): + from pineforge_codegen.lexer import Lexer + from pineforge_codegen.parser import Parser + src = ('strategy("t")\n' + "f(float[] arr) =>\n" + " x = array.size(arr)\n" + " x\n" + "plot(close)") + ast = Parser(Lexer(src).tokenize(), source=src).parse() + funcs = [(s.name, s.params) for s in ast.body if type(s).__name__ == "FuncDef"] + assert funcs == [("f", ["arr"])] + + +def test_drawing_array_param_not_dropped(): + from pineforge_codegen.lexer import Lexer + from pineforge_codegen.parser import Parser + src = ('strategy("t")\n' + "f(line[] ln, label[] lb, box[] bx) =>\n" + " n = array.size(ln)\n" + " n\n" + "plot(close)") + ast = Parser(Lexer(src).tokenize(), source=src).parse() + funcs = [(s.name, s.params) for s in ast.body if type(s).__name__ == "FuncDef"] + assert funcs == [("f", ["ln", "lb", "bx"])] + + +def test_array_param_function_preserves_following_global_var(): + # The original bug: a T[]-param function was dropped AND swallowed the + # following global var declaration (the "longActive" regression). + from pineforge_codegen.lexer import Lexer + from pineforge_codegen.parser import Parser + src = ('strategy("t")\n' + "f(float[] a) =>\n" + " array.size(a)\n" + "var bool longActive = false\n" + "var bool shortActive = false\n" + "plot(close)") + ast = Parser(Lexer(src).tokenize(), source=src).parse() + names = [s.name for s in ast.body if type(s).__name__ == "VarDecl"] + assert "longActive" in names + assert "shortActive" in names + + +# --------------------------------------------------------------------------- +# 5. typed drawing array constructors +# --------------------------------------------------------------------------- +def test_drawing_array_constructors_emit_typed_vectors(): + cpp = _cpp( + "var line[] lns = array.new_line()\n" + "var label[] lbs = array.new_label(3)\n" + "var box[] bxs = array.new_box()\n" + "var linefill[] lfs = array.new_linefill()\n" + "plot(array.size(lns))" + ) + assert "std::vector lns" in cpp + assert "lns = std::vector()" in cpp + assert "std::vector