Skip to content
711 changes: 704 additions & 7 deletions pineforge_codegen/analyzer/base.py

Large diffs are not rendered by default.

114 changes: 93 additions & 21 deletions pineforge_codegen/analyzer/call_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1088,15 +1088,52 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType:
"""Handle calls to user-defined functions."""
func_def = self._func_defs[func_name]

# Visit the call args
arg_types = []
# Visit every supplied argument in Pine source order, then bind both
# positional and keyword forms into declaration order. The old
# positional-only path skipped kwargs entirely, hiding nested calls and
# leaving deferred map-history validation without a concrete TypeSpec.
bound_args = self._bind_callable_args(node, list(func_def.params))
visited_types: dict[int, PineType] = {}
for arg in node.args:
arg_types.append(self._visit(arg))
visited_types[id(arg)] = self._visit(arg)
for arg in node.kwargs.values():
visited_types[id(arg)] = self._visit(arg)

# Determine param types from call-site args
param_types = arg_types[:len(func_def.params)]
while len(param_types) < len(func_def.params):
param_types.append(PineType.UNKNOWN)
param_types = [
visited_types.get(id(arg), PineType.UNKNOWN)
if arg is not None
else PineType.UNKNOWN
for arg in bound_args
]

# Per-param TypeSpec: declared hints are authoritative; for untyped
# params, infer from this call site's argument. Validate deferred
# history receivers before series propagation/cloning can reinterpret
# a map handle (or map-bearing UDT) as ``Series<double>``.
param_specs = list(
getattr(self, "_func_param_type_specs", {}).get(func_name)
or self._param_type_specs_from_def(func_def)
)
arg_specs = [
self._type_spec_from_expr(arg) if arg is not None else None
for arg in bound_args
]
for i in range(len(param_specs)):
if param_specs[i] is None and i < len(arg_specs):
param_specs[i] = arg_specs[i]
self._record_deferred_param_call_edge(
node,
func_name,
list(func_def.params),
bound_args,
)
self._validate_deferred_param_history_refs(
func_name,
{
name: spec
for name, spec in zip(func_def.params, param_specs)
},
)

# Determine return type: re-analyze the function body with known param types
# For now, use the cached return type from initial analysis
Expand Down Expand Up @@ -1131,8 +1168,10 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType:
func_sv = self._func_series_vars.get(func_name, set())
if func_sv:
for p_idx, param_name in enumerate(func_def.params):
if param_name in func_sv and p_idx < len(node.args):
arg = node.args[p_idx]
if param_name in func_sv and p_idx < len(bound_args):
arg = bound_args[p_idx]
if arg is None:
continue
if isinstance(arg, Identifier) and arg.name in BAR_FIELDS:
self._series_bar_fields.add(arg.name)
elif isinstance(arg, Identifier):
Expand Down Expand Up @@ -1165,17 +1204,6 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType:
# Forward UDT-return inference (set in _visit_FuncDef) so codegen can
# emit the struct return type. Probe: udt-method-probe-20.
udt_ret = self._func_udt_return_types.get(func_name)
# Per-param TypeSpec: declared hints are authoritative; for untyped
# params, infer from the call-site argument's type_spec (so an untyped
# ``s`` used as a string, or a UDT passed by value, emits correctly).
param_specs = list(
getattr(self, "_func_param_type_specs", {}).get(func_name)
or self._param_type_specs_from_def(func_def)
)
arg_specs = [self._type_spec_from_expr(arg) for arg in node.args]
for i in range(len(param_specs)):
if param_specs[i] is None and i < len(arg_specs):
param_specs[i] = arg_specs[i]
existing = [fi for fi in self._func_infos if fi.name == func_name]

