@pineforge/codegen-pyodide 0.7.6 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,8 +66,8 @@ from __future__ import annotations
66
66
  from typing import Any
67
67
 
68
68
  from ..ast_nodes import (
69
- ASTNode, BoolLiteral, FuncCall, Identifier, MemberAccess,
70
- NumberLiteral, StringLiteral, TupleLiteral,
69
+ ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess,
70
+ NumberLiteral, StringLiteral, TupleLiteral, UnaryOp, VarDecl,
71
71
  )
72
72
  from ..symbols import PineType
73
73
  from .. import signatures as sigs
@@ -207,6 +207,7 @@ class CallHandlers:
207
207
  is_static=is_static,
208
208
  )
209
209
  self._ta_call_sites.append(site)
210
+ self._ta_member_names.add(site.member_name)
210
211
  return PineType.FLOAT
211
212
 
212
213
  # Determine constructor args
@@ -249,9 +250,29 @@ class CallHandlers:
249
250
  is_static=is_static,
250
251
  )
251
252
  self._ta_call_sites.append(site)
253
+ self._ta_member_names.add(site.member_name)
252
254
 
253
255
  return PineType.FLOAT
254
256
 
257
+ def _security_symbol_is_heikinashi(self, node, _seen=None) -> bool:
258
+ """True when a request.security symbol is ``ticker.heikinashi(<chart
259
+ symbol>)`` — directly, or via a global alias (``haTicker =
260
+ ticker.heikinashi(syminfo.tickerid)``). Name-cycle-guarded. The
261
+ support_checker has already rejected the cross-symbol HA case, so any HA
262
+ reaching here is the chart's own symbol."""
263
+ if _seen is None:
264
+ _seen = set()
265
+ if (isinstance(node, FuncCall) and isinstance(node.callee, MemberAccess)
266
+ and isinstance(node.callee.object, Identifier)
267
+ and node.callee.object.name == "ticker"
268
+ and node.callee.member == "heikinashi"):
269
+ return True
270
+ if (isinstance(node, Identifier) and node.name in self._global_expr_map
271
+ and node.name not in _seen):
272
+ _seen.add(node.name)
273
+ return self._security_symbol_is_heikinashi(self._global_expr_map[node.name], _seen)
274
+ return False
275
+
255
276
  def _handle_request_call(self, func_name: str, node: FuncCall) -> PineType:
256
277
  """Handle request.* function calls."""
257
278
  if func_name == "security":
@@ -293,6 +314,15 @@ class CallHandlers:
293
314
  lookahead_node = all_args[4] if len(all_args) > 4 else None
294
315
 
295
316
  mutable_globals = tuple(sorted(self._collect_security_mutable_globals(expr_node)))
317
+ # Heikin-Ashi same-symbol read: request.security(ticker.heikinashi(
318
+ # syminfo.tickerid), ...) (directly or via a global alias). The engine
319
+ # applies the HA candle transform inside the security eval.
320
+ symbol_node = all_args[0] if all_args else None
321
+ heikinashi = self._security_symbol_is_heikinashi(symbol_node)
322
+ # Capture the user function (if any) whose body contains this call,
323
+ # so the codegen can resolve a parameter ``tf`` via the call sites.
324
+ scope_name = self._symbols.current_scope.name
325
+ containing_func = scope_name[5:] if scope_name.startswith("func_") else ""
296
326
  self._security_calls.append(SecurityCallInfo(
297
327
  sec_id=sec_id,
298
328
  timeframe=tf_node,
@@ -302,8 +332,10 @@ class CallHandlers:
302
332
  gaps=gaps_node,
303
333
  lookahead=lookahead_node,
304
334
  ta_range=security_ta_range,
335
+ heikinashi=heikinashi,
305
336
  depends_on_mutable_globals=bool(mutable_globals),
306
337
  mutable_globals=mutable_globals,
338
+ containing_func=containing_func,
307
339
  ))
308
340
 
309
341
  return PineType.FLOAT
@@ -765,6 +797,57 @@ class CallHandlers:
765
797
  # User-defined function calls
766
798
  # ------------------------------------------------------------------
767
799
 
