diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index 5501c78..2096e16 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -45,6 +45,12 @@ ) from ..symbols import PineType, TypeSpec +# Drawing-objects-as-data type names (spec §4.1). Defined locally — the +# analyzer must not import from ``codegen`` (codegen imports analyzer, so the +# reverse would be a cycle). Mirrors codegen.tables.DRAWING_TYPE_TO_CPP keys. +_DRAWING_TYPE_NAMES = frozenset({"line", "box", "label", "linefill", "chart.point"}) +_DRAWING_NS = frozenset({"line", "box", "label", "linefill"}) + class TypeHelper: """Pine type-hint / expression inference. @@ -100,6 +106,13 @@ def _type_spec_from_hint(self, hint: str | None) -> TypeSpec | None: return TypeSpec.map(key, val) if hint in self._udt_fields: return TypeSpec.udt(hint) + # Drawing-objects-as-data (P3): scalar ``line``/``box``/``label``/ + # ``linefill``/``chart.point`` carry the handle identity via a udt + # TypeSpec. Without this the analyzer field-spec filter (base.py ~847) + # erases a scalar drawing field, collapsing it to double. Drawing names + # are NOT in _udt_fields. + if hint in _DRAWING_TYPE_NAMES: + return TypeSpec.udt(hint) return None def _template_args_from_call(self, node: FuncCall) -> list[str]: @@ -116,6 +129,17 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: func = cal.member if isinstance(cal, MemberAccess) else None ns = cal.object.name if isinstance(cal, MemberAccess) and isinstance(cal.object, Identifier) else None targs = self._template_args_from_call(value) + # Drawing-objects-as-data return typing: *.new / *.copy -> handle of + # the self-type; linefill.get_line* -> line; chart.point.* -> point. + if ns in _DRAWING_NS: + if func in ("new", "copy"): + return TypeSpec.udt(ns) + if ns == "linefill" and func in ("get_line1", "get_line2"): + return TypeSpec.udt("line") + if (isinstance(cal, MemberAccess) and isinstance(cal.object, MemberAccess) + and isinstance(cal.object.object, Identifier) + and cal.object.object.name == "chart" and cal.object.member == "point"): + return TypeSpec.udt("chart.point") if ns == "array" and func in ("new", "new_float", "new_int", "new_bool", "new_string", "from"): if func == "new_float": return TypeSpec.array(TypeSpec.primitive("float")) @@ -194,6 +218,13 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: return recv_spec.element if func == "eigenvalues": return TypeSpec.array(TypeSpec.primitive("float")) + # Drawing method-form: a.copy() -> same handle; lf.get_line*() -> line. + if (recv_spec is not None and recv_spec.kind == "udt" + and recv_spec.name in _DRAWING_TYPE_NAMES): + if func == "copy": + return recv_spec + if recv_spec.name == "linefill" and func in ("get_line1", "get_line2"): + return TypeSpec.udt("line") if isinstance(value, Identifier): sym = self._symbols.resolve(value.name) if sym is not None and sym.type_spec is not None: diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 039fa50..f995e5b 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -39,6 +39,7 @@ BAR_FIELDS, BAR_BUILTINS, BAR_SERIES_PUSH, + DRAWING_TYPE_TO_CPP, SECURITY_OHLC_BAR_FIELDS, TA_RETURNS_BOOL, TA_IMPLICIT_COMPUTE, @@ -112,12 +113,17 @@ from .visit_expr import ExprVisitor from .visit_call import CallVisitor +# DrawingVisitor owns the drawing-objects-as-data dispatch (line/box/label/ +# linefill/chart.point lowering onto the per-type arenas) plus _uses_drawing +# detection and arena-cap computation. See codegen/drawing.py. +from .drawing import DrawingVisitor + # --------------------------------------------------------------------------- # CodeGen class # --------------------------------------------------------------------------- -class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEmitter, TaSiteHelper, TypeInferer, InputHelper, NamingHelper): +class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEmitter, TaSiteHelper, TypeInferer, InputHelper, DrawingVisitor, NamingHelper): """Generate C++ from an AnalyzerContext (visitor pattern). Mixin chain (Python MRO is left-to-right; method names are @@ -347,7 +353,10 @@ def __init__(self, ctx: AnalyzerContext) -> None: # rewrite or strip downstream references to those fields so the # generated C++ never references a member that doesn't exist on the # emitted struct. See: pineforge-codegen issue #10. - _DRAWING_TYPES_INIT = {"label", "line", "box", "table", "linefill", "polyline", "chart.point"} + # Drawing-objects-as-data: line/box/label/linefill/chart.point are now + # REAL data (un-dropped from UDT structs). Only table/polyline stay + # dropped (no C++ representation). See drawing-objects-as-data.md §4.2. + _DRAWING_TYPES_INIT = {"table", "polyline"} self._udt_omitted_fields: dict[str, set[str]] = {} for _type_name, _fields in self._udt_defs.items(): _omitted = set() @@ -468,6 +477,11 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._register_global_aggregate_member_types() self._uses_matrix = self._detect_matrix_usage() + # Drawing-objects-as-data: gate all new emission (drawing.hpp include + + # the per-type arenas) on this flag so non-drawing strategies stay + # byte-identical. Caps come from the strategy() header max_*_count. + self._uses_drawing = self._detect_drawing_usage() + self._drawing_caps = self._compute_drawing_caps() if self._uses_drawing else {} # max_bars_back: the per-variable history depth the engine's Series # ring buffer should retain. Pine exposes this two ways — the @@ -1031,8 +1045,20 @@ def generate(self) -> str: continue seen_var_members.add(name) safe = self._safe_name(name) - # Detect array vars from init expression - if "array.new" in str(init_str) or "array.from" in str(init_str) or name in self._array_vars: + # Detect array vars from init expression. Guard the substring + # heuristic against a UDT constructor that merely WRAPS array.new / + # array.from in its arguments — e.g. + # ``var draw d = draw.new(array.new(), array.new())`` + # must declare as ``draw``, not ``std::vector``. (Drawing + # made this latent collision reachable.) + _init_str_s = str(init_str) + _is_udt_ctor_init = any( + _init_str_s.startswith(f"{u}.new") for u in self._udt_defs + ) + if (not _is_udt_ctor_init) and ( + "array.new" in _init_str_s or "array.from" in _init_str_s + or name in self._array_vars + ): self._array_vars.add(name) lines.append(f" {self._type_spec_to_cpp(self._array_spec_for_name(name))} {safe};") continue @@ -1063,6 +1089,17 @@ def generate(self) -> str: # decl as ``double`` and the later ``z = SDZone{...}`` would not # compile (assigning SDZone to double). init_s = str(init_str) + # Drawing handle var member (L-N2): a ``var line x`` declares as the + # C++ handle struct (Series when also history-referenced). + # Drawing names are NOT in _udt_defs, so the udt branch below would + # self-zero them to double; handle them first. + _draw_cpp = DRAWING_TYPE_TO_CPP.get(self._udt_var_types.get(name)) + if _draw_cpp is not None: + if name in self.ctx.series_vars: + lines.append(f" Series<{_draw_cpp}> {safe}{_mbb};") + else: + lines.append(f" {_draw_cpp} {safe};") + continue udt_type = self._udt_var_types.get(name) if udt_type not in self._udt_defs: udt_type = None @@ -1131,7 +1168,13 @@ def generate(self) -> str: # data/validation/udt-method-probe-19-array-of-udt-method, # data/validation/udt-method-probe-20-udt-return-from-func. udt_t = self._udt_var_types[name] - lines.append(f" {udt_t} {safe} = {udt_t}{{}};") + # Drawing handle global (L-N6 / U): map line/box/label/linefill + # to the C++ handle struct (the default is na, id=-1). + _draw_cpp = DRAWING_TYPE_TO_CPP.get(udt_t) + if _draw_cpp is not None: + lines.append(f" {_draw_cpp} {safe} = {_draw_cpp}{{}};") + else: + lines.append(f" {udt_t} {safe} = {udt_t}{{}};") else: expr = self.ctx.global_expr_map.get(name) if hasattr(self.ctx, "global_expr_map") else None cpp_type = self._infer_type(expr) if expr is not None else PINE_TYPE_TO_CPP.get(ptype, "double") @@ -1175,6 +1218,17 @@ def generate(self) -> str: else: lines.append(f" double {cloned_safe} = 0.0;") + # 8d. Drawing-objects-as-data arenas (gated on _uses_drawing so + # non-drawing strategies emit byte-identical C++). Each arena is a + # per-strategy member -> reset-per-run is automatic. Caps come from + # the strategy() header max_*_count (default 50; linefill default 50). + if self._uses_drawing: + caps = self._drawing_caps or {} + lines.append(f" DrawingArena _pf_lines_{{{caps.get('line', 50)}}};") + lines.append(f" DrawingArena _pf_boxes_{{{caps.get('box', 50)}}};") + lines.append(f" DrawingArena _pf_labels_{{{caps.get('label', 50)}}};") + lines.append(f" DrawingArena _pf_linefills_{{{caps.get('linefill', 50)}}};") + # 9. _var_initialized flag if self.ctx.var_members: lines.append(" bool _var_initialized = false;") @@ -1319,6 +1373,10 @@ def _resolve_known(self, arg_str: str) -> str: def _is_skip_expr(self, node) -> bool: """Check if an expression should be skipped (visual/unsupported).""" if isinstance(node, FuncCall): + # chart.point.* resolves to namespace "chart" (a SKIP_NAMESPACE) but + # is REAL data (a ChartPoint aggregate literal). Never skip it. + if self._is_chart_point_callee(node.callee): + return False func_name, namespace = self._resolve_callee(node.callee) if func_name in SKIP_FUNC_NAMES: return True diff --git a/pineforge_codegen/codegen/drawing.py b/pineforge_codegen/codegen/drawing.py new file mode 100644 index 0000000..7d8674a --- /dev/null +++ b/pineforge_codegen/codegen/drawing.py @@ -0,0 +1,504 @@ +"""Drawing-objects-as-data dispatch for the codegen. + +Spec: ``docs/drawing-objects-as-data.md`` §4.3 (the 4-form call dispatch), +§L (handle lifecycle) and §U (UDT × drawing). Everything here only ever +runs for a strategy whose ``self._uses_drawing`` is True (the dispatch is +reached solely when a ``line``/``box``/``label``/``linefill``/``chart.point`` +call or a drawing-typed receiver is present), so non-drawing strategies emit +byte-identical C++. + +The geometry of a drawing object becomes REAL C++ state (per-type arena in +``pineforge/drawing.hpp``); every VISUAL kwarg/method (color / style / width / +bgcolor / text_* / extend-as-visual / force_overlay) is accepted-and-DROPPED. + +``DrawingVisitor`` is mixed into ``CodeGen``; it owns no state of its own and +reads only attributes established by ``CodeGen.__init__`` (``_uses_drawing``, +``_udt_var_types``, ``_udt_param_udt``) and sibling mixins +(``_visit_expr``, ``_type_spec_from_expr``). +""" + +from __future__ import annotations + +from ..ast_nodes import FuncCall, Identifier, MemberAccess +from .tables import DRAWING_TYPE_TO_CPP, DRAWING_ARENA + + +# --------------------------------------------------------------------------- +# Canonical Pine v6 constructor param-name lists (positional order). +# Only the GEOMETRY names are consumed downstream; every other (visual) name is +# merged-and-dropped. Two overloads each: scalar coords vs chart.point. +# --------------------------------------------------------------------------- +_LINE_CTOR = ["x1", "y1", "x2", "y2", "xloc", "extend", "color", "style", + "width", "force_overlay"] +_LINE_CTOR_PTS = ["first_point", "second_point", "xloc", "color", "style", + "width", "extend", "force_overlay"] +_BOX_CTOR = ["left", "top", "right", "bottom", "border_color", "border_width", + "border_style", "extend", "xloc", "bgcolor", "text", "text_size", + "text_color", "text_halign", "text_valign", "text_wrap", + "text_font_family", "force_overlay"] +_BOX_CTOR_PTS = ["top_left", "bottom_right", "border_color", "border_width", + "border_style", "extend", "xloc", "bgcolor", "text", + "text_size", "text_color", "text_halign", "text_valign", + "text_wrap", "text_font_family", "force_overlay"] +_LABEL_CTOR = ["x", "y", "text", "xloc", "yloc", "color", "style", "textcolor", + "size", "textalign", "tooltip", "text_font_family", + "force_overlay"] +_LABEL_CTOR_PTS = ["point", "text", "xloc", "yloc", "color", "style", + "textcolor", "size", "textalign", "tooltip", + "text_font_family", "force_overlay"] + + +# --------------------------------------------------------------------------- +# Method classification per type. GEOMETRY methods emit real arena calls; +# *_NOOP visual setters emit pf_noop (args evaluated + discarded). +# --------------------------------------------------------------------------- +LINE_METHODS = frozenset({ + "get_x1", "get_x2", "get_y1", "get_y2", "get_price", + "set_x1", "set_x2", "set_y1", "set_y2", "set_xy1", "set_xy2", + "set_first_point", "set_second_point", "set_xloc", "copy", "delete", +}) +LINE_NOOP = frozenset({"set_color", "set_style", "set_width", "set_extend"}) + +BOX_METHODS = frozenset({ + "get_left", "get_right", "get_top", "get_bottom", + "set_left", "set_right", "set_top", "set_bottom", + "set_lefttop", "set_rightbottom", + "set_top_left_point", "set_bottom_right_point", "set_xloc", + "copy", "delete", +}) +BOX_NOOP = frozenset({ + "set_border_color", "set_border_width", "set_border_style", "set_bgcolor", + "set_extend", "set_text", "set_text_color", "set_text_size", + "set_text_halign", "set_text_valign", "set_text_font_family", + "set_text_wrap", "set_text_formatting", +}) + +LABEL_METHODS = frozenset({ + "get_x", "get_y", "get_text", + "set_x", "set_y", "set_xy", "set_point", "set_xloc", "set_yloc", + "set_text", "copy", "delete", +}) +LABEL_NOOP = frozenset({ + "set_color", "set_style", "set_textcolor", "set_size", "set_textalign", + "set_tooltip", "set_text_font_family", "set_text_formatting", +}) + +LINEFILL_METHODS = frozenset({"get_line1", "get_line2", "delete"}) +LINEFILL_NOOP = frozenset({"set_color"}) + +# Scalar getter -> C++ return type. (linefill.get_line1/2 return a Line handle, +# typed via _type_spec_from_expr -> udt, not here.) x-coords are int64_t, +# y-coords/prices double, label text std::string. +_DRAWING_GETTER_RET = { + ("line", "get_x1"): "int64_t", ("line", "get_x2"): "int64_t", + ("line", "get_y1"): "double", ("line", "get_y2"): "double", + ("line", "get_price"): "double", + ("box", "get_left"): "int64_t", ("box", "get_right"): "int64_t", + ("box", "get_top"): "double", ("box", "get_bottom"): "double", + ("label", "get_x"): "int64_t", ("label", "get_y"): "double", + ("label", "get_text"): "std::string", +} + +# Per-type lookup (geometry, noop) used by the dispatcher and support_checker. +DRAWING_METHODS_BY_TYPE = { + "line": (LINE_METHODS, LINE_NOOP), + "box": (BOX_METHODS, BOX_NOOP), + "label": (LABEL_METHODS, LABEL_NOOP), + "linefill": (LINEFILL_METHODS, LINEFILL_NOOP), +} + +# All method names that the §4.3 receiver dispatch recognises. ``new`` is +# DELIBERATELY excluded (it is namespace-functional only and would otherwise +# shadow ``Type.new(...)`` UDT constructors). Gating the receiver branch on +# membership here enforces the L.1 precedence rule: a user method (egoigor's +# ``slope``) is not in this set and therefore routes to user-method dispatch. +ALL_DRAWING_METHODS = ( + LINE_METHODS | LINE_NOOP | BOX_METHODS | BOX_NOOP + | LABEL_METHODS | LABEL_NOOP | LINEFILL_METHODS | LINEFILL_NOOP +) + + +class DrawingVisitor: + """Drawing-objects-as-data emit helpers. Mixed into ``CodeGen``.""" + + # ------------------------------------------------------------------ + # Enum / extend lowering helpers + # ------------------------------------------------------------------ + @staticmethod + def _lower_xloc(node) -> str: + if (isinstance(node, MemberAccess) and isinstance(node.object, Identifier) + and node.object.name == "xloc" and node.member == "bar_time"): + return "XLoc::bar_time" + return "XLoc::bar_index" + + @staticmethod + def _lower_yloc(node) -> str: + if isinstance(node, MemberAccess) and isinstance(node.object, Identifier) and node.object.name == "yloc": + if node.member == "abovebar": + return "YLoc::abovebar" + if node.member == "belowbar": + return "YLoc::belowbar" + return "YLoc::price" + + @staticmethod + def _lower_extend(node) -> tuple[str, str]: + """Map ``extend.{none,left,right,both}`` -> (ext_left, ext_right).""" + if isinstance(node, MemberAccess) and isinstance(node.object, Identifier) and node.object.name == "extend": + return { + "both": ("true", "true"), + "left": ("true", "false"), + "right": ("false", "true"), + "none": ("false", "false"), + }.get(node.member, ("false", "false")) + return ("false", "false") + + @staticmethod + def _is_chart_point_callee(callee) -> bool: + """True for ``chart.point.(...)`` callees.""" + return ( + isinstance(callee, MemberAccess) + and isinstance(callee.object, MemberAccess) + and isinstance(callee.object.object, Identifier) + and callee.object.object.name == "chart" + and callee.object.member == "point" + ) + + # ------------------------------------------------------------------ + # Constructor (CREATE) lowering + # ------------------------------------------------------------------ + @staticmethod + def _merge_drawing_args(node: FuncCall, param_names: list[str]) -> dict: + """Map positional args + kwargs onto ``param_names`` -> {name: node}.""" + vals: dict = {} + for i, a in enumerate(node.args): + if i < len(param_names): + vals[param_names[i]] = a + for k, v in node.kwargs.items(): + vals[k] = v + return vals + + def _drawing_ctor_is_points(self, node: FuncCall) -> bool: + """Pick the chart.point overload when the first geometry arg is a + ChartPoint (inferred TypeSpec udt('chart.point')) or a point kwarg is + supplied.""" + for k in ("first_point", "top_left", "point"): + if k in node.kwargs: + return True + if node.args: + spec = self._type_spec_from_expr(node.args[0]) + if spec is not None and spec.kind == "udt" and spec.name == "chart.point": + return True + return False + + def _emit_drawing_ctor(self, dtype: str, node: FuncCall) -> str: + arena = DRAWING_ARENA[dtype] + if dtype == "linefill": + vals = self._merge_drawing_args(node, ["line1", "line2", "color"]) + l1 = self._visit_expr(vals["line1"]) if vals.get("line1") is not None else "Line{}" + l2 = self._visit_expr(vals["line2"]) if vals.get("line2") is not None else "Line{}" + return f"pf_linefill_new({arena}, {l1}, {l2})" + + use_pts = self._drawing_ctor_is_points(node) + + if dtype == "line": + vals = self._merge_drawing_args(node, _LINE_CTOR_PTS if use_pts else _LINE_CTOR) + xloc = self._lower_xloc(vals.get("xloc")) + if use_pts: + p1 = self._visit_expr(vals.get("first_point")) + p2 = self._visit_expr(vals.get("second_point")) + return f"pf_line_new_pts({arena}, {p1}, {p2}, {xloc})" + el, er = self._lower_extend(vals.get("extend")) + x1 = self._visit_expr(vals.get("x1")) + y1 = self._visit_expr(vals.get("y1")) + x2 = self._visit_expr(vals.get("x2")) + y2 = self._visit_expr(vals.get("y2")) + return (f"pf_line_new({arena}, (int64_t)({x1}), (double)({y1}), " + f"(int64_t)({x2}), (double)({y2}), {xloc}, {el}, {er})") + + if dtype == "box": + vals = self._merge_drawing_args(node, _BOX_CTOR_PTS if use_pts else _BOX_CTOR) + xloc = self._lower_xloc(vals.get("xloc")) + if use_pts: + tl = self._visit_expr(vals.get("top_left")) + br = self._visit_expr(vals.get("bottom_right")) + return f"pf_box_new_pts({arena}, {tl}, {br}, {xloc})" + left = self._visit_expr(vals.get("left")) + top = self._visit_expr(vals.get("top")) + right = self._visit_expr(vals.get("right")) + bottom = self._visit_expr(vals.get("bottom")) + return (f"pf_box_new({arena}, (int64_t)({left}), (double)({top}), " + f"(int64_t)({right}), (double)({bottom}), {xloc})") + + if dtype == "label": + vals = self._merge_drawing_args(node, _LABEL_CTOR_PTS if use_pts else _LABEL_CTOR) + yloc = self._lower_yloc(vals.get("yloc")) + text = (self._visit_expr(vals["text"]) if vals.get("text") is not None + else 'std::string("")') + if use_pts: + pt = self._visit_expr(vals.get("point")) + return f"pf_label_new_pt({arena}, {pt}, {text}, {yloc})" + x = self._visit_expr(vals.get("x")) + y = self._visit_expr(vals.get("y")) + xloc = self._lower_xloc(vals.get("xloc")) + return (f"pf_label_new({arena}, (int64_t)({x}), (double)({y}), " + f"{text}, {xloc}, {yloc})") + + return "0" # unreachable + + # ------------------------------------------------------------------ + # chart.point — inline aggregate literals (no arena) + # ------------------------------------------------------------------ + def _emit_chart_point(self, func_name: str, node: FuncCall) -> str: + if func_name == "copy": + inner = self._visit_expr(node.args[0]) if node.args else "ChartPoint{}" + return f"ChartPoint({inner})" + if func_name == "now": + vals = self._merge_drawing_args(node, ["price"]) + price = (self._visit_expr(vals["price"]) if vals.get("price") is not None + else "current_bar_.close") + return (f"ChartPoint{{ .index=bar_index_, " + f".time=(int64_t)current_bar_.timestamp, .price=({price}) }}") + if func_name == "from_index": + vals = self._merge_drawing_args(node, ["index", "price"]) + idx = self._visit_expr(vals.get("index")) + price = self._visit_expr(vals.get("price")) + return (f"ChartPoint{{ .index=(int64_t)({idx}), .time=na(), " + f".price=({price}) }}") + if func_name == "from_time": + vals = self._merge_drawing_args(node, ["time", "price"]) + tm = self._visit_expr(vals.get("time")) + price = self._visit_expr(vals.get("price")) + return (f"ChartPoint{{ .index=na(), .time=(int64_t)({tm}), " + f".price=({price}) }}") + if func_name == "new": + vals = self._merge_drawing_args(node, ["time", "index", "price"]) + tm = self._visit_expr(vals.get("time")) + idx = self._visit_expr(vals.get("index")) + price = self._visit_expr(vals.get("price")) + return (f"ChartPoint{{ .index=(int64_t)({idx}), .time=(int64_t)({tm}), " + f".price=({price}) }}") + return "ChartPoint{}" + + # ------------------------------------------------------------------ + # Namespace-functional + method-form dispatch + # ------------------------------------------------------------------ + def _emit_drawing_namespace_call(self, namespace: str, func_name: str, node: FuncCall) -> str: + """``line.new(...)`` / ``line.get_y2(ln)`` / ``linefill.new(l1,l2,c)`` …""" + if func_name == "new": + return self._emit_drawing_ctor(namespace, node) + if not node.args: + return "0" + return self._emit_drawing_method(namespace, func_name, node.args[0], list(node.args[1:]), node) + + def _emit_drawing_method(self, dtype: str, method: str, recv_node, arg_nodes: list, node: FuncCall) -> str: + """Lower a drawing method onto its arena (or pf_noop for visual setters). + + ``recv_node`` is the handle receiver; ``arg_nodes`` the remaining args. + """ + arena = DRAWING_ARENA[dtype] + recv = self._visit_expr(recv_node) + geometry, noop = DRAWING_METHODS_BY_TYPE[dtype] + if method in noop: + extra = "".join(", " + self._visit_expr(a) for a in arg_nodes) + return f"pf_noop({recv}{extra})" + av = [self._visit_expr(a) for a in arg_nodes] + if dtype == "line": + return self._emit_line_method(method, arena, recv, av, arg_nodes) + if dtype == "box": + return self._emit_box_method(method, arena, recv, av, arg_nodes) + if dtype == "label": + return self._emit_label_method(method, arena, recv, av, arg_nodes) + if dtype == "linefill": + return self._emit_linefill_method(method, arena, recv, av) + return "0" + + def _emit_line_method(self, m, a, r, av, raw) -> str: + if m == "get_x1": + return f"pf_line_get_x1({a}, {r})" + if m == "get_x2": + return f"pf_line_get_x2({a}, {r})" + if m == "get_y1": + return f"pf_line_get_y1({a}, {r})" + if m == "get_y2": + return f"pf_line_get_y2({a}, {r})" + if m == "get_price": + return f"pf_line_get_price({a}, {r}, (int64_t)({av[0]}))" + if m == "set_x1": + return f"pf_line_set_x1({a}, {r}, (int64_t)({av[0]}))" + if m == "set_x2": + return f"pf_line_set_x2({a}, {r}, (int64_t)({av[0]}))" + if m == "set_y1": + return f"pf_line_set_y1({a}, {r}, (double)({av[0]}))" + if m == "set_y2": + return f"pf_line_set_y2({a}, {r}, (double)({av[0]}))" + if m == "set_xy1": + return f"pf_line_set_xy1({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))" + if m == "set_xy2": + return f"pf_line_set_xy2({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))" + if m == "set_first_point": + return f"pf_line_set_first_point({a}, {r}, {av[0]})" + if m == "set_second_point": + return f"pf_line_set_second_point({a}, {r}, {av[0]})" + if m == "set_xloc": + return f"pf_line_set_xloc({a}, {r}, (int64_t)({av[0]}), (int64_t)({av[1]}), {self._lower_xloc(raw[2])})" + if m == "copy": + return f"pf_line_copy({a}, {r})" + if m == "delete": + return f"pf_line_delete({a}, {r})" + return "0" + + def _emit_box_method(self, m, a, r, av, raw) -> str: + if m == "get_left": + return f"pf_box_get_left({a}, {r})" + if m == "get_right": + return f"pf_box_get_right({a}, {r})" + if m == "get_top": + return f"pf_box_get_top({a}, {r})" + if m == "get_bottom": + return f"pf_box_get_bottom({a}, {r})" + if m == "set_left": + return f"pf_box_set_left({a}, {r}, (int64_t)({av[0]}))" + if m == "set_right": + return f"pf_box_set_right({a}, {r}, (int64_t)({av[0]}))" + if m == "set_top": + return f"pf_box_set_top({a}, {r}, (double)({av[0]}))" + if m == "set_bottom": + return f"pf_box_set_bottom({a}, {r}, (double)({av[0]}))" + if m == "set_lefttop": + return f"pf_box_set_lefttop({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))" + if m == "set_rightbottom": + return f"pf_box_set_rightbottom({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))" + if m == "set_top_left_point": + return f"pf_box_set_top_left_point({a}, {r}, {av[0]})" + if m == "set_bottom_right_point": + return f"pf_box_set_bottom_right_point({a}, {r}, {av[0]})" + if m == "set_xloc": + return f"pf_box_set_xloc({a}, {r}, (int64_t)({av[0]}), (int64_t)({av[1]}), {self._lower_xloc(raw[2])})" + if m == "copy": + return f"pf_box_copy({a}, {r})" + if m == "delete": + return f"pf_box_delete({a}, {r})" + return "0" + + def _emit_label_method(self, m, a, r, av, raw) -> str: + if m == "get_x": + return f"pf_label_get_x({a}, {r})" + if m == "get_y": + return f"pf_label_get_y({a}, {r})" + if m == "get_text": + return f"pf_label_get_text({a}, {r})" + if m == "set_x": + return f"pf_label_set_x({a}, {r}, (int64_t)({av[0]}))" + if m == "set_y": + return f"pf_label_set_y({a}, {r}, (double)({av[0]}))" + if m == "set_xy": + return f"pf_label_set_xy({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))" + if m == "set_point": + return f"pf_label_set_point({a}, {r}, {av[0]})" + if m == "set_xloc": + return f"pf_label_set_xloc({a}, {r}, (int64_t)({av[0]}), {self._lower_xloc(raw[1])})" + if m == "set_yloc": + return f"pf_label_set_yloc({a}, {r}, {self._lower_yloc(raw[0])})" + if m == "set_text": + return f"pf_label_set_text({a}, {r}, {av[0]})" + if m == "copy": + return f"pf_label_copy({a}, {r})" + if m == "delete": + return f"pf_label_delete({a}, {r})" + return "0" + + def _emit_linefill_method(self, m, a, r, av) -> str: + if m == "get_line1": + return f"pf_linefill_get_line1({a}, {r})" + if m == "get_line2": + return f"pf_linefill_get_line2({a}, {r})" + if m == "delete": + return f"pf_linefill_delete({a}, {r})" + return "0" + + def _drawing_call_return_cpp(self, node: FuncCall) -> str | None: + """C++ scalar return type for a drawing GETTER call, else None. + + Covers both the namespace-functional form (``label.get_text(lb)``) and + the method form (``lb.get_text()``). Used by ``_infer_type`` / + ``_type_for_decl`` so the receiving local declares as the right scalar + (an int64_t x-coord, a double y-coord, or a std::string label text) + instead of the analyzer's default double. + """ + callee = node.callee + if not isinstance(callee, MemberAccess): + return None + method = callee.member + _fn, ns = self._resolve_callee(callee) + from .tables import DRAWING_NS + dtype = None + if ns in DRAWING_NS: + dtype = ns + else: + recv_spec = self._type_spec_from_expr(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 None + return _DRAWING_GETTER_RET.get((dtype, method)) + + # ------------------------------------------------------------------ + # _uses_drawing detection + arena caps + # ------------------------------------------------------------------ + def _spec_mentions_drawing(self, spec) -> bool: + if spec is None: + return False + if spec.kind == "udt" and spec.name in DRAWING_TYPE_TO_CPP: + return True + if spec.kind == "array": + return self._spec_mentions_drawing(spec.element) + if spec.kind == "map": + return self._spec_mentions_drawing(spec.key) or self._spec_mentions_drawing(spec.value) + if spec.kind == "matrix": + return self._spec_mentions_drawing(spec.element) + return False + + def _detect_drawing_usage(self) -> bool: + """True iff emitted C++ needs pineforge/drawing.hpp + the arenas. + + Mirrors ``_detect_matrix_usage``: any var/field/array of a drawing type + OR any line/box/label/linefill namespace call OR any chart.point call. + """ + for t in self._udt_var_types.values(): + if t in DRAWING_TYPE_TO_CPP: + return True + for spec in self._collection_types.values(): + if self._spec_mentions_drawing(spec): + return True + for fields in self._udt_field_type_specs.values(): + for spec in fields.values(): + if self._spec_mentions_drawing(spec): + return True + for _tname, _fields in self._udt_defs.items(): + for f in _fields: + if self._spec_mentions_drawing(self._type_spec_from_hint_name(f.type_name)): + return True + from .tables import DRAWING_NS + for node in self._walk_ast(self.ctx.ast): + if isinstance(node, FuncCall): + _fn, ns = self._resolve_callee(node.callee) + if ns in DRAWING_NS: + return True + if self._is_chart_point_callee(node.callee): + return True + return False + + def _compute_drawing_caps(self) -> dict: + """Per-type arena capacity from the strategy() header (default 50).""" + from ..ast_nodes import StrategyDecl + caps = {"line": 50, "box": 50, "label": 50, "linefill": 50} + header_field = {"line": "max_lines_count", "box": "max_boxes_count", + "label": "max_labels_count"} + for node in self._walk_ast(self.ctx.ast): + if isinstance(node, StrategyDecl): + for key, field in header_field.items(): + v = self._int_literal_value(node.kwargs.get(field)) + if v is not None and v > 0: + caps[key] = v + return caps diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index b610f78..192506f 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -90,6 +90,7 @@ from ..symbols import TypeSpec from .tables import ( BAR_SERIES_PUSH, + DRAWING_TYPE_TO_CPP, PINE_TYPE_TO_CPP, RUNTIME_REGISTER_SECURITY_EVAL_FN, RUNTIME_REGISTER_SECURITY_LOWER_TF_EVAL_FN, @@ -136,6 +137,11 @@ def _emit_includes(self, lines: list[str]) -> None: float_spec = TypeSpec.primitive("float") if any(spec.element != float_spec for spec in self._matrix_specs.values()): lines.append('#include ') + # Drawing-objects-as-data runtime (line/box/label/linefill arenas + + # ChartPoint). Gated on _uses_drawing so non-drawing strategies stay + # byte-identical — mirrors the matrix.hpp gating above. + if getattr(self, "_uses_drawing", False): + lines.append('#include ') lines.append("") # Compatibility shim for the namespace-wrap refactor: unqualified # references to BacktestEngine / Bar / na() / ta::* / etc. resolve @@ -245,6 +251,12 @@ def _emit_constructor(self, lines: list[str]) -> None: # ``z(na())`` would not type-match the struct member. if name in self._udt_var_types and self._udt_var_types[name] in self._udt_defs: continue + # Drawing handle var member (L-N3): ``var line x`` / ``var box b`` + # default-construct to {-1} (na). A ``b(na())`` ctor init + # would not type-match the handle struct — skip it (the in-class + # member default is the once-only persistent na init). + if name in self._udt_var_types and self._udt_var_types[name] in DRAWING_TYPE_TO_CPP: + continue if name not in self.ctx.series_vars: cpp_val = self._resolve_known(init_expr) cpp_val = self._typed_na_init(cpp_val, name, ptype) @@ -729,7 +741,12 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No func_sv = self.ctx.func_series_vars.get(fi.name, set()) for i, p in enumerate(node.params): if is_udt and i == 0 and fi.udt_type_name: - cpp_t = f"{fi.udt_type_name}&" + # A method receiver whose type is a drawing primitive + # (egoigor's ``method slope(line ln)``) must emit ``Line&`` not + # the unknown ``line&``. Register _udt_param_udt so the body's + # getters dispatch through the §4.3 drawing path (L.6d / U.5). + recv_cpp = DRAWING_TYPE_TO_CPP.get(fi.udt_type_name, fi.udt_type_name) + cpp_t = f"{recv_cpp}&" safe_p = self._safe_name(p) self._udt_param_udt[safe_p] = fi.udt_type_name self._udt_param_udt[p] = fi.udt_type_name @@ -757,7 +774,9 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No tuple_types_list = self._infer_tuple_types(node, fi.tuple_element_count) ret_type = f"std::tuple<{', '.join(tuple_types_list)}>" elif getattr(fi, "udt_return_type", None): - ret_type = fi.udt_return_type + # 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) else: ret_type = PINE_TYPE_TO_CPP.get(fi.return_type, "double") diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 74300fb..7c90aa2 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -263,15 +263,44 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: PineType.STRING: "std::string", PineType.NA: "double", PineType.UNKNOWN: "double", PineType.VOID: "double", PineType.COLOR: "int", + # Drawing-objects-as-data: the value-view handle structs (see + # drawing.hpp). Explicit-hint decls (``var line x``), UDT-method drawing + # params, and ``_type_for_decl`` resolve through here. + "line": "Line", "box": "Box", "label": "Label", + "linefill": "Linefill", "chart.point": "ChartPoint", +} + +# --------------------------------------------------------------------------- +# Drawing-objects-as-data (spec §4.1) +# --------------------------------------------------------------------------- +# Pine drawing type name -> C++ handle struct (pineforge/drawing.hpp). +# ``table`` / ``polyline`` are DELIBERATELY absent — they stay 100% no-op +# (kept in SKIP_NAMESPACES / SKIP_VAR_TYPES / _DRAWING_TYPES_INIT). +DRAWING_TYPE_TO_CPP = { + "line": "Line", "box": "Box", "label": "Label", + "linefill": "Linefill", "chart.point": "ChartPoint", +} +# Function-call namespaces routed to the per-type arena dispatch. +DRAWING_NS = {"line", "box", "label", "linefill"} +# Constant member reads (``line.style_solid`` -> "0"). chart.point/linefill +# carry no style constants. +DRAWING_STYLE_NS = {"line", "box", "label"} +# Per-type arena member name on the generated strategy. +DRAWING_ARENA = { + "line": "_pf_lines_", "box": "_pf_boxes_", + "label": "_pf_labels_", "linefill": "_pf_linefills_", } SKIP_FUNC_NAMES = { "plot", "plotshape", "plotchar", "plotcandle", "plotbar", "plotarrow", "fill", "hline", "barcolor", "bgcolor", "alert", "alertcondition", } +# line / box / label / linefill REMOVED — they now route to real drawing +# codegen (gated on _uses_drawing). table / polyline / chart / display / size +# / position stay no-op. SKIP_NAMESPACES = { - "table", "label", "line", "box", "polyline", "chart", - "linefill", "display", "size", "position", + "table", "polyline", "chart", + "display", "size", "position", } SKIP_VAR_TYPES = {"table"} diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 5883bc3..80b94b9 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -44,6 +44,8 @@ ARRAY_METHODS, BAR_BUILTINS, BAR_FIELDS, + DRAWING_NS, + DRAWING_TYPE_TO_CPP, PINE_TYPE_TO_CPP, TA_RETURNS_BOOL, ) @@ -100,6 +102,12 @@ def _type_spec_from_hint_name(self, name: str | None) -> TypeSpec | None: return TypeSpec.map(key, val) if name in self._udt_defs: return TypeSpec.udt(name) + # Drawing-objects-as-data (spec §4.1 / P3): scalar ``line``/``box``/ + # ``label``/``linefill``/``chart.point`` hints carry the handle identity + # via a udt TypeSpec. (``array`` already resolves via the array + # fallback above.) Drawing names are NOT in _udt_defs. + if name in DRAWING_TYPE_TO_CPP: + return TypeSpec.udt(name) return None def _type_spec_to_cpp(self, spec: TypeSpec | None) -> str: @@ -110,6 +118,11 @@ def _type_spec_to_cpp(self, spec: TypeSpec | None) -> str: return {"float": "double", "int": "int", "bool": "bool", "string": "std::string", "color": "int"}.get(spec.name or "float", "double") if spec.kind == "udt" and spec.name: + # Drawing handle structs (P1): map BEFORE the _udt_defs check so + # array -> std::vector and scalar line -> Line instead + # of the old collapse to double / unknown-type-name. + if spec.name in DRAWING_TYPE_TO_CPP: + return DRAWING_TYPE_TO_CPP[spec.name] return spec.name if spec.name in self._udt_defs else "double" if spec.kind == "array": return f"std::vector<{self._type_spec_to_cpp(spec.element)}>" @@ -143,6 +156,10 @@ def _default_for_spec(self, spec: TypeSpec | None) -> str: UDTs would otherwise fall through to ``0`` which is type-incompatible. """ if spec is not None and spec.kind == "udt" and spec.name: + # Drawing handle default (P2): brace-init the C++ struct name + # (Line{} = na handle), NOT the lowercase Pine name (line{}). + if spec.name in DRAWING_TYPE_TO_CPP: + return f"{DRAWING_TYPE_TO_CPP[spec.name]}{{}}" return f"{spec.name}{{}}" cpp_type = self._type_spec_to_cpp(spec) if cpp_type.startswith("std::vector") or cpp_type.startswith("std::unordered_map"): @@ -179,6 +196,12 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: return self._collection_types[node.name] if node.name in self._udt_var_types: return TypeSpec.udt(self._udt_var_types[node.name]) + # Drawing-typed method/function parameter (L.6d / U.5): a ``line ln`` + # method receiver registers in _udt_param_udt so its body getters + # resolve to the drawing udt and dispatch through the §4.3 path. + _pu = getattr(self, "_udt_param_udt", None) + if _pu and node.name in _pu and _pu[node.name] in DRAWING_TYPE_TO_CPP: + return TypeSpec.udt(_pu[node.name]) sym = self.ctx.symbols.resolve(node.name) if sym is not None and getattr(sym, "type_spec", None) is not None: return sym.type_spec @@ -191,6 +214,15 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: if isinstance(node, FuncCall): func_name, namespace = self._resolve_callee(node.callee) targs = self._template_args_from_call(node) + # Drawing-objects-as-data return typing (spec §4.5 DRAWING_RETURN_SPECS): + # *.new / *.copy -> handle of the self-type; linefill.get_line* -> line. + if namespace in DRAWING_NS: + if func_name in ("new", "copy"): + return TypeSpec.udt(namespace) + if namespace == "linefill" and func_name in ("get_line1", "get_line2"): + return TypeSpec.udt("line") + if self._is_chart_point_callee(node.callee): + return TypeSpec.udt("chart.point") if namespace == "str" and func_name == "split": return TypeSpec.array(TypeSpec.primitive("string")) if namespace == "array" and func_name in ( @@ -249,6 +281,14 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: return recv_spec.element if func_name == "eigenvalues": return TypeSpec.array(TypeSpec.primitive("float")) + # Drawing method-form: ``a.copy()`` -> same handle type; + # ``lf.get_line1()`` -> line. (L-N6 alias-vs-copy typing.) + if (recv_spec is not None and recv_spec.kind == "udt" + and recv_spec.name in DRAWING_TYPE_TO_CPP): + if func_name == "copy": + return recv_spec + if recv_spec.name == "linefill" and func_name in ("get_line1", "get_line2"): + return TypeSpec.udt("line") return None # ------------------------------------------------------------------ @@ -345,6 +385,22 @@ def _type_for_decl(self, node: VarDecl) -> str: if node.type_hint in self._udt_defs: return node.type_hint return PINE_TYPE_TO_CPP.get(node.type_hint, "double") + # Drawing handle local (L-N6): a hintless local whose RHS resolves to a + # drawing udt must declare as the handle struct, not the analyzer's + # scalar default. Covers ``ln = arr.get(i)``, alias ``b = a``, field read + # ``lvl.ln``, and ``c = a.copy()``. Also records _udt_var_types so later + # uses (``ln.set_x2(...)`` / ``ln.slope()``) resolve to the drawing udt. + if getattr(self, "_uses_drawing", False): + rhs_spec = self._type_spec_from_expr(node.value) + if (rhs_spec is not None and rhs_spec.kind == "udt" + and rhs_spec.name in DRAWING_TYPE_TO_CPP): + self._udt_var_types.setdefault(node.name, rhs_spec.name) + return DRAWING_TYPE_TO_CPP[rhs_spec.name] + # Scalar drawing getter local (get_text -> std::string, etc.). + if isinstance(node.value, FuncCall): + _dret = self._drawing_call_return_cpp(node.value) + if _dret is not None: + return _dret sym = self.ctx.symbols.resolve(node.name) if sym is not None: inferred = self._infer_type(node.value) @@ -467,6 +523,12 @@ def _infer_type(self, node) -> str: return "double" if isinstance(node, FuncCall): func_name, namespace = self._resolve_callee(node.callee) + # Drawing scalar getter return type (get_text -> std::string, + # get_x* -> int64_t, get_y*/get_price/get_top/get_bottom -> double). + if getattr(self, "_uses_drawing", False): + _dret = self._drawing_call_return_cpp(node) + if _dret is not None: + return _dret if func_name in ("time", "time_close") and namespace is None and node.args: return "int64_t" if func_name == "timestamp" and namespace is None: diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index e741602..6e7fbfe 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -141,10 +141,13 @@ ) from ..symbols import TypeSpec from .. import signatures as sigs +from .drawing import ALL_DRAWING_METHODS from .tables import ( ARRAY_METHODS, BAR_FIELDS, BAR_SERIES_PUSH, + DRAWING_NS, + DRAWING_TYPE_TO_CPP, MAP_METHODS, MATH_FUNC_MAP, MATRIX_METHODS, @@ -226,6 +229,32 @@ def _visit_func_call(self, node: FuncCall) -> str: ) rest = [self._visit_expr(a) for a in rest_nodes] return f"{fn_cpp}({', '.join([recv_e] + rest)})" + + # Drawing method dispatch (spec §4.3 / L.1). A KNOWN drawing method on a + # receiver that resolves to a drawing udt — gated on the METHOD NAME + # FIRST so a user method (egoigor's ``ln.slope()``, already routed by the + # block above) is never captured here. This single check covers all + # receiver shapes (identifier ``ln.set_x2(v)``, obj.field + # ``d.fld.set_y2(v)``, and arbitrary-expr ``d.upln.get(0).delete()``), so + # it precedes the obj.field.method / identifier branches below AND the + # generic ``delete`` -> ``_delete_`` rewrites + _resolve_callee. + if isinstance(callee, MemberAccess) and callee.member in ALL_DRAWING_METHODS: + recv_spec = self._type_spec_from_expr(callee.object) + if (recv_spec is not None and recv_spec.kind == "udt" + and recv_spec.name in DRAWING_TYPE_TO_CPP): + return self._emit_drawing_method( + recv_spec.name, callee.member, callee.object, + list(node.args), node, + ) + + # chart.point.now/new/from_index/from_time/copy — REAL data (a ChartPoint + # aggregate). Routed here BEFORE the obj.field.method receiver logic, + # which would otherwise mis-treat ``chart.point`` as a receiver object + # and raise on the ``chart.point`` member read (chart ∈ SKIP_NAMESPACES). + if self._is_chart_point_callee(callee): + cp_func, _cp_ns = self._resolve_callee(callee) + return self._emit_chart_point(cp_func, node) + # obj.field.method(args) — must not lower to namespace::method (loses receiver chain). if isinstance(callee, MemberAccess): obj = callee.object @@ -446,6 +475,16 @@ def _visit_func_call(self, node: FuncCall) -> str: if namespace is None and func_name == "color" and func_name not in self._func_names: return "0" + # Drawing-objects-as-data namespace-functional form (spec §4.3 form 1): + # line.new(...) / line.get_y2(ln) / box.set_top(b, v) / linefill.new(...) + # MUST precede the SKIP_NAMESPACES early-return (these namespaces were + # removed from SKIP_NAMESPACES). chart.point.* resolves to namespace + # "chart", so it is matched by callee shape instead. + if namespace in DRAWING_NS: + return self._emit_drawing_namespace_call(namespace, func_name, node) + if self._is_chart_point_callee(callee): + return self._emit_chart_point(func_name, node) + # Skip visual/unsupported namespace calls if namespace in SKIP_NAMESPACES or namespace in SKIP_VAR_TYPES: return "0" diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 706f476..738adbb 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -111,6 +111,7 @@ COLOR_CONST_MAP, DAYOFWEEK_MAP, DISPLAY_MAP, + DRAWING_STYLE_NS, ON_OFF_INHERIT_MAP, ORDER_DIRECTION_MAP, SKIP_NAMESPACES, @@ -589,6 +590,14 @@ def _visit_member_access(self, node: MemberAccess) -> str: if node.member in COLOR_CONST_MAP: return COLOR_CONST_MAP[node.member] return "0" + # Drawing style/visual CONSTANT member reads (line.style_solid, + # box.style_dashed, label.style_label_left, ...). These namespaces + # left SKIP_NAMESPACES (their FuncCall forms now route to the arena), + # but their constant members only ever feed dropped visual kwargs, so + # they still lower to "0". The function names arrive as FuncCalls + # (visit_call), never here. See spec §4.4. + if ns in DRAWING_STYLE_NS: + return "0" if ns in SKIP_NAMESPACES: return "0" if ns == "currency": diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index fc4b869..56a1251 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -55,6 +55,10 @@ SKIP_FUNC_NAMES, SKIP_NAMESPACES, BAR_BUILTINS, BAR_FIELDS, ) +from .codegen.drawing import ( + LINE_METHODS, LINE_NOOP, BOX_METHODS, BOX_NOOP, + LABEL_METHODS, LABEL_NOOP, LINEFILL_METHODS, LINEFILL_NOOP, +) # --------------------------------------------------------------------------- @@ -78,6 +82,27 @@ 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) +# Drawing-objects-as-data (spec §4.5). Geometry methods are REAL (route to the +# per-type arena); *_NOOP visual setters are accepted no-ops (Level.WARNING). +# Any line/box/label/linefill method NOT in these sets rejects loudly so an +# unknown drawing method is not silently emitted. ``table``/``polyline`` are +# intentionally absent — they stay in SKIP_NAMESPACES (no-op, never reclassified). +SUPPORTED_LINE: frozenset[str] = frozenset(LINE_METHODS | LINE_NOOP | {"new"}) +SUPPORTED_BOX: frozenset[str] = frozenset(BOX_METHODS | BOX_NOOP | {"new"}) +SUPPORTED_LABEL: frozenset[str] = frozenset(LABEL_METHODS | LABEL_NOOP | {"new"}) +SUPPORTED_LINEFILL: frozenset[str] = frozenset(LINEFILL_METHODS | LINEFILL_NOOP | {"new"}) +SUPPORTED_CHART_POINT: frozenset[str] = frozenset( + {"new", "now", "from_index", "from_time", "copy"} +) +# Visual no-op drawing setters (warn, don't reject) keyed by namespace. +_DRAWING_NOOP_BY_NS: dict[str, frozenset[str]] = { + "line": LINE_NOOP, "box": BOX_NOOP, "label": LABEL_NOOP, "linefill": LINEFILL_NOOP, +} +_DRAWING_SUPPORTED_BY_NS: dict[str, frozenset[str]] = { + "line": SUPPORTED_LINE, "box": SUPPORTED_BOX, + "label": SUPPORTED_LABEL, "linefill": SUPPORTED_LINEFILL, +} +_DRAWING_TYPE_NAMES: frozenset[str] = frozenset({"line", "box", "label", "linefill"}) SUPPORTED_COLOR_CONST: frozenset[str] = frozenset(COLOR_CONST_MAP) SUPPORTED_COLOR_FUNC: frozenset[str] = frozenset({"new", "rgb", "r", "g", "b", "t"}) # Cosmetic color builders with no backtest-logic effect. Warned (not rejected); @@ -411,6 +436,17 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._user_enums: set[str] = set() self._user_funcs: set[str] = set() self._user_methods: set[str] = set() + # Drawing lifecycle gap guard (spec L.3/U.7): only bare identifiers are + # promoted to Series by analyzer/base.py::_visit_Subscript. A history + # read on a drawing handle nested in a UDT field (``p.a[1]``) or on a + # tuple element known to be a drawing handle (``la[1]`` after + # ``[la, lb] = makePair()``) would otherwise silently read the current + # bar. Track enough lightweight declaration information to reject those + # shapes before codegen. + self._udt_drawing_fields: dict[str, set[str]] = {} + self._var_udt_types: dict[str, str] = {} + self._drawing_tuple_vars: set[str] = set() + self._func_tuple_drawing_returns: dict[str, list[bool]] = {} # Track whether we are inside an if/ternary condition expression. self._in_conditional_depth: int = 0 # Track whether we are inside an argument subtree that legitimately @@ -446,13 +482,71 @@ def _collect_user_definitions(self, ast: Program) -> None: for stmt in ast.body: if isinstance(stmt, TypeDecl): self._user_types.add(stmt.name) + drawing_fields = { + field.name + for field in stmt.fields + if self._type_name_contains_drawing(field.type_name) + } + if drawing_fields: + self._udt_drawing_fields[stmt.name] = drawing_fields elif isinstance(stmt, EnumDecl): self._user_enums.add(stmt.name) elif isinstance(stmt, FuncDef): self._user_funcs.add(stmt.name) + tuple_returns = self._single_expr_tuple_drawing_mask(stmt) + if tuple_returns: + self._func_tuple_drawing_returns[stmt.name] = tuple_returns elif isinstance(stmt, MethodDef): self._user_methods.add(stmt.name) + @staticmethod + def _type_name_contains_drawing(type_name: str | None) -> bool: + if not type_name: + return False + compact = str(type_name).replace(" ", "") + if compact.endswith("[]"): + compact = f"array<{compact[:-2]}>" + if compact in _DRAWING_TYPE_NAMES: + return True + return any(f"<{drawing}>" in compact for drawing in _DRAWING_TYPE_NAMES) + + @staticmethod + def _is_literal_zero_subscript(node: Subscript) -> bool: + return isinstance(node.index, NumberLiteral) and node.index.value == 0 + + def _expr_is_drawing_handle(self, expr: ASTNode | None) -> bool: + if isinstance(expr, FuncCall): + ns, name = _qualified_name(expr.callee) + if ns in _DRAWING_TYPE_NAMES and name in {"new", "copy"}: + return True + if ns == "linefill" and name in {"get_line1", "get_line2"}: + return True + if isinstance(expr, Identifier): + return expr.name in self._drawing_tuple_vars + if isinstance(expr, Ternary): + return self._expr_is_drawing_handle(expr.true_val) or self._expr_is_drawing_handle(expr.false_val) + return False + + def _single_expr_tuple_drawing_mask(self, fn: FuncDef) -> list[bool] | None: + if not fn.is_single_expr or len(fn.body) != 1: + return None + stmt = fn.body[0] + expr = stmt.expr if isinstance(stmt, ExprStmt) else None + if not isinstance(expr, TupleLiteral): + return None + mask = [self._expr_is_drawing_handle(item) for item in expr.elements] + return mask if any(mask) else None + + def _tuple_assign_drawing_mask(self, value: ASTNode | None) -> list[bool] | None: + if isinstance(value, TupleLiteral): + mask = [self._expr_is_drawing_handle(item) for item in value.elements] + return mask if any(mask) else None + if isinstance(value, FuncCall): + ns, name = _qualified_name(value.callee) + if ns is None and name in self._func_tuple_drawing_returns: + return self._func_tuple_drawing_returns[name] + return None + # -- Diagnostic emission -- def _err(self, node: ASTNode | None, message: str, hint: str | None = None) -> None: @@ -639,6 +733,12 @@ def _visit_StrategyDecl(self, node: StrategyDecl) -> None: self._visit_children_const_ok(node) def _visit_VarDecl(self, node: VarDecl) -> None: + if node.type_hint in self._user_types: + self._var_udt_types[node.name] = node.type_hint + elif isinstance(node.value, FuncCall): + ns, name = _qualified_name(node.value.callee) + if name == "new" and ns in self._user_types: + self._var_udt_types[node.name] = ns if node.is_varip: self._err( node, @@ -655,6 +755,11 @@ def _visit_VarDecl(self, node: VarDecl) -> None: self._visit_children(node) def _visit_TupleAssign(self, node: TupleAssign) -> None: + drawing_mask = self._tuple_assign_drawing_mask(node.value) + if drawing_mask: + for idx, name in enumerate(node.names): + if idx < len(drawing_mask) and drawing_mask[idx]: + self._drawing_tuple_vars.add(name) if isinstance(node.value, FuncCall): ns, name = _qualified_name(node.value.callee) if ns == "ta" and name == "stoch": @@ -665,6 +770,40 @@ def _visit_TupleAssign(self, node: TupleAssign) -> None: ) self._visit_children(node) + def _visit_Subscript(self, node: Subscript) -> None: + # Best-effort defensive guard for the silent-wrong shapes called out in + # spec L.3/U.7. This is not a full expression type checker; it catches + # the known dangerous surface (UDT vars with drawing fields, and tuple + # destructured drawing handles) without trying to prove every nested or + # parameter-passed UDT expression. Literal [0] is current-bar in Pine and + # safe to leave to codegen's existing current-value behavior. + if self._is_literal_zero_subscript(node): + self._visit_children(node) + return + if isinstance(node.object, MemberAccess) and isinstance(node.object.object, Identifier): + var_name = node.object.object.name + udt_name = self._var_udt_types.get(var_name) + if udt_name is not None and node.object.member in self._udt_drawing_fields.get(udt_name, set()): + self._err( + node, + "History references on UDT drawing fields are not supported in PineForge.", + hint=( + f"{var_name}.{node.object.member}[...] would be read as the current-bar field, " + "not prior-bar history. Copy the field to a bare drawing variable first, " + "or remove the history reference." + ), + ) + elif isinstance(node.object, Identifier) and node.object.name in self._drawing_tuple_vars: + self._err( + node, + "History references on tuple-destructured drawing handles are not supported in PineForge.", + hint=( + f"{node.object.name}[...] is a drawing handle from tuple destructuring; " + "PineForge cannot historize that tuple element safely." + ), + ) + self._visit_children(node) + def _visit_FuncCall(self, node: FuncCall) -> None: ns, name = _qualified_name(node.callee) @@ -942,6 +1081,34 @@ def _visit_FuncCall(self, node: FuncCall) -> None: self._visit_children(node) return + # Drawing-objects-as-data (line/box/label/linefill). Geometry methods + # are REAL data; *_NOOP visual setters are accepted no-ops (warned); + # any OTHER drawing method rejects loudly. Args carry constant-namespace + # members (xloc.*, yloc.*, text.align_*, style consts), so children are + # visited with const reads allowed. NOTE: user methods on a drawing var + # (egoigor ``ln.slope()``) have ns="ln" (the receiver), NOT a DRAWING_NS, + # so they are never gated here. + if ns in _DRAWING_SUPPORTED_BY_NS: + if name not in _DRAWING_SUPPORTED_BY_NS[ns]: + self._err(node, f"{ns}.{name}(...) is not implemented in PineForge runtime.") + self._visit_children(node) + return + if name in _DRAWING_NOOP_BY_NS[ns]: + self._warn( + node, + f"{ns}.{name}(...) is a visual no-op in PineForge backtests " + f"(drawing geometry is data; color/style/size are dropped).", + ) + self._visit_children_const_ok(node) + return + if ns == "chart.point": + if name not in SUPPORTED_CHART_POINT: + self._err(node, f"chart.point.{name}(...) is not implemented in PineForge runtime.") + self._visit_children(node) + return + self._visit_children_const_ok(node) + return + # Drawing / charting / alert namespaces — codegen drops silently. Warn, # don't error: many strategies include these for the TradingView UI. # Their argument subtrees legitimately carry constant-namespace diff --git a/tests/test_codegen_drawing_data.py b/tests/test_codegen_drawing_data.py new file mode 100644 index 0000000..f29b807 --- /dev/null +++ b/tests/test_codegen_drawing_data.py @@ -0,0 +1,177 @@ +"""Drawing-objects-as-data: full-feature lowering + compile probes. + +Covers the line/box/label/linefill/chart.point surface that the public +corpus does not exercise (only ``line.new`` appears there). Transpile-level +assertions pin the emitted ``pf_*`` arena calls; the compile-gated probes +prove the generated C++ type-checks against ``pineforge/drawing.hpp``. +""" + +from __future__ import annotations + +from pineforge_codegen import transpile +from tests._compile import compile_cpp, skip_if_no_compile_env + + +def _cpp(body: str, header: str = "") -> str: + hdr = '//@version=6\nstrategy("t"' + (", " + header if header else "") + ")\n" + return transpile(hdr + body + "\n") + + +# --------------------------------------------------------------------------- +# Non-drawing strategies stay byte-clean (no include, no arenas). +# --------------------------------------------------------------------------- +def test_non_drawing_strategy_has_no_drawing_machinery(): + cpp = _cpp("x = ta.ema(close, 9)\nplot(x)") + assert "pineforge/drawing.hpp" not in cpp + assert "DrawingArena" not in cpp + assert "_pf_lines_" not in cpp + + +# --------------------------------------------------------------------------- +# line +# --------------------------------------------------------------------------- +def test_line_new_drops_visual_kwargs_keeps_geometry(): + cpp = _cpp( + "ln = line.new(bar_index, close, bar_index + 1, open, " + "xloc=xloc.bar_index, extend=extend.both, color=color.red, " + "style=line.style_dashed, width=2)" + ) + assert ("pf_line_new(_pf_lines_, (int64_t)(bar_index_), (double)(current_bar_.close), " + "(int64_t)((bar_index_ + 1)), (double)(current_bar_.open), " + "XLoc::bar_index, true, true)") in cpp + assert "color" not in cpp.split("pf_line_new")[1].split(";")[0] + + +def test_line_getters_setters_delete_copy(): + cpp = _cpp( + "ln = line.new(bar_index, close, bar_index, close)\n" + "y = ln.get_y2()\n" + "x = ln.get_x1()\n" + "ln.set_y2(close)\n" + "ln.set_x2(bar_index)\n" + "c = ln.copy()\n" + "ln.delete()" + ) + # getter return types drive the member decl; the arena call is the RHS. + assert "double y" in cpp and "pf_line_get_y2(_pf_lines_, ln)" in cpp + assert "int64_t x" in cpp and "pf_line_get_x1(_pf_lines_, ln)" in cpp + assert "pf_line_set_y2(_pf_lines_, ln, (double)(current_bar_.close))" in cpp + assert "pf_line_set_x2(_pf_lines_, ln, (int64_t)(bar_index_))" in cpp + assert "Line c" in cpp and "pf_line_copy(_pf_lines_, ln)" in cpp + assert "pf_line_delete(_pf_lines_, ln)" in cpp + + +# --------------------------------------------------------------------------- +# box +# --------------------------------------------------------------------------- +def test_box_new_and_getters(): + cpp = _cpp( + "bx = box.new(bar_index, high, bar_index + 5, low, " + "border_color=color.red, bgcolor=color.blue)\n" + "t = box.get_top(bx)\n" + "l = box.get_left(bx)\n" + "box.set_bottom(bx, low)" + ) + assert ("pf_box_new(_pf_boxes_, (int64_t)(bar_index_), (double)(current_bar_.high), " + "(int64_t)((bar_index_ + 5)), (double)(current_bar_.low), XLoc::bar_index)") in cpp + assert "double t" in cpp and "pf_box_get_top(_pf_boxes_, bx)" in cpp + assert "int64_t l" in cpp and "pf_box_get_left(_pf_boxes_, bx)" in cpp + assert "pf_box_set_bottom(_pf_boxes_, bx, (double)(current_bar_.low))" in cpp + + +# --------------------------------------------------------------------------- +# label +# --------------------------------------------------------------------------- +def test_label_new_text_and_getters(): + cpp = _cpp( + 'lb = label.new(bar_index, close, "hi", yloc=yloc.abovebar, ' + "color=color.green, style=label.style_label_down)\n" + "label.set_text(lb, \"bye\")\n" + "s = lb.get_text()\n" + "yy = lb.get_y()" + ) + assert ('pf_label_new(_pf_labels_, (int64_t)(bar_index_), (double)(current_bar_.close), ' + 'std::string("hi"), XLoc::bar_index, YLoc::abovebar)') in cpp + assert 'pf_label_set_text(_pf_labels_, lb, std::string("bye"))' in cpp + assert "std::string s" in cpp and "pf_label_get_text(_pf_labels_, lb)" in cpp + assert "double yy" in cpp and "pf_label_get_y(_pf_labels_, lb)" in cpp + + +# --------------------------------------------------------------------------- +# chart.point + line-from-points + linefill +# --------------------------------------------------------------------------- +def test_chart_point_and_line_pts_and_linefill(): + cpp = _cpp( + "p1 = chart.point.now(close)\n" + "p2 = chart.point.from_index(bar_index + 3, high)\n" + "ln1 = line.new(p1, p2)\n" + "ln2 = line.new(bar_index, low, bar_index + 3, low)\n" + "lf = linefill.new(ln1, ln2, color.new(color.red, 80))\n" + "g = linefill.get_line1(lf)" + ) + assert "ChartPoint{ .index=bar_index_, .time=(int64_t)current_bar_.timestamp, .price=(current_bar_.close) }" in cpp + assert "ChartPoint{ .index=(int64_t)((bar_index_ + 3)), .time=na(), .price=(current_bar_.high) }" in cpp + assert "pf_line_new_pts(_pf_lines_, p1, p2, XLoc::bar_index)" in cpp + # linefill drops the color arg. + assert "pf_linefill_new(_pf_linefills_, ln1, ln2)" in cpp + assert "Line g" in cpp and "pf_linefill_get_line1(_pf_linefills_, lf)" in cpp + + +# --------------------------------------------------------------------------- +# arena caps from the strategy() header. +# --------------------------------------------------------------------------- +def test_arena_caps_from_header(): + cpp = _cpp( + "ln = line.new(bar_index, close, bar_index, close)\n" + "bx = box.new(bar_index, high, bar_index, low)\n" + 'lb = label.new(bar_index, close, "x")', + header="max_lines_count=300, max_boxes_count=100, max_labels_count=200", + ) + assert "DrawingArena _pf_lines_{300};" in cpp + assert "DrawingArena _pf_boxes_{100};" in cpp + assert "DrawingArena _pf_labels_{200};" in cpp + assert "DrawingArena _pf_linefills_{50};" in cpp + + +def test_var_handle_na_default_no_ctor_init(): + """``var line x = na`` -> a plain ``Line x;`` member (na = id -1); the + constructor must NOT emit ``x(na())`` against the handle struct.""" + cpp = _cpp("var line x = na\nx := line.new(bar_index, close, bar_index, close)") + assert "Line x;" in cpp + assert "x(na())" not in cpp + + +# --------------------------------------------------------------------------- +# Compile-gated full-feature probe. +# --------------------------------------------------------------------------- +_FULL_PROBE = '''//@version=6 +strategy("drawing full probe", overlay=true, max_lines_count=300, max_boxes_count=100, max_labels_count=200) +var box bx = na +if bar_index == 10 + bx := box.new(bar_index, high, bar_index + 5, low, border_color=color.red) +if not na(bx) + box.set_top(bx, high) + box.set_rightbottom(bx, bar_index, low) + if box.get_top(bx) - box.get_bottom(bx) > 0 and box.get_left(bx) > 0 + strategy.entry("L", strategy.long) +var label lb = na +lb := label.new(bar_index, close, "hi", xloc=xloc.bar_index, yloc=yloc.abovebar, style=label.style_label_down, color=color.green) +label.set_text(lb, "bye") +label.set_y(lb, close) +ly = label.get_y(lb) +lt = label.get_text(lb) +p1 = chart.point.now(close) +p2 = chart.point.from_index(bar_index + 3, high) +ln1 = line.new(p1, p2) +ln2 = line.new(bar_index, low, bar_index + 3, low) +lf = linefill.new(ln1, ln2, color.new(color.red, 80)) +g1 = linefill.get_line1(lf) +px = line.get_price(ln2, bar_index + 1) +if px > 0 + strategy.close("L") +''' + + +def test_full_drawing_feature_compiles(): + skip_if_no_compile_env() + compile_cpp(transpile(_FULL_PROBE), label="drawing-full-probe") diff --git a/tests/test_codegen_drawing_handles.py b/tests/test_codegen_drawing_handles.py index f4c4478..aee13db 100644 --- a/tests/test_codegen_drawing_handles.py +++ b/tests/test_codegen_drawing_handles.py @@ -1,10 +1,9 @@ -"""Regression: loop-local drawing-object handles must be declared. +"""Drawing-objects-as-data: loop-local drawing handles are REAL data. -Drawing calls (line.new / label.new / box.new / table.new ...) are no-ops in a -backtest, but the variable they're assigned to may still be referenced (e.g. -pushed into an `array`). When the assignment lives in a for-loop body the -handle is a local; it must be declared (as an inert default) or the generated -C++ references an undeclared identifier. +Drawing geometry now becomes real C++ state (per-type arena in +``pineforge/drawing.hpp``); the handle variable declares as the C++ handle +struct (``Line``/``Label``) and the call lowers onto the arena. (Previously +these were inert no-ops declared as ``double`` / ``auto``.) """ from pineforge_codegen import transpile @@ -14,20 +13,29 @@ def _cpp(body: str) -> str: return transpile('//@version=6\nstrategy("t")\n' + body + "\n") -def test_line_handle_in_loop_is_declared(): +def test_line_handle_in_loop_is_a_real_handle(): cpp = _cpp( "var a = array.new()\n" "for k = 1 to 3\n" " ln = line.new(bar_index, close, bar_index + 1, close)\n" " array.push(a, ln)" ) - assert "double ln" in cpp or "auto ln" in cpp + # The loop-local declares as a Line handle and the ctor lowers onto the arena. + assert "Line ln = pf_line_new(_pf_lines_," in cpp + # The array is a std::vector; the handle pushes by value. + assert "std::vector" in cpp + assert "a.push_back(ln)" in cpp + # Drawing runtime is included + arena declared. + assert "#include " in cpp + assert "DrawingArena _pf_lines_" in cpp -def test_label_handle_in_loop_is_declared(): +def test_label_handle_in_loop_is_a_real_handle(): cpp = _cpp( "for k = 1 to 3\n" " lb = label.new(bar_index, high, 'x')\n" " label.delete(lb)" ) - assert "double lb" in cpp or "auto lb" in cpp + assert "Label lb = pf_label_new(_pf_labels_," in cpp + # label.delete(lb) lowers onto the arena (NOT the generic _delete_ rewrite). + assert "pf_label_delete(_pf_labels_, lb)" in cpp diff --git a/tests/test_support_checker.py b/tests/test_support_checker.py index 5610f85..06fb5cc 100644 --- a/tests/test_support_checker.py +++ b/tests/test_support_checker.py @@ -471,11 +471,104 @@ def test_plot_emits_warning_not_error(): assert any("visual only" in d.message for d in _warnings(src)) -def test_label_namespace_emits_warning_not_error(): - src = PRELUDE + 'label.new(bar_index, high, "x")\n' - # bar_index now warns (divergent); label.new also warns (visual). - warnings = _warnings(src) - assert any("visual only" in d.message for d in warnings) +def test_label_geometry_accepted_visual_setter_warns(): + # Drawing-objects-as-data: label.new is REAL geometry (a label in the + # per-type arena) — accepted, no hard error and no "visual only" warning. + assert _errors(PRELUDE + 'label.new(bar_index, high, "x")\n') == [] + # A pure-visual setter (label.set_color) is the part that is a no-op: it is + # accepted but warned, never rejected. + src2 = PRELUDE + 'lb = label.new(bar_index, high, "x")\nlabel.set_color(lb, color.red)\n' + assert _errors(src2) == [] + assert any("visual no-op" in d.message for d in _warnings(src2)) + # An UNKNOWN drawing method rejects loudly (not silently emitted). + assert _errors(PRELUDE + 'lb = label.new(bar_index, high, "x")\nlabel.bogus(lb)\n') + + +def test_udt_drawing_field_history_rejected(): + src = PRELUDE + """ +type DrawState + line ln = na +var DrawState d = DrawState.new() +d.ln := line.new(bar_index, close, bar_index + 1, close) +prev = d.ln[1] +""" + _expect_error(src, "UDT drawing fields") + + +def test_udt_array_drawing_field_history_rejected(): + src = PRELUDE + """ +type DrawState + array lines = na +var DrawState d = DrawState.new(array.new()) +prev = d.lines[1] +""" + _expect_error(src, "UDT drawing fields") + + +def test_udt_bracket_array_drawing_field_history_rejected(): + src = PRELUDE + """ +type DrawState + line[] lines = na +var DrawState d = DrawState.new(array.new()) +prev = d.lines[1] +""" + _expect_error(src, "UDT drawing fields") + + +def test_udt_drawing_field_current_bar_zero_allowed(): + src = PRELUDE + """ +type DrawState + line ln = na +var DrawState d = DrawState.new() +d.ln := line.new(bar_index, close, bar_index + 1, close) +same = d.ln[0] +""" + assert _errors(src) == [] + + +def test_udt_non_drawing_field_history_allowed(): + src = PRELUDE + """ +type State + float n = 0.0 +var State d = State.new() +prev = d.n[1] +""" + assert _errors(src) == [] + + +def test_tuple_destructured_drawing_handle_history_rejected(): + src = PRELUDE + """ +makePair() => [line.new(bar_index, close, bar_index + 1, close), line.new(bar_index, open, bar_index + 1, open)] +[la, lb] = makePair() +prev = la[1] +""" + _expect_error(src, "tuple-destructured drawing handles") + + +def test_tuple_literal_drawing_handle_history_rejected(): + src = PRELUDE + """ +[la, lb] = [line.new(bar_index, close, bar_index + 1, close), line.new(bar_index, open, bar_index + 1, open)] +prev = lb[1] +""" + _expect_error(src, "tuple-destructured drawing handles") + + +def test_tuple_ternary_drawing_handle_history_rejected(): + src = PRELUDE + """ +makePair() => [close > open ? line.new(bar_index, close, bar_index + 1, close) : na, line.new(bar_index, open, bar_index + 1, open)] +[la, lb] = makePair() +prev = la[1] +""" + _expect_error(src, "tuple-destructured drawing handles") + + +def test_numeric_tuple_element_history_still_allowed(): + src = PRELUDE + """ +makePair() => [close, open] +[a, b] = makePair() +prev = a[1] +""" + assert _errors(src) == [] # --------------------------------------------------------------------------- diff --git a/tests/test_udt_drawing_field_cleanup.py b/tests/test_udt_drawing_field_cleanup.py index 6d43535..9a4f993 100644 --- a/tests/test_udt_drawing_field_cleanup.py +++ b/tests/test_udt_drawing_field_cleanup.py @@ -1,20 +1,17 @@ -"""UDT with drawing-typed fields must compile cleanly even when downstream code -references the dropped field. +"""UDT with drawing-typed fields: the field is now REAL handle data. -Repro: when a Pine UDT contains a drawing-typed field (``label``, ``line``, -``box``, ``linefill``, ``polyline``, ``table``), codegen correctly omits the -field from the C++ struct emission (the engine doesn't model drawing objects). -But downstream ``m.tag := label.new(...)`` or ``x = m.tag`` references the -dropped field and the generated C++ would otherwise fail to compile. +Drawing-objects-as-data (spec §4.2 / §U): a UDT field of type ``line``/ +``box``/``label``/``linefill`` is no longer dropped from the emitted C++ +struct — it lowers to a plain handle struct member (``Line ln;`` / +``std::vector upln;``), and ``m.tag := label.new(...)`` / ``x = m.tag`` +become real field read/writes against the shared per-type arena. -The fix: track omitted fields per UDT and rewrite reads / strip writes so -the generated C++ never references a member that doesn't exist on the -emitted struct. +(``table``/``polyline`` fields are still dropped — they have no C++ type.) """ from pineforge_codegen import transpile -def test_udt_with_dropped_label_field_compiles(): +def test_udt_label_field_is_real_handle(): src = '''//@version=6 strategy("t") type Marker @@ -27,19 +24,15 @@ def test_udt_with_dropped_label_field_compiles(): plot(m.price) ''' cpp = transpile(src) - # Struct must NOT declare tag (existing behavior) - assert "label tag" not in cpp - # Downstream m.tag references must NOT appear as a raw struct member - # access (which would be a compile error). They must be commented out - # or rewritten to a placeholder expression. - for line in cpp.splitlines(): - stripped = line.strip() - if stripped.startswith("//") or stripped.startswith("/*"): - continue - assert "m.tag" not in stripped, f"bare m.tag reference in: {stripped!r}" + # The struct now declares a real Label handle member. + assert "Label tag" in cpp + # The field write lowers onto the arena, NOT a dropped placeholder. + assert "m.tag = pf_label_new(_pf_labels_," in cpp + # The field read is a plain handle copy (real member access survives). + assert "m.tag" in cpp -def test_udt_with_dropped_line_field_assignment_stripped(): +def test_udt_line_field_assignment_is_real(): src = '''//@version=6 strategy("t") type Segment @@ -52,15 +45,11 @@ def test_udt_with_dropped_line_field_assignment_stripped(): plot(s.p1) ''' cpp = transpile(src) - assert "line ln" not in cpp - for line in cpp.splitlines(): - stripped = line.strip() - if stripped.startswith("//") or stripped.startswith("/*"): - continue - assert "s.ln" not in stripped + assert "Line ln" in cpp + assert "s.ln = pf_line_new(_pf_lines_," in cpp -def test_udt_with_dropped_box_field_read_replaced(): +def test_udt_box_field_read_is_real(): src = '''//@version=6 strategy("t") type Region @@ -73,12 +62,9 @@ def test_udt_with_dropped_box_field_read_replaced(): plot(r.top) ''' cpp = transpile(src) - assert "box bx" not in cpp - for line in cpp.splitlines(): - stripped = line.strip() - if stripped.startswith("//") or stripped.startswith("/*"): - continue - assert "r.bx" not in stripped + assert "Box bx" in cpp + # The field read survives as a real handle member access. + assert "r.bx" in cpp def test_udt_without_drawing_fields_unchanged(): @@ -101,3 +87,5 @@ def test_udt_without_drawing_fields_unchanged(): # survives in the executable body (the plot of v depends on it). assert "p.x" in cpp assert "p.y" in cpp + # No drawing machinery for a non-drawing strategy. + assert "pineforge/drawing.hpp" not in cpp