From 8efd12add60960c0292553e827ca29004674237b Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 28 Mar 2026 21:13:26 +0100 Subject: [PATCH] Add call hooks for series and push Introduces `CallHooks` to enable extensions to the type inference and AST finalization process. This allows for more dynamic and configurable behavior without hardcoding symbol names in the compiler core. Specifically, this commit adds: - **`series_post_call`**: A hook for the `(series n)` function. It ensures that the return type is a fresh `TypeVar` when the element type is `Any`, allowing for proper type inference from subsequent `push` calls. - **`series_finalize`**: A hook for `(series n)` that rewrites the AST node. It replaces the generic `series` call with a pre-configured factory closure that allocates the correct series storage type (e.g., `ScalarSeries` for floats, `RecordSeries` for records). This avoids runtime dispatch and relies on type information captured during compilation. - **`push_post_call`**: A hook for the `(push series val)` function. It unifies the series element `TypeVar` with the pushed value's type. It also handles numeric and record field promotion and updates the `TypeVar` binding if a promotion occurs. --- src/ast/compiler/analyzer.rs | 9 +- src/ast/compiler/call_hooks.rs | 65 ++++++++ src/ast/compiler/mod.rs | 1 + src/ast/compiler/type_checker.rs | 248 +++++++++++++------------------ src/ast/environment.rs | 50 +++++-- src/ast/rtl/docs.rs | 31 +++- src/ast/rtl/series/mod.rs | 209 ++++++++++++++++++++------ 7 files changed, 408 insertions(+), 205 deletions(-) create mode 100644 src/ast/compiler/call_hooks.rs diff --git a/src/ast/compiler/analyzer.rs b/src/ast/compiler/analyzer.rs index 6b850c5..2f2411e 100644 --- a/src/ast/compiler/analyzer.rs +++ b/src/ast/compiler/analyzer.rs @@ -2,7 +2,7 @@ use crate::ast::nodes::{ Address, AnalyzedNode, GlobalIdx, IdentifierBinding, Node, NodeKind, NodeMetrics, TypedNode, TypedPhase, }; -use crate::ast::types::{Identity, Purity}; +use crate::ast::types::{Identity, Purity, Value}; use std::collections::{HashMap, HashSet}; use std::rc::Rc; @@ -70,7 +70,12 @@ impl<'a> Analyzer<'a> { let mut is_recursive = false; let (new_kind, purity) = match &node.kind { - NodeKind::Constant(v) => (NodeKind::Constant(v.clone()), Purity::Pure), + // Propagate the declared purity of function constants so the constant + // folder does not evaluate impure factory closures at compile time. + NodeKind::Constant(v) => { + let purity = if let Value::Function(f) = v { f.purity } else { Purity::Pure }; + (NodeKind::Constant(v.clone()), purity) + } NodeKind::Nop => (NodeKind::Nop, Purity::Pure), NodeKind::Identifier { symbol, binding } => { diff --git a/src/ast/compiler/call_hooks.rs b/src/ast/compiler/call_hooks.rs new file mode 100644 index 0000000..cee2405 --- /dev/null +++ b/src/ast/compiler/call_hooks.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; +use std::rc::Rc; + +use crate::ast::diagnostics::Diagnostics; +use crate::ast::nodes::{Address, NodeKind, TypedNode, TypedPhase, VirtualId}; +use crate::ast::types::StaticType; + +/// Gives a call-hook read/write access to the current scope's slot types without +/// exposing `TypeContext` internals. +pub trait TypeSlotAccess { + fn get_slot_type(&self, addr: Address) -> StaticType; + fn set_slot_type(&mut self, addr: Address, ty: StaticType); +} + +/// Gives a call-hook access to the type-checker's core inference operations without +/// depending on the concrete `TypeChecker` type (avoids a circular module dependency). +pub trait TypeInferenceAccess { + /// Allocate a fresh unique `TypeVar`. + fn fresh_var(&self) -> StaticType; + + /// Unify two types, recording the substitution. Emits a diagnostic on conflict. + fn unify(&self, a: StaticType, b: StaticType, diag: &mut Diagnostics); + + /// Directly bind a `TypeVar` ID to a resolved type in the substitution map. + fn bind_typevar(&self, id: u32, ty: StaticType); + + /// Apply the current substitution to a type, resolving all known `TypeVar`s. + fn apply_subst_ty(&self, ty: StaticType) -> StaticType; + + /// Int/Float numeric-widening rule. Returns the promoted type, or `None` if + /// the combination is not a widening pair. + fn try_numeric_widen(&self, a: &StaticType, b: &StaticType) -> Option; + + /// Record-field promotion rule. Returns the promoted record type when one record + /// is a strict widening of the other, or `None` otherwise. + fn try_record_promote(&self, a: &StaticType, b: &StaticType) -> Option; +} + +/// Called after the call's return type is resolved. +/// May replace the return type and/or perform unification side-effects (e.g. push). +pub type PostCallHook = fn( + args: &TypedNode, + ret_ty: StaticType, + inference: &dyn TypeInferenceAccess, + ctx: &mut dyn TypeSlotAccess, + diag: &mut Diagnostics, +) -> StaticType; + +/// Called during AST finalization. +/// Returns `Some(new_kind)` to rewrite the node, or `None` to keep the original. +pub type FinalizeHook = fn( + callee: Rc, + args: Rc, + node_ty: &StaticType, + subst: &HashMap, +) -> Option>; + +/// Hooks attached to a specific RTL function slot. +/// Enables type-inference extensions and AST rewrites without hardcoding symbol +/// names anywhere in the compiler core. +#[derive(Default)] +pub struct CallHooks { + pub post_call: Option, + pub finalize: Option, +} diff --git a/src/ast/compiler/mod.rs b/src/ast/compiler/mod.rs index 05fdc8a..9d5b797 100644 --- a/src/ast/compiler/mod.rs +++ b/src/ast/compiler/mod.rs @@ -1,5 +1,6 @@ pub mod analyzer; pub mod binder; +pub mod call_hooks; pub mod module_loader; pub mod captures; pub mod dumper; diff --git a/src/ast/compiler/type_checker.rs b/src/ast/compiler/type_checker.rs index f09ed39..ff55265 100644 --- a/src/ast/compiler/type_checker.rs +++ b/src/ast/compiler/type_checker.rs @@ -1,9 +1,10 @@ +use crate::ast::compiler::call_hooks::{CallHooks, TypeInferenceAccess, TypeSlotAccess}; use crate::ast::nodes::{ Address, AssignBinding, BoundLike, BoundPhase, DefBinding, IdentifierBinding, LambdaBinding, Node, NodeKind, TypedNode, TypedPhase, VirtualId, }; use crate::ast::diagnostics::Diagnostics; -use crate::ast::types::{Keyword, NodeIdentity, RecordLayout, Signature, StaticType, Value}; +use crate::ast::types::{Keyword, NodeIdentity, RecordLayout, Signature, StaticType}; use std::cell::Cell; use std::collections::HashMap; use std::rc::Rc; @@ -74,6 +75,42 @@ impl<'a> TypeContext<'a> { } } +impl TypeSlotAccess for TypeContext<'_> { + fn get_slot_type(&self, addr: Address) -> StaticType { + self.get_type(addr) + } + + fn set_slot_type(&mut self, addr: Address, ty: StaticType) { + self.set_type(addr, ty); + } +} + +impl TypeInferenceAccess for TypeChecker { + fn fresh_var(&self) -> StaticType { + self.fresh_var() + } + + fn unify(&self, a: StaticType, b: StaticType, diag: &mut Diagnostics) { + self.unify(a, b, diag); + } + + fn bind_typevar(&self, id: u32, ty: StaticType) { + self.subst.borrow_mut().insert(id, ty); + } + + fn apply_subst_ty(&self, ty: StaticType) -> StaticType { + Self::apply_subst(ty, &self.subst.borrow()) + } + + fn try_numeric_widen(&self, a: &StaticType, b: &StaticType) -> Option { + Self::numeric_widen(a, b) + } + + fn try_record_promote(&self, a: &StaticType, b: &StaticType) -> Option { + Self::record_promote(a, b) + } +} + /// Extracts expected lambda parameter types from the callee type for a specific argument position. /// Used for bidirectional type inference: when a Call's callee expects a function at `arg_index`, /// this returns the expected parameter types for that function, derived from the callee's signature @@ -137,14 +174,21 @@ pub struct TypeChecker { /// Global substitution map: TypeVar ID → resolved StaticType. /// Shared across all scopes within a single type-checking pass. subst: std::cell::RefCell>, + /// Type-inference and finalization hooks keyed by global slot index. + /// Populated from the frozen RTL snapshot; empty for non-RTL call sites. + call_hooks: Rc>, } impl TypeChecker { - pub fn new(root_types: Rc>>) -> Self { + pub fn new( + root_types: Rc>>, + call_hooks: Rc>, + ) -> Self { Self { root_types, var_counter: Cell::new(0), subst: std::cell::RefCell::new(HashMap::new()), + call_hooks, } } @@ -261,11 +305,6 @@ impl TypeChecker { } } - /// Returns true if `node` is an identifier with the given symbol name. - fn is_identifier_named(name: &str, node: &TypedNode) -> bool { - matches!(&node.kind, NodeKind::Identifier { symbol, .. } if symbol.name.as_ref() == name) - } - /// After a `FunctionOverloads` call resolves successfully, unify the matched /// overload's concrete parameter types with the actual argument types. /// @@ -371,49 +410,20 @@ impl TypeChecker { Some(StaticType::Record(RecordLayout::get_or_create(promoted_fields))) } - /// Converts a resolved series element type to the schema `Value` passed to the - /// `series` runtime: a type keyword (`:float`, `:int`, …) for scalar types, or a - /// schema record (`{:price :float …}`) for record types. - /// Returns `None` if the type is still unresolved (`TypeVar`, `Any`, …). - fn series_element_to_schema_value(ty: &StaticType) -> Option { - match ty { - StaticType::Float => Some(Value::Keyword(Keyword::intern("float"))), - StaticType::Int | StaticType::DateTime => Some(Value::Keyword(Keyword::intern("int"))), - StaticType::Bool => Some(Value::Keyword(Keyword::intern("bool"))), - StaticType::Text => Some(Value::Keyword(Keyword::intern("text"))), - StaticType::Record(layout) => { - // Build schema record: { :field :type_keyword ... } - let schema_fields: Vec<(Keyword, StaticType)> = layout - .fields - .iter() - .map(|(k, _)| (*k, StaticType::Keyword)) - .collect(); - let schema_layout = RecordLayout::get_or_create(schema_fields); - let schema_values: Option> = layout - .fields - .iter() - .map(|(_, field_ty)| Self::series_element_to_schema_value(field_ty)) - .collect(); - schema_values.map(|vals| Value::Record(schema_layout, std::rc::Rc::new(vals))) - } - _ => None, - } - } - /// Walks the typed AST, applies the HM substitution to every type annotation, - /// and elaborates `(series n)` calls by injecting the resolved schema as a - /// second argument so the runtime can allocate the correct storage. - fn finalize_node(node: TypedNode, subst: &HashMap) -> TypedNode { + /// and dispatches finalization hooks (e.g. schema injection for `series`). + fn finalize_node(&self, node: TypedNode, subst: &HashMap) -> TypedNode { let new_ty = Self::apply_subst(node.ty, subst); - let new_kind = Self::finalize_kind(node.kind, subst, &new_ty, &node.identity); + let new_kind = self.finalize_kind(node.kind, subst, &new_ty, &node.identity); Node { kind: new_kind, ty: new_ty, identity: node.identity, comments: node.comments } } fn finalize_kind( + &self, kind: NodeKind, subst: &HashMap, node_ty: &StaticType, - identity: &Rc, + _identity: &Rc, ) -> NodeKind { match kind { // Leaf nodes — nothing to recurse into @@ -424,71 +434,61 @@ impl TypeChecker { NodeKind::Identifier { symbol, binding } => NodeKind::Identifier { symbol, binding }, NodeKind::Extension(ext) => NodeKind::Extension(ext), - // Call: detect `(series n)` and inject the resolved schema arg + // Call: dispatch finalize hook registered by RTL (keyed by global slot index). + // Hooks may rewrite the call node — e.g. series injects a schema argument. NodeKind::Call { callee, args } => { - let callee_fin = Rc::new(Self::finalize_node((*callee).clone(), subst)); - if Self::is_identifier_named("series", &callee_fin) - && let StaticType::Series(inner) = node_ty - && let NodeKind::Tuple { elements } = &args.kind - && elements.len() == 1 - && let Some(schema_val) = Self::series_element_to_schema_value(inner) - { - let orig_arg = Rc::new(Self::finalize_node((*elements[0]).clone(), subst)); - let schema_ty = schema_val.static_type(); - let schema_node = Rc::new(Node { - kind: NodeKind::Constant(schema_val), - ty: schema_ty, - identity: identity.clone(), - comments: Rc::from([]), - }); - let new_elems = vec![orig_arg, schema_node]; - let elem_types: Vec = - new_elems.iter().map(|e| e.ty.clone()).collect(); - let new_args = Node { - kind: NodeKind::Tuple { elements: new_elems }, - ty: StaticType::Tuple(elem_types), - identity: args.identity.clone(), - comments: args.comments.clone(), - }; - return NodeKind::Call { callee: callee_fin, args: Rc::new(new_args) }; - } - let args_fin = Rc::new(Self::finalize_node((*args).clone(), subst)); + let callee_fin = Rc::new(self.finalize_node((*callee).clone(), subst)); + let args_fin = Rc::new(self.finalize_node((*args).clone(), subst)); + if let NodeKind::Identifier { + binding: IdentifierBinding::Reference(Address::Global(idx)), + .. + } = &callee_fin.kind + && let Some(hook) = self.call_hooks.get(&idx.0) + && let Some(finalize) = hook.finalize + && let Some(new_kind) = finalize( + Rc::clone(&callee_fin), + Rc::clone(&args_fin), + node_ty, + subst, + ) { + return new_kind; + } NodeKind::Call { callee: callee_fin, args: args_fin } } NodeKind::If { cond, then_br, else_br } => NodeKind::If { - cond: Rc::new(Self::finalize_node((*cond).clone(), subst)), - then_br: Rc::new(Self::finalize_node((*then_br).clone(), subst)), - else_br: else_br.map(|e| Rc::new(Self::finalize_node((*e).clone(), subst))), + cond: Rc::new(self.finalize_node((*cond).clone(), subst)), + then_br: Rc::new(self.finalize_node((*then_br).clone(), subst)), + else_br: else_br.map(|e| Rc::new(self.finalize_node((*e).clone(), subst))), }, NodeKind::Def { pattern, value, info } => NodeKind::Def { - pattern: Rc::new(Self::finalize_node((*pattern).clone(), subst)), - value: Rc::new(Self::finalize_node((*value).clone(), subst)), + pattern: Rc::new(self.finalize_node((*pattern).clone(), subst)), + value: Rc::new(self.finalize_node((*value).clone(), subst)), info, }, NodeKind::Assign { target, value, info } => NodeKind::Assign { - target: Rc::new(Self::finalize_node((*target).clone(), subst)), - value: Rc::new(Self::finalize_node((*value).clone(), subst)), + target: Rc::new(self.finalize_node((*target).clone(), subst)), + value: Rc::new(self.finalize_node((*value).clone(), subst)), info, }, NodeKind::Lambda { params, body, info } => NodeKind::Lambda { - params: Rc::new(Self::finalize_node((*params).clone(), subst)), - body: Rc::new(Self::finalize_node((*body).clone(), subst)), + params: Rc::new(self.finalize_node((*params).clone(), subst)), + body: Rc::new(self.finalize_node((*body).clone(), subst)), info, }, NodeKind::Again { args } => NodeKind::Again { - args: Rc::new(Self::finalize_node((*args).clone(), subst)), + args: Rc::new(self.finalize_node((*args).clone(), subst)), }, NodeKind::Block { exprs } => NodeKind::Block { exprs: exprs .into_iter() - .map(|e| Rc::new(Self::finalize_node((*e).clone(), subst))) + .map(|e| Rc::new(self.finalize_node((*e).clone(), subst))) .collect(), }, NodeKind::Tuple { elements } => NodeKind::Tuple { elements: elements .into_iter() - .map(|e| Rc::new(Self::finalize_node((*e).clone(), subst))) + .map(|e| Rc::new(self.finalize_node((*e).clone(), subst))) .collect(), }, NodeKind::Record { fields, layout } => NodeKind::Record { @@ -496,34 +496,34 @@ impl TypeChecker { .into_iter() .map(|(k, v)| { ( - Rc::new(Self::finalize_node((*k).clone(), subst)), - Rc::new(Self::finalize_node((*v).clone(), subst)), + Rc::new(self.finalize_node((*k).clone(), subst)), + Rc::new(self.finalize_node((*v).clone(), subst)), ) }) .collect(), layout, }, NodeKind::GetField { rec, field } => NodeKind::GetField { - rec: Rc::new(Self::finalize_node((*rec).clone(), subst)), + rec: Rc::new(self.finalize_node((*rec).clone(), subst)), field, }, NodeKind::Expansion { original_call, expanded } => NodeKind::Expansion { original_call, - expanded: Rc::new(Self::finalize_node((*expanded).clone(), subst)), + expanded: Rc::new(self.finalize_node((*expanded).clone(), subst)), }, NodeKind::MacroDecl { name, params, body } => NodeKind::MacroDecl { name, - params: Rc::new(Self::finalize_node((*params).clone(), subst)), - body: Rc::new(Self::finalize_node((*body).clone(), subst)), + params: Rc::new(self.finalize_node((*params).clone(), subst)), + body: Rc::new(self.finalize_node((*body).clone(), subst)), }, NodeKind::Template(inner) => { - NodeKind::Template(Rc::new(Self::finalize_node((*inner).clone(), subst))) + NodeKind::Template(Rc::new(self.finalize_node((*inner).clone(), subst))) } NodeKind::Placeholder(inner) => { - NodeKind::Placeholder(Rc::new(Self::finalize_node((*inner).clone(), subst))) + NodeKind::Placeholder(Rc::new(self.finalize_node((*inner).clone(), subst))) } NodeKind::Splice(inner) => { - NodeKind::Splice(Rc::new(Self::finalize_node((*inner).clone(), subst))) + NodeKind::Splice(Rc::new(self.finalize_node((*inner).clone(), subst))) } } } @@ -533,7 +533,7 @@ impl TypeChecker { /// Must be called after `check()` or `check_node_as_bound()` completes. pub fn finalize(&self, node: TypedNode) -> TypedNode { let subst = self.subst.borrow().clone(); - Self::finalize_node(node, &subst) + self.finalize_node(node, &subst) } pub fn check( @@ -1225,62 +1225,18 @@ impl TypeChecker { // e.g. `(+ Float TypeVar(1))` resolves TypeVar(1) = Float. self.unify_matched_overload(&callee_typed.ty, &args_typed.ty, diag); - // HM: (series n) returns Series(Any) — replace with a fresh TypeVar so - // subsequent push calls can unify the element type. - if Self::is_identifier_named("series", &callee_typed) - && let StaticType::Series(inner) = &ret_ty - && **inner == StaticType::Any - { - ret_ty = StaticType::Series(Box::new(self.fresh_var())); - } - - // HM: (push series val) — unify the series element TypeVar with the value - // type, applying Int→Float numeric promotion when needed. - // - // We intentionally do NOT bake the resolved type into ctx after normal - // unification. Keeping `Series(TypeVar(n))` in ctx allows a later push - // to recover the TypeVar ID and rebind it (e.g. Int → Float promotion). - // All identifier lookups call `apply_subst`, so the resolved type is - // always correct regardless of what ctx stores. - if Self::is_identifier_named("push", &callee_typed) - && let NodeKind::Tuple { elements } = &args_typed.kind - && elements.len() >= 2 - { - let series_arg = &elements[0]; - let value_arg = &elements[1]; - if let StaticType::Series(inner) = &series_arg.ty { - let inner_ty = (**inner).clone(); - let val_ty = value_arg.ty.clone(); - - if let NodeKind::Identifier { - binding: IdentifierBinding::Reference(addr), - .. - } = &series_arg.kind - { - // Recover the raw inner type from ctx (before apply_subst) so - // we can find the TypeVar ID even after the first push resolved it. - let raw_inner = match ctx.get_type(*addr) { - StaticType::Series(ri) => *ri, - _ => inner_ty.clone(), - }; - - if let Some(promoted) = Self::numeric_widen(&inner_ty, &val_ty) - .or_else(|| Self::record_promote(&inner_ty, &val_ty)) - { - // Rebind the TypeVar to the promoted type so that finalize - // injects the correct schema (e.g. :float instead of :int). - if let StaticType::TypeVar(n) = raw_inner { - self.subst.borrow_mut().insert(n, promoted.clone()); - } - ctx.set_type(*addr, StaticType::Series(Box::new(promoted))); - } else { - self.unify(inner_ty, val_ty, diag); - } - } else { - self.unify(inner_ty, val_ty, diag); + // Dispatch post-call hooks registered by the RTL (keyed by global slot index). + // Hooks handle type-inference extensions such as: + // - series: inject a fresh TypeVar for the element type + // - push: unify the series element TypeVar with the pushed value type + if let NodeKind::Identifier { + binding: IdentifierBinding::Reference(Address::Global(idx)), + .. + } = &callee_typed.kind + && let Some(hook) = self.call_hooks.get(&idx.0) + && let Some(post_call) = hook.post_call { + ret_ty = post_call(&args_typed, ret_ty, self, ctx, diag); } - } - } ( NodeKind::Call { diff --git a/src/ast/environment.rs b/src/ast/environment.rs index dced83c..29de487 100644 --- a/src/ast/environment.rs +++ b/src/ast/environment.rs @@ -1,5 +1,6 @@ use crate::ast::compiler::analyzer::Analyzer; use crate::ast::compiler::binder::{Binder, CompilerScope, LocalInfo}; +use crate::ast::compiler::call_hooks::CallHooks; use crate::ast::compiler::{CapturePass, TypeChecker, TypedNode}; use crate::ast::nodes::{AnalyzedPhase, Symbol, SyntaxKind, SyntaxNode}; use crate::ast::parser::Parser; @@ -53,6 +54,9 @@ pub struct Rtl { pub purity: Vec, pub values: Rc<[Value]>, pub rtl_docs: Vec, + /// Type-inference and finalization hooks, keyed by global slot index. + /// Populated during RTL registration via `RtlRegistration::with_call_hooks`. + pub call_hooks: Rc>, } pub struct Environment { @@ -79,6 +83,9 @@ pub struct Environment { /// Documentation for symbols defined in Myc source (via `;;` comments before `def`/`macro`). /// Key: symbol name. Value: concatenated doc lines joined by newlines. pub myc_docs: Rc>>, + /// Staging area for call hooks registered during RTL bootstrap. + /// Frozen into `Rtl::call_hooks` after `rtl::register()` completes. + pub call_hooks: Rc>>, /// Frozen RTL snapshot from which this environment was created. pub rtl: Rc, } @@ -104,6 +111,7 @@ struct RuntimeMacroEvaluator { globals: GlobalStore, root_purity: Rc>>, fixed_scope_idx: i32, + call_hooks: Rc>, } impl MacroEvaluator for RuntimeMacroEvaluator { @@ -124,7 +132,7 @@ impl MacroEvaluator for RuntimeMacroEvaluator { let (bound_ast, captures, _, _) = Binder::bind_root(initial_scopes, initial_slot_count, self.fixed_scope_idx, node, &mut diag)?; let bound_ast = CapturePass::apply(bound_ast, &captures); - let checker = TypeChecker::new(self.root_types.clone()); + let checker = TypeChecker::new(self.root_types.clone(), Rc::clone(&self.call_hooks)); let typed_ast = checker.check(&bound_ast, &[], &mut diag); if diag.has_errors() { @@ -167,6 +175,7 @@ impl Environment { loaded_modules: Rc::new(RefCell::new(HashSet::new())), rtl_docs: Rc::new(RefCell::new(Vec::new())), myc_docs: Rc::new(RefCell::new(HashMap::new())), + call_hooks: Rc::new(RefCell::new(HashMap::new())), // Placeholder — overwritten below after bootstrap. rtl: Rc::new(Rtl { scope: CompilerScope::new(), @@ -175,6 +184,7 @@ impl Environment { purity: vec![], values: Rc::from([]), rtl_docs: vec![], + call_hooks: Rc::new(HashMap::new()), }), }; @@ -185,6 +195,7 @@ impl Environment { let rtl_slot_count = *env.root_slot_count.borrow(); // user_values was used as the bootstrap scratch; freeze it into an immutable slice. let rtl_vals: Rc<[Value]> = env.user_values.borrow().clone().into(); + let frozen_hooks = Rc::new(std::mem::take(&mut *env.call_hooks.borrow_mut())); env.rtl = Rc::new(Rtl { scope: env.root_scopes.borrow()[0].clone(), slot_count: rtl_slot_count, @@ -192,6 +203,7 @@ impl Environment { purity: env.root_purity.borrow().clone(), values: Rc::clone(&rtl_vals), rtl_docs: std::mem::take(&mut env.rtl_docs.borrow_mut()), + call_hooks: frozen_hooks, }); // Set the frozen RTL slice. Stream closures registered during bootstrap @@ -245,6 +257,7 @@ impl Environment { loaded_modules: Rc::new(RefCell::new(HashSet::new())), rtl_docs: Rc::new(RefCell::new(Vec::new())), myc_docs: Rc::new(RefCell::new(HashMap::new())), + call_hooks: Rc::new(RefCell::new(HashMap::new())), rtl, }; @@ -306,6 +319,7 @@ impl Environment { globals: self.global_store(), root_purity: self.root_purity.clone(), fixed_scope_idx: self.fixed_scope_idx, + call_hooks: Rc::clone(&self.rtl.call_hooks), }; MacroExpander::new(self.macro_registry.borrow().clone(), evaluator) } @@ -466,7 +480,7 @@ impl Environment { LambdaCollector::collect(&bound_ast, &mut self.function_registry.borrow_mut()); - let checker = TypeChecker::new(self.root_types.clone()); + let checker = TypeChecker::new(self.root_types.clone(), Rc::clone(&self.rtl.call_hooks)); let wrapped_ast = if let NodeKind::Lambda { .. } = bound_ast.kind { bound_ast } else { @@ -508,10 +522,12 @@ impl Environment { /// Allocates a new global slot and registers a value in the fixed RTL scope (scope index 0). /// This is the single source of truth for all global slot allocation. - fn allocate_slot(&self, name: &str, ty: StaticType, purity: Purity, val: Value) { + /// Returns the allocated slot index so callers can key call-hooks against it. + fn allocate_slot(&self, name: &str, ty: StaticType, purity: Purity, val: Value) -> u32 { let mut root_scopes = self.root_scopes.borrow_mut(); let mut slot_count = self.root_slot_count.borrow_mut(); - let global_idx = GlobalIdx(*slot_count); + let idx = *slot_count; + let global_idx = GlobalIdx(idx); root_scopes[0].locals.insert( Symbol::from(name), LocalInfo { @@ -525,6 +541,7 @@ impl Environment { self.root_types.borrow_mut().push(ty); self.root_purity.borrow_mut().push(purity); self.user_values.borrow_mut().push(val); + idx } pub fn register_native( @@ -532,8 +549,8 @@ impl Environment { name: &str, ty: StaticType, func: Rc, - ) { - self.allocate_slot(name, ty, func.purity, Value::Function(func)); + ) -> u32 { + self.allocate_slot(name, ty, func.purity, Value::Function(func)) } pub fn register_native_fn( @@ -543,7 +560,7 @@ impl Environment { purity_level: Purity, func: impl Fn(&[Value]) -> Value + 'static, ) -> RtlRegistration { - self.register_native( + let slot_idx = self.register_native( name, ty, Rc::new(NativeFunction { @@ -551,12 +568,22 @@ impl Environment { purity: purity_level, }), ); - RtlRegistration::new(name.to_string(), Rc::clone(&self.rtl_docs)) + RtlRegistration::new( + name.to_string(), + slot_idx, + Rc::clone(&self.rtl_docs), + Rc::clone(&self.call_hooks), + ) } pub fn register_constant(&self, name: &str, ty: StaticType, val: Value) -> RtlRegistration { - self.allocate_slot(name, ty, Purity::Pure, val); - RtlRegistration::new(name.to_string(), Rc::clone(&self.rtl_docs)) + let slot_idx = self.allocate_slot(name, ty, Purity::Pure, val); + RtlRegistration::new( + name.to_string(), + slot_idx, + Rc::clone(&self.rtl_docs), + Rc::clone(&self.call_hooks), + ) } @@ -800,13 +827,14 @@ impl Environment { let root_types = self.root_types.clone(); let root_purity = self.root_purity.clone(); let optimization = self.optimization; + let call_hooks = Rc::clone(&self.rtl.call_hooks); let compiler = Rc::new( move |func_template: Rc>, arg_types: &[StaticType]| -> Result<(Value, StaticType), String> { let mut diag = Diagnostics::new(); - let checker = TypeChecker::new(root_types.clone()); + let checker = TypeChecker::new(root_types.clone(), Rc::clone(&call_hooks)); // Monomorphization: re-type-check the function template with the concrete // call-site argument types, producing a specialized TypedNode. diff --git a/src/ast/rtl/docs.rs b/src/ast/rtl/docs.rs index 9a25385..4495409 100644 --- a/src/ast/rtl/docs.rs +++ b/src/ast/rtl/docs.rs @@ -1,6 +1,9 @@ use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; +use crate::ast::compiler::call_hooks::CallHooks; + /// A generator function for data pipelines. Returns `true` while data is available. pub type PipelineGenerator = Box bool>; @@ -19,19 +22,30 @@ pub struct RtlDocEntry { /// Builder returned by `register_native_fn` and `register_constant`. /// Call `.doc(...)` to attach documentation; optional `.description(...)` and `.examples(...)` /// can be chained. The entry is written to the registry when the builder is dropped. +/// Optionally, call `.with_call_hooks(...)` to register type-inference or finalization +/// hooks for this slot — keyed by its global slot index, not by name. pub struct RtlRegistration { name: String, + slot_idx: u32, rtl_docs: Rc>>, + call_hooks_registry: Rc>>, one_liner: Option<&'static str>, description: Option<&'static str>, examples: Option<&'static [&'static str]>, } impl RtlRegistration { - pub(crate) fn new(name: String, rtl_docs: Rc>>) -> Self { + pub(crate) fn new( + name: String, + slot_idx: u32, + rtl_docs: Rc>>, + call_hooks_registry: Rc>>, + ) -> Self { Self { name, + slot_idx, rtl_docs, + call_hooks_registry, one_liner: None, description: None, examples: None, @@ -55,6 +69,21 @@ impl RtlRegistration { self.examples = Some(ex); self } + + /// Register type-inference and/or finalization hooks for this slot. + /// Hooks are keyed by the slot's global index — no name matching in the compiler. + pub fn with_call_hooks( + self, + post_call: Option, + finalize: Option, + ) -> Self { + if post_call.is_some() || finalize.is_some() { + self.call_hooks_registry + .borrow_mut() + .insert(self.slot_idx, CallHooks { post_call, finalize }); + } + self + } } impl Drop for RtlRegistration { diff --git a/src/ast/rtl/series/mod.rs b/src/ast/rtl/series/mod.rs index 7db3fd6..c5d6146 100644 --- a/src/ast/rtl/series/mod.rs +++ b/src/ast/rtl/series/mod.rs @@ -1,10 +1,164 @@ pub mod data; pub use data::*; +use std::collections::HashMap; use std::rc::Rc; +use std::sync::Arc; +use crate::ast::compiler::call_hooks::{FinalizeHook, PostCallHook, TypeInferenceAccess, TypeSlotAccess}; +use crate::ast::diagnostics::Diagnostics; use crate::ast::environment::Environment; -use crate::ast::types::{Purity, RecordLayout, Signature, StaticType, Value}; +use crate::ast::nodes::{IdentifierBinding, Node, NodeKind, TypedNode, TypedPhase}; +use crate::ast::types::{NativeFunction, NodeIdentity, Purity, Signature, SourceLocation, StaticType, Value}; + +// ============================================================================ +// 4. Type-Inference Hooks (registered via RTL bootstrap, no name coupling) +// ============================================================================ + +/// Post-call hook for `(series n)`. +/// Replaces the `Series(Any)` return type with a fresh TypeVar so that +/// subsequent `push` calls can unify the element type (HM inference). +fn series_post_call( + _args: &TypedNode, + ret_ty: StaticType, + inference: &dyn TypeInferenceAccess, + _ctx: &mut dyn TypeSlotAccess, + _diag: &mut Diagnostics, +) -> StaticType { + if let StaticType::Series(inner) = &ret_ty + && **inner == StaticType::Any + { + return StaticType::Series(Box::new(inference.fresh_var())); + } + ret_ty +} + +/// Finalization hook for `(series n)`. +/// Replaces the callee with a pre-configured factory closure that directly +/// allocates the correct storage type (e.g. `ScalarSeries::` for Float, +/// `RecordSeries` with a captured `Arc` for record types). +/// No schema argument is needed — the type is captured in the closure. +fn series_finalize( + _callee: Rc, + args: Rc, + node_ty: &StaticType, + _subst: &HashMap, +) -> Option> { + let StaticType::Series(inner) = node_ty else { return None }; + let NodeKind::Tuple { elements } = &args.kind else { return None }; + if elements.len() != 1 { + return None; + } + + // Build a factory Value::Function whose closure already knows the exact + // storage type — no keyword encoding, no runtime dispatch. + let factory_value: Value = match inner.as_ref() { + StaticType::Float => Value::Function(Rc::new(NativeFunction { + func: Rc::new(|args: &[Value]| { + let n = args[0].as_int().unwrap_or(0) as usize; + Value::Series(Rc::new(ScalarSeries::::new("FloatSeries", n))) + }), + purity: Purity::Impure, + })), + StaticType::Int | StaticType::DateTime => Value::Function(Rc::new(NativeFunction { + func: Rc::new(|args: &[Value]| { + let n = args[0].as_int().unwrap_or(0) as usize; + Value::Series(Rc::new(ScalarSeries::::new("IntSeries", n))) + }), + purity: Purity::Impure, + })), + StaticType::Bool => Value::Function(Rc::new(NativeFunction { + func: Rc::new(|args: &[Value]| { + let n = args[0].as_int().unwrap_or(0) as usize; + Value::Series(Rc::new(ScalarSeries::::new("BoolSeries", n))) + }), + purity: Purity::Impure, + })), + StaticType::Text => Value::Function(Rc::new(NativeFunction { + func: Rc::new(|args: &[Value]| { + let n = args[0].as_int().unwrap_or(0) as usize; + Value::Series(Rc::new(ValueSeries::new(n))) + }), + purity: Purity::Impure, + })), + StaticType::Record(layout) => { + let layout = Arc::clone(layout); + Value::Function(Rc::new(NativeFunction { + func: Rc::new(move |args: &[Value]| { + let n = args[0].as_int().unwrap_or(0) as usize; + Value::Series(Rc::new(RecordSeries::new(Arc::clone(&layout), n))) + }), + purity: Purity::Impure, + })) + } + _ => return None, + }; + + let factory_node = Rc::new(Node { + kind: NodeKind::Constant(factory_value), + ty: StaticType::Any, + identity: NodeIdentity::new(SourceLocation { line: 0, col: 0 }), + comments: Rc::from([]), + }); + Some(NodeKind::Call { callee: factory_node, args: Rc::clone(&args) }) +} + +/// Post-call hook for `(push series val)`. +/// Unifies the series element TypeVar with the pushed value type, applying +/// Int→Float numeric promotion and record field promotion when needed. +/// +/// We intentionally do NOT bake the resolved type into ctx after normal +/// unification. Keeping `Series(TypeVar(n))` in ctx allows a later push +/// to recover the TypeVar ID and rebind it (e.g. Int → Float promotion). +/// All identifier lookups call `apply_subst`, so the resolved type is +/// always correct regardless of what ctx stores. +fn push_post_call( + args: &TypedNode, + ret_ty: StaticType, + inference: &dyn TypeInferenceAccess, + ctx: &mut dyn TypeSlotAccess, + diag: &mut Diagnostics, +) -> StaticType { + let NodeKind::Tuple { elements } = &args.kind else { return ret_ty }; + if elements.len() < 2 { + return ret_ty; + } + let series_arg = &elements[0]; + let value_arg = &elements[1]; + let StaticType::Series(inner) = &series_arg.ty else { return ret_ty }; + let inner_ty = (**inner).clone(); + let val_ty = value_arg.ty.clone(); + + if let NodeKind::Identifier { + binding: IdentifierBinding::Reference(addr), + .. + } = &series_arg.kind + { + // Recover the raw inner type from ctx (before apply_subst) so we can + // find the TypeVar ID even after the first push resolved it. + let raw_inner = match ctx.get_slot_type(*addr) { + StaticType::Series(ri) => *ri, + _ => inner_ty.clone(), + }; + + if let Some(promoted) = inference + .try_numeric_widen(&inner_ty, &val_ty) + .or_else(|| inference.try_record_promote(&inner_ty, &val_ty)) + { + // Rebind the TypeVar to the promoted type so that finalize + // injects the correct schema (e.g. :float instead of :int). + if let StaticType::TypeVar(n) = raw_inner { + inference.bind_typevar(n, promoted.clone()); + } + ctx.set_slot_type(*addr, StaticType::Series(Box::new(promoted))); + } else { + inference.unify(inner_ty, val_ty, diag); + } + } else { + inference.unify(inner_ty, val_ty, diag); + } + ret_ty +} // ============================================================================ // 5. Script Integration (RTL Registration) @@ -23,51 +177,15 @@ pub fn register(env: &Environment) { })), Purity::Impure, |args: &[Value]| { + // The FinalizeHook replaces this call with a pre-configured factory + // for resolved element types. This fallback only runs when the element + // type could not be determined at compile time (e.g. push inside a + // nested closure the type checker could not reach). let lookback_limit = args[0].as_int().unwrap_or(0) as usize; - // The type checker injects the schema as a second argument via HM inference. - // If schema injection was not possible (e.g. the first push is inside a nested - // closure that the type checker could not reach), fall back to a ValueSeries - // that stores unboxed Values. The HM type checker still guarantees type safety - // at compile time; this path is only a runtime fallback for storage allocation. - if args.len() < 2 { - return Value::Series(Rc::new(ValueSeries::new(lookback_limit))); - } - let arg = &args[1]; - match arg { - Value::Keyword(k) => { - match k.name().to_lowercase().as_str() { - "float" => Value::Series(Rc::new(ScalarSeries::::new("FloatSeries", lookback_limit))), - "int" | "datetime" => Value::Series(Rc::new(ScalarSeries::::new("IntSeries", lookback_limit))), - "bool" => Value::Series(Rc::new(ScalarSeries::::new("BoolSeries", lookback_limit))), - "text" => Value::Series(Rc::new(ValueSeries::new(lookback_limit))), - _ => panic!("Unknown or unsupported series type keyword: :{}", k.name()), - } - } - Value::Record(schema_layout, schema_values) => { - // Interpret the record as a schema: { :field :type_keyword } - let mut fields = Vec::with_capacity(schema_layout.fields.len()); - for (i, (name, _)) in schema_layout.fields.iter().enumerate() { - let type_keyword = match &schema_values[i] { - Value::Keyword(tk) => tk.name().to_lowercase(), - _ => panic!("Field :{} in series schema must be a type keyword (e.g., :float, :int)", name.name()), - }; - let ty = match type_keyword.as_str() { - "float" => StaticType::Float, - "int" => StaticType::Int, - "bool" => StaticType::Bool, - "datetime" => StaticType::DateTime, - "text" => StaticType::Text, - _ => panic!("Unsupported type keyword :{} for series field :{}", type_keyword, name.name()), - }; - fields.push((*name, ty)); - } - let final_layout = RecordLayout::get_or_create(fields); - Value::Series(Rc::new(RecordSeries::new(final_layout, lookback_limit))) - } - _ => panic!("series: second arg must be a type keyword (:float, :int…) or a schema record ({{:field :type}})"), - } + Value::Series(Rc::new(ValueSeries::new(lookback_limit))) }, - ).doc("Creates a new series (ring buffer) with the given lookback limit.") + ).with_call_hooks(Some(series_post_call as PostCallHook), Some(series_finalize as FinalizeHook)) + .doc("Creates a new series (ring buffer) with the given lookback limit.") .description("Takes a single lookback limit (int). The element type is inferred at compile time from subsequent push calls via HM type inference. The compiler injects a second schema argument automatically; the runtime also accepts an explicit schema for cases where inference is not possible.") .examples(&[ "(series 100)", @@ -94,7 +212,8 @@ pub fn register(env: &Environment) { } panic!("push expects a series as the first argument") }, - ).doc("Pushes a new value into a series. After push, index 0 returns this value.") + ).with_call_hooks(Some(push_post_call as PostCallHook), None) + .doc("Pushes a new value into a series. After push, index 0 returns this value.") .examples(&[ "(push my-ticks {:price 10.5 :volume 100})", "(push prices 42.0)",