800
+ def _func_local_length_defs(self, func_def) -> dict[str, str]:
801
+ """Collect a user function's local scalar length-vars to their RHS
802
+ expression string, e.g. ``qqeCalc`` with ``wp = sf * 2 - 1`` returns
803
+ ``{"wp": "sf * 2 - 1"}``.
804
+
805
+ Only plain (non-``var``/``varip``) declarations whose RHS is a pure
806
+ arithmetic expression over identifiers/numbers (NumberLiteral, Identifier,
807
+ BinOp, UnaryOp, or a math.* FuncCall) qualify — these are the shapes that
808
+ can legitimately feed a TA constructor length. Series-valued locals (whose
809
+ RHS is a ta.* call, a subscript, a ternary, etc.) are skipped so we never
810
+ inline a price series into a ctor-length slot. Names reassigned with ``:=``
811
+ are also skipped (their value is not a stable compile-time length).
812
+ """
813
+ def _is_arith(n) -> bool:
814
+ if isinstance(n, (NumberLiteral, Identifier)):
815
+ return True
816
+ if isinstance(n, BinOp):
817
+ return _is_arith(n.left) and _is_arith(n.right)
818
+ if isinstance(n, UnaryOp):
819
+ return _is_arith(n.operand)
820
+ if isinstance(n, FuncCall):
821
+ # Allow math.* helpers (math.round/sqrt/...) over arith args.
822
+ callee = n.callee
823
+ if (isinstance(callee, MemberAccess)
824
+ and isinstance(callee.object, Identifier)
825
+ and callee.object.name == "math"):
826
+ return all(_is_arith(a) for a in n.args)
827
+ return False
828
+
829
+ reassigned: set[str] = set()
830
+ def _scan_reassign(stmts):
831
+ from ..ast_nodes import Assignment
832
+ for s in stmts or []:
833
+ if isinstance(s, Assignment) and isinstance(s.target, Identifier):
834
+ reassigned.add(s.target.name)
835
+ for attr in ("body", "else_body"):
836
+ sub = getattr(s, attr, None)
837
+ if isinstance(sub, list):
838
+ _scan_reassign(sub)
839
+ _scan_reassign(func_def.body)
840
+
841
+ defs: dict[str, str] = {}
842
+ for stmt in func_def.body or []:
843
+ if (isinstance(stmt, VarDecl)
844
+ and not stmt.is_var and not stmt.is_varip
845
+ and stmt.name not in reassigned
846
+ and stmt.value is not None
847
+ and _is_arith(stmt.value)):
848
+ defs[stmt.name] = self._expr_to_str(stmt.value)
849
+ return defs
850
+
768
851
  def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType:
769
852
  """Handle calls to user-defined functions."""
770
853
  func_def = self._func_defs[func_name]
@@ -783,14 +866,28 @@ class CallHandlers:
783
866
  # For now, use the cached return type from initial analysis
784
867
  return_type = self._func_return_types.get(func_name, PineType.FLOAT)
785
868
 
786
- # If the return type was UNKNOWN or VOID, infer from param types
869
+ # If the return type was UNKNOWN or VOID, infer it ONLY when the body
870
+ # is a single bare identifier that returns a parameter directly
871
+ # (``f(s) => s``). Inferring from params for arbitrary bodies misfires
872
+ # when a function merely HAS a string param but returns something else
873
+ # (e.g. ``getLineStyle(s) => switch s ... => line.style_solid`` or a
874
+ # body ending in ``label.new(...)``). Other cases rely on the cached
875
+ # body type plus udt_return_type / tuple inference.
787
876
  if return_type in (PineType.UNKNOWN, PineType.VOID):
788
- if any(t == PineType.STRING for t in param_types):
789
- return_type = PineType.STRING
790
- elif any(t == PineType.FLOAT for t in param_types):
791
- return_type = PineType.FLOAT
792
- elif any(t == PineType.INT for t in param_types):
793
- return_type = PineType.INT
877
+ if (func_def.is_single_expr and func_def.body
878
+ and isinstance(func_def.body[0], ExprStmt)
879
+ and isinstance(func_def.body[0].expr, Identifier)):
880
+ ret_name = func_def.body[0].expr.name
881
+ for idx, pname in enumerate(func_def.params):
882
+ if pname == ret_name and idx < len(param_types):
883
+ pt = param_types[idx]
884
+ if pt == PineType.STRING:
885
+ return_type = PineType.STRING
886
+ elif pt == PineType.INT:
887
+ return_type = PineType.INT
888
+ elif pt == PineType.FLOAT:
889
+ return_type = PineType.FLOAT
890
+ break
794
891
 