# A direct terminal map call on an untyped parameter cannot be typed
Expand All @@ -1196,6 +1224,37 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType:
},
)
ret_spec = getattr(self, "_func_return_type_specs", {}).get(func_name)
terminal_expr = self._direct_terminal_return_expr(func_def)
terminal_selection_spec = self._terminal_map_selection_return_spec(
terminal_expr,
{
name: spec
for name, spec in zip(
func_def.params, effective_param_specs
)
},
)
if (
terminal_map_return is None
and isinstance(terminal_expr, Identifier)
and terminal_expr.name in func_def.params
):
terminal_index = func_def.params.index(terminal_expr.name)
terminal_identity_spec = (
effective_param_specs[terminal_index]
if terminal_index < len(effective_param_specs)
else None
)
if (terminal_identity_spec is not None
and terminal_identity_spec.kind == "map"):
# An untyped identity UDF learns the map handle type from its
# call site. The handle is returned by value, preserving the
# backing ID rather than cloning map contents.
self._func_return_type_specs[func_name] = terminal_identity_spec
ret_spec = terminal_identity_spec
if terminal_selection_spec is not None:
self._func_return_type_specs[func_name] = terminal_selection_spec
ret_spec = terminal_selection_spec
if terminal_map_return is not None:
return_type, inferred_ret_spec = terminal_map_return
self._func_return_types[func_name] = return_type
Expand Down Expand Up @@ -1240,4 +1299,17 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType:
if fi.return_type_spec is None and ret_spec is not None:
fi.return_type_spec = ret_spec

return return_type
# A concrete map can arrive only at an outer wrapper's eventual call
# site, after every nested untyped UDF was initially analyzed with
# scalar fallbacks. Propagate the concrete specs through the exact
# definition-time call edges and infer returns in post-order before
# codegen snapshots the FuncInfo table.
self._propagate_deferred_map_callable_specs(
func_name,
{
name: spec
for name, spec in zip(func_def.params, param_specs)
},
)

return self._func_return_types.get(func_name, return_type)
172 changes: 169 additions & 3 deletions pineforge_codegen/analyzer/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@
from typing import Any

from ..ast_nodes import (
ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess,
NaLiteral, NumberLiteral, StringLiteral, TupleLiteral, UnaryOp,
ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, IfStmt,
MemberAccess, NaLiteral, NumberLiteral, StringLiteral, Ternary,
TupleLiteral, UnaryOp,
)
from ..symbols import PineType, TypeSpec

Expand Down Expand Up @@ -181,6 +182,51 @@ def _array_from_element_spec(self, value: ASTNode | None) -> TypeSpec | None:
def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None:
if value is None:
return None
if isinstance(value, Ternary):
true_spec = self._type_spec_from_expr(value.true_val)
false_spec = self._type_spec_from_expr(value.false_val)
if (true_spec is not None
and true_spec.kind == "map"
and true_spec == false_spec):
return true_spec
# A Pine ``na`` arm acquires the other arm's map type. Keep this
# narrow to maps so unrelated scalar/array inference and generated
# output retain their established behavior.
if (true_spec is not None
and true_spec.kind == "map"
and isinstance(value.false_val, NaLiteral)):
return true_spec
if (false_spec is not None
and false_spec.kind == "map"
and isinstance(value.true_val, NaLiteral)):
return false_spec
return None
if isinstance(value, IfStmt):
def terminal_expr(body):
if not body:
return None
terminal = body[-1]
return terminal.expr if isinstance(terminal, ExprStmt) else terminal