795
892
  # If this function has series params, ensure bar-field arguments
796
893
  # passed at the call site are registered as series_bar_fields so that
@@ -824,6 +921,16 @@ class CallHandlers:
824
921
  if has_ta:
825
922
  start, end = self._func_ta_ranges[func_name]
826
923
 
924
+ # Map of this function's local (non-param, non-series) derived
925
+ # length vars to their raw RHS expression strings, e.g.
926
+ # ``qqeCalc`` => ``wp = sf * 2 - 1`` -> {"wp": "sf * 2 - 1"}.
927
+ # A TA ctor arg captured as the bare local name ("wp") must be
928
+ # expanded to its definition so the subsequent param-substitution
929
+ # turns it into a class-scope expression ("rsiSmooth * 2 - 1")
930
+ # rather than leaving a dangling local that degenerates to
931
+ # period 1 in codegen.
932
+ local_defs = self._func_local_length_defs(func_def)
933
+
827
934
  def _subst_params(arg: str, pmap: dict[str, str]) -> str:
828
935
  """Substitute parameter names in an expression string.
829
936
 
@@ -837,23 +944,81 @@ class CallHandlers:
837
944
  result = re.sub(rf'\b{re.escape(param)}\b', value, result)
838
945
  return result
839
946
 
947
+ def _expand_locals(arg: str) -> str:
948
+ """Recursively expand function-local length vars to their RHS
949
+ (parenthesized) so only params / class-scope names remain."""
950
+ import re
951
+ if not local_defs:
952
+ return arg
953
+ for _ in range(32):
954
+ def _rep(m: re.Match) -> str:
955
+ nm = m.group(0)
956
+ if nm in local_defs:
957
+ return "(" + local_defs[nm] + ")"
958
+ return nm
959
+ new = re.sub(r"[A-Za-z_][A-Za-z_0-9]*", _rep, arg)
960
+ if new == arg:
961
+ break
962
+ arg = new
963
+ return arg
964
+
965
+ # Params of the function we are *currently inside* (if this is a
966
+ # nested user-func call). Used to detect when a substituted ctor
967
+ # arg becomes parameterized by the OUTER function, so the outer
968
+ # call site can resolve it (f_bbwp's _bbwLen -> i_bbwLen reaches
969
+ # f_basisMa's sites).
970
+ import re as _re
971
+ enclosing_params: set[str] = set()
972
+ for s in self._enclosing_func_params:
973
+ enclosing_params |= s
974
+
840
975
  if cs_idx == 0:
841
976
  # First call site: save original param-based ctor_args for future cloning,
842
977
  # then resolve to actual call-site values
843
978
  for i in range(start, end):
844
979
  site = self._ta_call_sites[i]
845
980
  if not hasattr(site, '_orig_ctor_args'):
846
- site._orig_ctor_args = site.ctor_args[:]
981
+ site._orig_ctor_args = [
982
+ _expand_locals(a) for a in site.ctor_args
983
+ ]
847
984
  site.ctor_args = [_subst_params(a, param_arg_map) for a in site._orig_ctor_args]
985
+ # If a substituted arg is now expressed in terms of an
986
+ # enclosing function's params, promote it to the original
987
+ # so the enclosing call re-substitutes, and mark the site
988
+ # so the enclosing function's TA range widens to cover it.
989
+ if enclosing_params and self._nested_ta_touched is not None:
990
+ for a in site.ctor_args:
991
+ toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", a))
992
+ if toks & enclosing_params:
993
+ site._orig_ctor_args = list(site.ctor_args)
994
+ self._nested_ta_touched.add(i)
995
+ break
848
996
  else:
849
997
  # Subsequent call sites: clone using saved original param names,
850
998
  # substituted with this call site's arguments
999
+ clone_name_map: dict[str, str] = {}
851
1000
  for i in range(start, end):
852
1001
  orig = self._ta_call_sites[i]
853
1002
  orig_args = getattr(orig, '_orig_ctor_args', orig.ctor_args)
854
1003
  resolved_ctor = [_subst_params(a, param_arg_map) for a in orig_args]
1004
+ # Default name follows the ``{base}_cs{cs_idx}`` formula the
1005
+ # codegen re-derives. But the SAME base TA site can be reached
1006
+ # through more than one enclosing function (e.g. a helper cloned
1007
+ # both via its own call sites AND via a range-widened outer
1008
+ # function), so two distinct (func, cs_idx) namespaces can mint
1009
+ # the same name. Detect that collision and fall back to a
1010
+ # globally-unique name; record the chosen name so the codegen
1011
+ # consumes it verbatim (see _func_cs_ta_clone_names).
1012
+ clone_name = f"{orig.member_name}_cs{cs_idx}"
1013
+ if clone_name in self._ta_member_names:
1014
+ base = clone_name
1015
+ n = 2
1016
+ while clone_name in self._ta_member_names:
1017
+ clone_name = f"{base}_u{n}"
1018
+ n += 1
1019
+ clone_name_map[orig.member_name] = clone_name
855
1020
  cloned = TACallSite(
856
- member_name=f"{orig.member_name}_cs{cs_idx}",
1021
+ member_name=clone_name,
857
1022
  class_name=orig.class_name,
858
1023
  ctor_args=resolved_ctor,
859
1024
  compute_args=orig.compute_args[:],
@@ -862,6 +1027,9 @@ class CallHandlers:
862
1027
  is_static=orig.is_static,
863
1028
  )
864
1029
  self._ta_call_sites.append(cloned)
1030
+ self._ta_member_names.add(clone_name)
1031
+ if clone_name_map:
1032
+ self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map
865
1033
 
866
1034
  # Create or update FuncInfo
867
1035
  is_tuple = self._func_returns_tuple.get(func_name, False)
@@ -869,6 +1037,15 @@ class CallHandlers:
869
1037
  # Forward UDT-return inference (set in _visit_FuncDef) so codegen can
870
1038
  # emit the struct return type. Probe: udt-method-probe-20.
871
1039
  udt_ret = self._func_udt_return_types.get(func_name)
1040
+ ret_spec = getattr(self, "_func_return_type_specs", {}).get(func_name)
1041
+ # Per-param TypeSpec: declared hints are authoritative; for untyped
1042
+ # params, infer from the call-site argument's type_spec (so an untyped
1043
+ # ``s`` used as a string, or a UDT passed by value, emits correctly).
1044
+ param_specs = self._param_type_specs_from_def(func_def)
1045
+ arg_specs = [self._type_spec_from_expr(arg) for arg in node.args]
1046
+ for i in range(len(param_specs)):
1047
+ if param_specs[i] is None and i < len(arg_specs):
1048
+ param_specs[i] = arg_specs[i]
872
1049
  existing = [fi for fi in self._func_infos if fi.name == func_name]
873
1050
  if not existing:
874
1051
  fi = FuncInfo(
@@ -879,6 +1056,8 @@ class CallHandlers:
879
1056
  returns_tuple=is_tuple,
880
1057
  tuple_element_count=tuple_count,
881
1058
  udt_return_type=udt_ret,
1059
+ param_type_specs=param_specs,
1060
+ return_type_spec=ret_spec,
882
1061
  )
883
1062
  self._func_infos.append(fi)
884
1063
  else:
@@ -889,7 +1068,17 @@ class CallHandlers:
889
1068
  for i, pt in enumerate(param_types):
890
1069
  if i < len(fi.param_types) and fi.param_types[i] == PineType.UNKNOWN:
891
1070
  fi.param_types[i] = pt
1071
+ # Merge per-param TypeSpecs: keep declared hints (authoritative),
1072
+ # fill untyped slots from this call site if still unknown.
1073
+ if not fi.param_type_specs:
1074
+ fi.param_type_specs = list(param_specs)
1075
+ else:
1076
+ for i in range(len(param_specs)):
1077
+ if i < len(fi.param_type_specs) and fi.param_type_specs[i] is None:
1078
+ fi.param_type_specs[i] = param_specs[i]
892
1079
  if fi.udt_return_type is None and udt_ret is not None:
893
1080
  fi.udt_return_type = udt_ret
1081
+ if fi.return_type_spec is None and ret_spec is not None:
1082
+ fi.return_type_spec = ret_spec
894
1083
 
895
1084
  return return_type
@@ -65,6 +65,21 @@ class FuncInfo:
65
65
  # ``Sample s = build_sample(...)`` then ``s.score()`` dispatches
66
66
  # correctly. Probe: data/validation/udt-method-probe-20-udt-return-from-func.
67
67
  udt_return_type: str | None = None
68
+ # Parallel to ``node.params``; each entry is a ``TypeSpec`` (or ``None``)
69
+ # carrying UDT / drawing-handle / precise-scalar typing that the coarse
70
+ # ``param_types`` (PineType) cannot represent. Populated from the
71
+ # function's declared parameter type hints (authoritative) and, for
72
+ # untyped params, from the call-site argument type. The codegen prefers
73
+ # this over ``param_types`` when emitting each parameter's C++ type so a
74
+ # ``pivot hi`` parameter emits as ``pivot hi`` (not ``double hi``) and an
75
+ # untyped ``s`` used as a string emits as ``std::string s``.
76
+ param_type_specs: list = field(default_factory=list)
77
+ # ``TypeSpec`` of the function's return value when it is a collection the
78
+ # coarse ``return_type`` (PineType) cannot represent — today this covers
79
+ # array-returning functions (``buildPDLevels() => array.from(...)`` ->
80
+ # ``std::vector<double>``). UDT / drawing-handle returns use
81
+ # ``udt_return_type``; tuple returns use ``returns_tuple``.
82
+ return_type_spec: Any = None
68
83
 
69
84
 
70
85
  @dataclass
@@ -104,9 +119,33 @@ class SecurityCallInfo:
104
119
  gaps: Any = None
105
120
  lookahead: Any = None
106
121
  ta_range: Any = None
122
+ # request.security(ticker.heikinashi(syminfo.tickerid), ...) — the chart's own
123
+ # symbol read as Heikin-Ashi candles. The engine applies the HA OHLC transform
124
+ # to each (aggregated) bar before evaluating the security expression; the
125
+ # expression's close/open/high/low therefore see HA values. Same-symbol only.
126
+ heikinashi: bool = False
107
127
  depends_on_mutable_globals: bool = False
108
128
  mutable_globals: tuple[str, ...] = ()
109
129
  is_lower_tf_array: bool = False
130
+ # Name of the user function whose body contains this call ("" at global
131
+ # scope). A ``request.security(sym, tf, ...)`` whose ``tf`` is that
132
+ # function's parameter cannot be resolved at class scope (the security
133
+ # evaluator is a class method, not the function body) — the codegen resolves
134
+ # such a param-tf from the function's call sites instead.
135
+ containing_func: str = ""
136
+ # Set when this SecurityCallInfo is one of N per-call-site CLONES of a
137
+ # single source request.security(...) whose ``timeframe`` is a UDF
138
+ # parameter called from >= 2 sites with >= 2 distinct literal timeframes
139
+ # (e.g. ``scoreFromRange(tf) => request.security(sym, tf, ...)`` called as
140
+ # ``scoreFromRange("15")``, ``scoreFromRange("240")``, ...). Each clone's
141
+ # ``timeframe`` is rewritten to a literal StringLiteral for its call site
142
+ # and gets its own unique ``sec_id``; ``callsite_idx`` is the SAME index
143
+ # ``func_call_cs_map`` assigns that call site for the existing per-
144
+ # call-site UDF-body-cloning mechanism, so the codegen can pick the right
145
+ # clone's ``sec_id`` while emitting that call site's specialized function
146
+ # body (``self._active_call_site_idx``). None for an ordinary
147
+ # (non-cloned) security call.
148
+ callsite_idx: int | None = None
110
149
 
111
150
 
112
151
  @dataclass
@@ -135,6 +174,21 @@ class AnalyzerContext:
135
174
  func_ta_ranges: dict = field(default_factory=dict) # func_name -> (start_idx, end_idx)
136
175
  func_call_cs_map: dict = field(default_factory=dict) # call_node_id -> (func_name, call_site_index)
137
176
  func_call_site_counts: dict = field(default_factory=dict) # func_name -> int
177
+ # Functions that need per-call-site body cloning PURELY because they
178
+ # contain a request.security(...) whose timeframe is a UDF parameter
179
+ # called with >= 2 distinct literal timeframes (the security-tf-
180
+ # monomorphization case), even though they have no TA call sites or
181
+ # series/var members of their own (the usual trigger for cloning). The
182
+ # codegen's function-emission gate ORs this in alongside has_ta/
183
+ # has_series so self._active_call_site_idx is actually set while it
184
+ # emits each call site's body — required for the per-clone
185
+ # SecurityCallInfo.callsite_idx disambiguation in visit_call.py to work.
186
+ func_security_clone_only: set = field(default_factory=set)
187
+ # (func_name, cs_idx) -> {orig_member_name: cloned_member_name}. Populated by
188
+ # the analyzer ONLY for clones whose default ``{base}_cs{cs_idx}`` name would
189
+ # collide with a clone minted through another enclosing function; lets codegen
190
+ # use the disambiguated name instead of re-deriving a colliding one.
191
+ func_cs_ta_clone_names: dict = field(default_factory=dict)
138
192
  # UDT / enum definitions:
139
193
  udt_defs: dict = field(default_factory=dict) # type_name -> {field_name: PineType}
140
194
  enum_defs: dict = field(default_factory=dict) # enum_name -> [member names]
@@ -145,12 +199,20 @@ class AnalyzerContext:
145
199
  # Per-function var_members + series_vars (used when emitting per-function call-site variants):
146
200
  func_var_members: dict = field(default_factory=dict)
147
201
  func_series_vars: dict = field(default_factory=dict)
202
+ # Per-function array-return TypeSpec (see FuncInfo.return_type_spec).
203
+ func_return_type_specs: dict = field(default_factory=dict)
148
204
  # var_name -> UDT type name for variables instantiated via TypeName.new(...)
149
205
  udt_var_types: dict[str, str] = field(default_factory=dict)
150
206
  # var_name -> structured collection/UDT type metadata
151
207
  collection_types: dict[str, TypeSpec] = field(default_factory=dict)
152
208
  # UDT name -> field_name -> structured type metadata
153
209
  udt_field_type_specs: dict[str, dict[str, TypeSpec]] = field(default_factory=dict)
210
+ # id(block_node) -> {raw_var_name: scope_unique_member_name} for block-scoped
211
+ # ``var``/``varip`` declarations whose raw name collides with a same-named
212
+ # block var in a sibling scope. Codegen activates the rename via
213
+ # ``_active_var_remap`` while emitting that block's statements so reads/writes
214
+ # of the var resolve to the disambiguated member.
215
+ block_var_renames: dict[int, dict[str, str]] = field(default_factory=dict)
154
216
  # ``// @pf-trace name=expr`` pragmas in source order. Populated by
155
217
  # :func:`pineforge_codegen.pragmas.extract_pf_trace_pragmas` from
156
218
  # the original source text and attached after :class:`Analyzer`
@@ -45,6 +45,12 @@ from ..ast_nodes import (
45
45
  )
46
46
  from ..symbols import PineType, TypeSpec
47
47
 
48
+ # Drawing-objects-as-data type names (spec §4.1). Defined locally — the
49
+ # analyzer must not import from ``codegen`` (codegen imports analyzer, so the
50
+ # reverse would be a cycle). Mirrors codegen.tables.DRAWING_TYPE_TO_CPP keys.
51
+ _DRAWING_TYPE_NAMES = frozenset({"line", "box", "label", "linefill", "chart.point"})
52
+ _DRAWING_NS = frozenset({"line", "box", "label", "linefill"})
53
+
48
54
 
49
55
  class TypeHelper:
50
56
  """Pine type-hint / expression inference.
@@ -100,8 +106,28 @@ class TypeHelper:
100
106
  return TypeSpec.map(key, val)
101
107
  if hint in self._udt_fields:
102
108
  return TypeSpec.udt(hint)
109
+ # Drawing-objects-as-data (P3): scalar ``line``/``box``/``label``/
110
+ # ``linefill``/``chart.point`` carry the handle identity via a udt
111
+ # TypeSpec. Without this the analyzer field-spec filter (base.py ~847)
112
+ # erases a scalar drawing field, collapsing it to double. Drawing names
113
+ # are NOT in _udt_fields.
114
+ if hint in _DRAWING_TYPE_NAMES:
115
+ return TypeSpec.udt(hint)
103
116
  return None
104
117
 
118
+ def _param_type_specs_from_def(self, func_def) -> list:
119
+ """Per-parameter ``TypeSpec`` (or ``None``) from a function's DECLARED
120
+ parameter type hints — the authoritative source for typed params
121
+ (``pivot hi``, ``string tf``, ``line[] arr``). Untyped params are
122
+ ``None`` here so regular-function call-site inference can fill them.
123
+ """
124
+ hints = (getattr(func_def, "annotations", None) or {}).get("param_type_hints", [])
125
+ specs: list = []
126
+ for i in range(len(func_def.params)):
127
+ hint = hints[i] if i < len(hints) else None
128
+ specs.append(self._type_spec_from_hint(hint) if hint else None)
129
+ return specs
130
+
105
131
  def _template_args_from_call(self, node: FuncCall) -> list[str]:
106
132
  callee = node.callee
107
133
  ann = getattr(callee, "annotations", None) or {}
@@ -116,6 +142,17 @@ class TypeHelper:
116
142
  func = cal.member if isinstance(cal, MemberAccess) else None
117
143
  ns = cal.object.name if isinstance(cal, MemberAccess) and isinstance(cal.object, Identifier) else None
118
144
  targs = self._template_args_from_call(value)
145
+ # Drawing-objects-as-data return typing: *.new / *.copy -> handle of
146
+ # the self-type; linefill.get_line* -> line; chart.point.* -> point.
147
+ if ns in _DRAWING_NS:
148
+ if func in ("new", "copy"):
149
+ return TypeSpec.udt(ns)
150
+ if ns == "linefill" and func in ("get_line1", "get_line2"):
151
+ return TypeSpec.udt("line")
152
+ if (isinstance(cal, MemberAccess) and isinstance(cal.object, MemberAccess)
153
+ and isinstance(cal.object.object, Identifier)
154
+ and cal.object.object.name == "chart" and cal.object.member == "point"):
155
+ return TypeSpec.udt("chart.point")
119
156
  if ns == "array" and func in ("new", "new_float", "new_int", "new_bool", "new_string", "from"):
120
157
  if func == "new_float":
121
158
  return TypeSpec.array(TypeSpec.primitive("float"))