true_node = terminal_expr(value.body)
false_node = terminal_expr(value.else_body)
if true_node is None or false_node is None:
return None
true_spec = self._type_spec_from_expr(true_node)
false_spec = self._type_spec_from_expr(false_node)
if (true_spec is not None
and true_spec.kind == "map"
and true_spec == false_spec):
return true_spec
if (true_spec is not None
and true_spec.kind == "map"
and isinstance(false_node, NaLiteral)):
return true_spec
if (false_spec is not None
and false_spec.kind == "map"
and isinstance(true_node, NaLiteral)):
return false_spec
return None
if isinstance(value, FuncCall):
cal = value.callee
func = cal.member if isinstance(cal, MemberAccess) else None
Expand Down Expand Up @@ -237,6 +283,28 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None:
key = self._type_spec_from_hint(targs[0]) if len(targs) > 0 else TypeSpec.primitive("string")
val = self._type_spec_from_hint(targs[1]) if len(targs) > 1 else TypeSpec.primitive("float")
return TypeSpec.map(key or TypeSpec.primitive("string"), val or TypeSpec.primitive("float"))
if ns == "map" and func in {
"put", "get", "remove", "contains", "size", "keys",
"values", "copy", "put_all", "clear",
} and value.args:
recv_spec = self._type_spec_from_expr(value.args[0])
if recv_spec is not None and recv_spec.kind == "map":
if func in ("put", "get", "remove"):
return recv_spec.value
if func == "keys":
return TypeSpec.array(
recv_spec.key or TypeSpec.primitive("string")
)
if func == "values":
return TypeSpec.array(
recv_spec.value or TypeSpec.primitive("float")
)
if func == "copy":
return recv_spec
if func == "contains":
return TypeSpec.primitive("bool")
if func == "size":
return TypeSpec.primitive("int")
if ns == "str" and func == "split":
return TypeSpec.array(TypeSpec.primitive("string"))
if ns == "ta" and func == "pivot_point_levels":
Expand All @@ -263,12 +331,18 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None:
if func in ("copy", "slice"):
return recv_spec
if recv_spec is not None and recv_spec.kind == "map":
if func in ("get", "remove"):
if func in ("put", "get", "remove"):
return recv_spec.value
if func == "keys":
return TypeSpec.array(recv_spec.key or TypeSpec.primitive("string"))
if func == "values":
return TypeSpec.array(recv_spec.value or TypeSpec.primitive("float"))
if func == "copy":
return recv_spec
if func == "contains":
return TypeSpec.primitive("bool")
if func == "size":
return TypeSpec.primitive("int")
if recv_spec is not None and recv_spec.kind == "matrix":
if func in ("copy", "submatrix", "transpose", "concat"):
return recv_spec
Expand All @@ -278,6 +352,24 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None:
return recv_spec.element
if func == "eigenvalues":
return TypeSpec.array(TypeSpec.primitive("float"))
if (recv_spec is not None
and recv_spec.kind == "udt"
and recv_spec.name):
method_key = f"{recv_spec.name}.{func}"
method_info = next(
(
info
for info in getattr(self, "_func_infos", ())
if info.name == method_key
and getattr(info, "is_udt_method", False)
),
None,
)
return_spec = getattr(
method_info, "return_type_spec", None
)
if return_spec is not None:
return return_spec
# 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):
Expand Down Expand Up @@ -316,6 +408,80 @@ def _direct_terminal_return_expr(func_def) -> ASTNode | None:
return last_stmt
return None

def _terminal_map_selection_return_spec(
self,
value: ASTNode | None,
parameter_specs: dict[str, TypeSpec | None],
) -> TypeSpec | None:
"""Infer a map returned by a terminal ternary/block selection.

Untyped UDF parameters do not have a lexical ``TypeSpec`` while their
definition is first analyzed. At a concrete call site, however, the
argument specs are known. Propagate those specs through only the two
Pine selection shapes whose result type is determined by compatible
branches: ``condition ? a : b`` and terminal ``if`` expressions.

A direct ``na`` arm is context-typed by the opposite map arm. All
other unresolved or incompatible shapes return ``None`` so this helper
cannot turn a scalar/non-map UDF into a map-returning function.
"""

def branch_terminal(node: ASTNode | None) -> ASTNode | None:
if not isinstance(node, IfStmt):
return node
if not node.body or not node.else_body:
return None
return node

def body_terminal(body: list[ASTNode] | None) -> ASTNode | None:
if not body:
return None
terminal = body[-1]
return terminal.expr if isinstance(terminal, ExprStmt) else terminal

def resolve(node: ASTNode | None) -> TypeSpec | None:
if node is None or isinstance(node, NaLiteral):
return None
if isinstance(node, Identifier) and node.name in parameter_specs:
spec = parameter_specs[node.name]
return spec if spec is not None and spec.kind == "map" else None
if isinstance(node, Ternary):
return compatible(node.true_val, node.false_val)
if isinstance(node, IfStmt):
return compatible(
body_terminal(node.body),
body_terminal(node.else_body),
)
spec = self._type_spec_from_expr(node)
return spec if spec is not None and spec.kind == "map" else None

def compatible(
left_node: ASTNode | None,
right_node: ASTNode | None,
) -> TypeSpec | None:
left_node = branch_terminal(left_node)
right_node = branch_terminal(right_node)
if left_node is None or right_node is None:
return None
left = resolve(left_node)
right = resolve(right_node)
if left is not None and right is not None:
return left if left == right else None
if left is not None and isinstance(right_node, NaLiteral):
return left
if right is not None and isinstance(left_node, NaLiteral):
return right
return None

if isinstance(value, Ternary):
return compatible(value.true_val, value.false_val)
if isinstance(value, IfStmt):
return compatible(
body_terminal(value.body),
body_terminal(value.else_body),
)
return None

def _terminal_map_call_return(
self,
value: ASTNode | None,
Expand Down
Loading
Loading