@@ -194,10 +231,26 @@ class TypeHelper:
194
231
  return recv_spec.element
195
232
  if func == "eigenvalues":
196
233
  return TypeSpec.array(TypeSpec.primitive("float"))
234
+ # Drawing method-form: a.copy() -> same handle; lf.get_line*() -> line.
235
+ if (recv_spec is not None and recv_spec.kind == "udt"
236
+ and recv_spec.name in _DRAWING_TYPE_NAMES):
237
+ if func == "copy":
238
+ return recv_spec
239
+ if recv_spec.name == "linefill" and func in ("get_line1", "get_line2"):
240
+ return TypeSpec.udt("line")
197
241
  if isinstance(value, Identifier):
198
242
  sym = self._symbols.resolve(value.name)
199
243
  if sym is not None and sym.type_spec is not None:
200
244
  return sym.type_spec
245
+ if isinstance(value, FuncCall):
246
+ # User-function return spec (e.g. an array-returning
247
+ # ``buildPDLevels() => array.from(...)``), so a caller's
248
+ # ``allLevels = buildPDLevels()`` infers an array TypeSpec.
249
+ cal = value.callee
250
+ fname = cal.member if isinstance(cal, MemberAccess) else (
251
+ cal.name if isinstance(cal, Identifier) else None)
252
+ if fname and fname in getattr(self, "_func_return_type_specs", {}):
253
+ return self._func_return_type_specs[fname]
201
254
  if isinstance(value, MemberAccess):
202
255
  owner = self._type_spec_from_expr(value.object)
203
256
  if owner is not None and owner.kind == "udt" and owner.name:
@@ -41,6 +41,8 @@ from .tables import (
41
41
  SYMINFO_MEMBER_MAP,
42
42
  COLOR_CONST_MAP,
43
43
  ARRAY_METHODS,
44
+ ARRAY_DRAWING_NEW_CTORS,
45
+ ARRAY_NEW_CTORS,
44
46
  MAP_METHODS,
45
47
  MATRIX_METHODS,
46
48
  MATRIX_METHOD_KWARGS,
@@ -69,6 +71,8 @@ __all__ = [
69
71
  "SYMINFO_MEMBER_MAP",
70
72
  "COLOR_CONST_MAP",
71
73
  "ARRAY_METHODS",
74
+ "ARRAY_DRAWING_NEW_CTORS",
75
+ "ARRAY_NEW_CTORS",
72
76
  "MAP_METHODS",
73
77
  "MATRIX_METHODS",
74
78
  "MATRIX_METHOD_KWARGS",