From bf74795e01c13daaa3a336e0217fcc774bd09068 Mon Sep 17 00:00:00 2001 From: Michael Schimmel Date: Thu, 26 Feb 2026 21:38:20 +0100 Subject: [PATCH] Refactor: Implement Record Layouts and Optimized Field Access Introduces a new `RecordLayout` system for efficient and type-safe record handling. Key changes: - `RecordLayout` struct with `O(1)` field lookup using FMap optimization. - Field accessors (`.name`) are now first-class callable values. - Parser recognizes dot-prefixed identifiers as field accessors. - Binder and TypeChecker handle field accessors. - Optimizer transforms field accessor calls into specialized `GetField` nodes. - VM executes `GetField` efficiently by directly accessing record values. - Adds a new `records.myc` example showcasing record features. - Improves comparison logic for records to use pointer equality for layout. --- docs/record_optimizations_plan.md | 55 ++++++++++ examples/macro_financial.myc | 4 +- examples/records.myc | 49 +++++++++ src/ast/compiler/analyzer.rs | 17 +++ src/ast/compiler/binder.rs | 4 + src/ast/compiler/bound_nodes.rs | 15 +++ src/ast/compiler/captures.rs | 9 ++ src/ast/compiler/dumper.rs | 11 ++ src/ast/compiler/optimizer/engine.rs | 43 +++++++- src/ast/compiler/optimizer/utils.rs | 8 +- src/ast/compiler/tco.rs | 5 + src/ast/compiler/type_checker.rs | 48 +++++++-- src/ast/environment.rs | 10 ++ src/ast/nodes.rs | 2 + src/ast/parser.rs | 3 + src/ast/rtl/core.rs | 3 + src/ast/rtl/type_registry.rs | 27 ++--- src/ast/types.rs | 155 ++++++++++++++++++++++----- src/ast/vm.rs | 62 +++++++++++ src/integration_test.rs | 81 ++++++++++++++ 20 files changed, 548 insertions(+), 63 deletions(-) create mode 100644 docs/record_optimizations_plan.md create mode 100644 examples/records.myc diff --git a/docs/record_optimizations_plan.md b/docs/record_optimizations_plan.md new file mode 100644 index 0000000..f8b77bd --- /dev/null +++ b/docs/record_optimizations_plan.md @@ -0,0 +1,55 @@ +# Plan: Record Layout & Optimized Field Access + +This document outlines the implementation plan for porting Delphi's `TKeywordMappingRegistry` optimizations to the Rust-based Myc AST compiler and integrating first-class field accessors. + +## 1. Core Data Structures (`src/ast/types.rs`) + +### RecordLayout (The Schema) +- **Concept:** Replaces `IKeywordMapping`. +- **Interning:** Managed via a global `OnceLock>>>`. +- **FMap Optimization:** + - Stores `fields: Vec<(Keyword, StaticType)>`. + - Maintains a lookup array `Vec` mapping `Keyword.idx - first_key` to the index in the fields array. + - Provides `index_of(Keyword) -> Option` with **O(1)** complexity. +- **Thread Safety:** Uses `Arc` for global sharing across script threads. + +### Value & StaticType Updates +- `Value::Record` points to an `Arc` and a flat `Rc>`. +- `Value::FieldAccessor(Keyword)` added as a first-class callable value (e.g., `.name`). +- `StaticType::Record` now holds `Arc`. + +## 2. Compiler Pipeline + +### Parser (`src/ast/parser.rs`) +- Identifiers starting with `.` (e.g., `.age`) are parsed as `UntypedKind::FieldAccessor(Keyword)`. +- This ensures interning happens immediately; strings are never used for field access in subsequent phases. + +### Binder (`src/ast/compiler/binder.rs`) +- Maps `UntypedKind::FieldAccessor` to `BoundKind::FieldAccessor`. + +### TypeChecker (`src/ast/compiler/type_checker.rs`) +- **Analysis Only:** When encountering `Call { callee: FieldAccessor, args: [rec] }`: + - Validates that `rec` is a Record. + - Uses `RecordLayout::index_of` to find the field's type. + - Sets the call's return type accordingly. + - **Crucial:** Does NOT modify the AST structure. It remains a `Call`. + +### Optimizer (`src/ast/compiler/optimizer/engine.rs`) +- **Transformation:** Recognizes the `Call { callee: FieldAccessor, ... }` pattern. +- Transforms it into a specialized `BoundKind::GetField { rec, field: Keyword }` node. + +## 3. Execution (`src/ast/vm.rs`) + +### Optimized Path (`GetField`) +- The VM executes `GetField` by: + 1. Evaluating the record. + 2. Calling `layout.index_of(field)` (O(1)). + 3. Fetching the value from the flat array. + +### Functional Path (`FieldAccessor`) +- If `.field` is passed as a value (e.g., `(map .name users)`), the VM treats the `FieldAccessor` value as a standard function that takes one argument and performs the lookup. + +## 4. Benefits +- **Type Identity:** `Arc::ptr_eq` allows instant type comparison. +- **Performance:** Field access in loops is O(1) without hash lookups. +- **Safety:** The TypeChecker remains a pure analysis phase as per design requirements. diff --git a/examples/macro_financial.myc b/examples/macro_financial.myc index f561619..391d041 100644 --- a/examples/macro_financial.myc +++ b/examples/macro_financial.myc @@ -1,5 +1,5 @@ -;; Benchmark: 351ns -;; Benchmark-Repeat: 5716 +;; Benchmark: 401ns +;; Benchmark-Repeat: 5016 ;; Financial DSL Macro example ;; Demonstrates generating complex records from simple parameters. ;; Output: {:price 100, :size 2, :total 200} diff --git a/examples/records.myc b/examples/records.myc new file mode 100644 index 0000000..f4fcbbc --- /dev/null +++ b/examples/records.myc @@ -0,0 +1,49 @@ +;; Benchmark: 3.4us +;; Benchmark-Repeat: 573 +;; Output: ["Alice" 101 :admin "Zürich" ["Alice" "Bob"] true] + +; --------------------------------------------------------- +; Records & Optimized Field Access Showcase +; --------------------------------------------------------- + +(do + ; 1. Record Creation + ; Records use interned layouts for O(1) access and low memory overhead. + (def user {:id 101 :name "Alice" :role :admin}) + + ; 2. Optimized Field Access + ; These calls are transformed by the optimizer into specialized instructions. + (def name (.name user)) + (def id (.id user)) + + ; 3. First-Class Field Accessors + ; Accessors start with a dot and can be passed around like functions. + (def get-role .role) + (def role (get-role user)) + + ; 4. Nested Records + (def company { + :name "Myc Corp" + :location {:city "Zürich" :zip 8000} + :employees [user {:id 102 :name "Bob" :role :dev}] + }) + + ; Deep access (also optimized) + (def city (.city (.location company))) + + ; 5. Higher-Order usage + ; Accessors are perfect for mapping over data. + (def names ((fn [f list] + (do + (def [e1 e2] list) + [(f e1) (f e2)] + )) + .name (.employees company))) + + ; 6. Structural Identity + ; Records with the same layout share memory; equality checks values. + (def is-equal (= {:x 1 :y 2} {:x 1 :y 2})) + + ; Return a summary of all tested features + [name id role city names is-equal] +) diff --git a/src/ast/compiler/analyzer.rs b/src/ast/compiler/analyzer.rs index fc9ef33..9a2d0e1 100644 --- a/src/ast/compiler/analyzer.rs +++ b/src/ast/compiler/analyzer.rs @@ -83,6 +83,20 @@ impl<'a> Analyzer<'a> { ) } + BoundKind::FieldAccessor(k) => (BoundKind::FieldAccessor(*k), Purity::Pure), + + BoundKind::GetField { rec, field } => { + let rec_m = self.visit(Rc::new((**rec).clone())); + let p = rec_m.ty.purity; + ( + BoundKind::GetField { + rec: Box::new(rec_m), + field: *field, + }, + p, + ) + } + BoundKind::Set { addr, value } => { let val_m = self.visit(Rc::new((**value).clone())); ( @@ -313,6 +327,9 @@ impl NodeExt for BoundKind { BoundKind::Define { value, .. } | BoundKind::Set { value, .. } => { f(value); } + BoundKind::GetField { rec, .. } => { + f(rec); + } BoundKind::Lambda { params, body, .. } => { f(params); f(body); diff --git a/src/ast/compiler/binder.rs b/src/ast/compiler/binder.rs index 60f320a..9a55ba1 100644 --- a/src/ast/compiler/binder.rs +++ b/src/ast/compiler/binder.rs @@ -189,6 +189,10 @@ impl Binder { Err("Unexpected 'Parameter' node in general binder context. This should be handled via 'bind_pattern'.".to_string()) } + UntypedKind::FieldAccessor(k) => { + Ok(self.make_node(node.identity.clone(), BoundKind::FieldAccessor(*k))) + } + UntypedKind::If { cond, then_br, diff --git a/src/ast/compiler/bound_nodes.rs b/src/ast/compiler/bound_nodes.rs index 50048e7..b5b1766 100644 --- a/src/ast/compiler/bound_nodes.rs +++ b/src/ast/compiler/bound_nodes.rs @@ -97,6 +97,15 @@ pub enum BoundKind { captured_by: Vec, }, + /// A first-class field accessor (e.g. .name) + FieldAccessor(crate::ast::types::Keyword), + + /// Specialized field access (O(1) via RecordLayout) + GetField { + rec: Box>, + field: crate::ast::types::Keyword, + }, + If { cond: Box>, then_br: Box>, @@ -188,6 +197,10 @@ where captured_by: cb, }, ) => na == nb && aa == ab && ka == kb && va == vb && ca == cb, + (BoundKind::FieldAccessor(a), BoundKind::FieldAccessor(b)) => a == b, + (BoundKind::GetField { rec: ra, field: fa }, BoundKind::GetField { rec: rb, field: fb }) => { + ra == rb && fa == fb + } ( BoundKind::If { cond: ca, @@ -273,6 +286,8 @@ impl BoundKind { }; format!("DEF_{}({}, {:?})", k_str, name.name, addr) } + BoundKind::FieldAccessor(k) => format!("FIELD_ACCESSOR(.{})", k.name()), + BoundKind::GetField { field, .. } => format!("GET_FIELD(.{})", field.name()), BoundKind::If { .. } => "IF".to_string(), BoundKind::Destructure { .. } => "DESTRUCTURE".to_string(), BoundKind::Lambda { diff --git a/src/ast/compiler/captures.rs b/src/ast/compiler/captures.rs index 5c53a9c..18ce6c6 100644 --- a/src/ast/compiler/captures.rs +++ b/src/ast/compiler/captures.rs @@ -47,6 +47,15 @@ impl CapturePass { }; } + BoundKind::FieldAccessor(_) => {} + + BoundKind::GetField { rec, field } => { + node.kind = BoundKind::GetField { + rec: Box::new(Self::transform(*rec, capture_map)), + field, + }; + } + BoundKind::Destructure { pattern, value } => { node.kind = BoundKind::Destructure { pattern: Box::new(Self::transform(*pattern, capture_map)), diff --git a/src/ast/compiler/dumper.rs b/src/ast/compiler/dumper.rs index 159c987..8b79885 100644 --- a/src/ast/compiler/dumper.rs +++ b/src/ast/compiler/dumper.rs @@ -66,6 +66,17 @@ impl Dumper { self.log(&format!("Get: {} ({:?})", name.name, addr), node) } + BoundKind::FieldAccessor(k) => { + self.log(&format!("FieldAccessor: .{}", k.name()), node) + } + + BoundKind::GetField { rec, field } => { + self.log(&format!("GetField: .{}", field.name()), node); + self.indent += 1; + self.visit(rec); + self.indent -= 1; + } + BoundKind::Set { addr, value } => { self.log(&format!("Set: {:?}", addr), node); self.indent += 1; diff --git a/src/ast/compiler/optimizer/engine.rs b/src/ast/compiler/optimizer/engine.rs index 33b52aa..9c2b198 100644 --- a/src/ast/compiler/optimizer/engine.rs +++ b/src/ast/compiler/optimizer/engine.rs @@ -112,6 +112,27 @@ impl Optimizer { ) } + BoundKind::FieldAccessor(k) => (BoundKind::FieldAccessor(k), node.ty.clone()), + + BoundKind::GetField { ref rec, field } => { + let rec_opt = self.visit_node((**rec).clone(), sub, path); + + // Constant folding for Field Access + if let BoundKind::Constant(Value::Record(layout, values)) = &rec_opt.kind + && let Some(idx) = layout.index_of(field) + { + return folder.make_constant_node(values[idx].clone(), &node); + } + + ( + BoundKind::GetField { + rec: Box::new(rec_opt), + field, + }, + node.ty.clone(), + ) + } + BoundKind::Set { addr, value } => { let value = Box::new(self.visit_node(*value, sub, path)); if let BoundKind::Constant(val) = &value.kind { @@ -175,6 +196,23 @@ impl Optimizer { let args = self.visit_node(*args, sub, path); if self.enabled { + // Optimized Field Access Transformation + if let BoundKind::FieldAccessor(k) = &callee.kind + && let BoundKind::Tuple { elements } = &args.kind + && elements.len() == 1 + { + let rec = elements[0].clone(); + let metrics = node.ty.clone(); + return Node { + identity: node.identity, + kind: BoundKind::GetField { + rec: Box::new(rec), + field: *k, + }, + ty: metrics, + }; + } + let mut arg_nodes = Vec::new(); self.flatten_tuple(args.clone(), &mut arg_nodes); @@ -542,11 +580,6 @@ impl Optimizer { self.flatten_tuple(el, into); } } - BoundKind::Record { fields } => { - for (_, v) in fields { - self.flatten_tuple(v, into); - } - } BoundKind::Nop => {} _ => into.push(node), } diff --git a/src/ast/compiler/optimizer/utils.rs b/src/ast/compiler/optimizer/utils.rs index e02fbea..dce7aa2 100644 --- a/src/ast/compiler/optimizer/utils.rs +++ b/src/ast/compiler/optimizer/utils.rs @@ -68,6 +68,9 @@ impl UsageInfo { self.assigned.insert(*addr); self.collect(value); } + BoundKind::GetField { rec, .. } => { + self.collect(rec); + } BoundKind::Lambda { params, body, @@ -147,7 +150,10 @@ impl UsageInfo { BoundKind::Expansion { bound_expanded, .. } => { self.collect(bound_expanded); } - _ => {} + BoundKind::Again { args } => { + self.collect(args); + } + BoundKind::Nop | BoundKind::FieldAccessor(_) | BoundKind::Extension(_) => {} } } diff --git a/src/ast/compiler/tco.rs b/src/ast/compiler/tco.rs index 56b4626..d7f55e9 100644 --- a/src/ast/compiler/tco.rs +++ b/src/ast/compiler/tco.rs @@ -141,6 +141,11 @@ impl TCO { addr: *addr, name: name.clone(), }, + BoundKind::FieldAccessor(k) => BoundKind::FieldAccessor(*k), + BoundKind::GetField { rec, field } => BoundKind::GetField { + rec: Box::new(Self::transform(Rc::new((**rec).clone()), false)), + field: *field, + }, BoundKind::Nop => BoundKind::Nop, BoundKind::Expansion { original_call, diff --git a/src/ast/compiler/type_checker.rs b/src/ast/compiler/type_checker.rs index 875a308..a8a9c48 100644 --- a/src/ast/compiler/type_checker.rs +++ b/src/ast/compiler/type_checker.rs @@ -226,9 +226,10 @@ impl TypeChecker { StaticType::Vector(inner, _) => (**inner).clone(), StaticType::Matrix(inner, _) => (**inner).clone(), // Matrix flat iteration or row? Wait. Matrix destructuring logic is tricky. But for now let's focus on Record. StaticType::List(inner) => (**inner).clone(), - StaticType::Record(fields) => fields + StaticType::Record(layout) => layout + .fields .get(i) - .map(|(_, ty)| ty.clone()) + .map(|(_, ty): &(crate::ast::types::Keyword, StaticType)| ty.clone()) .unwrap_or(StaticType::Any), _ => StaticType::Any, }; @@ -296,6 +297,36 @@ impl TypeChecker { ) } + BoundKind::FieldAccessor(k) => (BoundKind::FieldAccessor(k), StaticType::FieldAccessor(k)), + + BoundKind::GetField { rec, field } => { + let rec_typed = self.check_node(*rec, ctx)?; + let field_ty = match &rec_typed.ty { + StaticType::Record(layout) => { + if let Some(idx) = layout.index_of(field) { + layout.fields[idx].1.clone() + } else { + return Err(format!("Record does not have field :{}", field.name())); + } + } + StaticType::Any => StaticType::Any, + _ => { + return Err(format!( + "Cannot access field :{} on non-record type {}", + field.name(), + rec_typed.ty + )); + } + }; + ( + BoundKind::GetField { + rec: Box::new(rec_typed), + field, + }, + field_ty, + ) + } + BoundKind::Set { addr, value } => { let val_typed = self.check_node(*value, ctx)?; let ty = val_typed.ty.clone(); @@ -545,11 +576,12 @@ impl TypeChecker { typed_fields.push((kt, vt)); } + let layout = crate::ast::types::RecordLayout::get_or_create(fields_ty); ( BoundKind::Record { fields: typed_fields, }, - StaticType::Record(Rc::new(fields_ty)), + StaticType::Record(layout), ) } @@ -801,12 +833,12 @@ mod tests { #[test] fn test_inference_record() { use crate::ast::types::Keyword; - let typed = check_source("{:x 1, :y 0.3}"); + let typed = check_source("{:x 1 :y 0.3}"); let ty = get_ret_type(&typed); - if let StaticType::Record(fields) = ty { - assert_eq!(fields.len(), 2); - assert_eq!(fields[0], (Keyword::intern("x"), StaticType::Int)); - assert_eq!(fields[1], (Keyword::intern("y"), StaticType::Float)); + if let StaticType::Record(layout) = ty { + assert_eq!(layout.fields.len(), 2); + assert_eq!(layout.fields[0], (Keyword::intern("x"), StaticType::Int)); + assert_eq!(layout.fields[1], (Keyword::intern("y"), StaticType::Float)); } else { panic!("Expected Record, got {:?}", ty); } diff --git a/src/ast/environment.rs b/src/ast/environment.rs index 17a48d0..2c2ce70 100644 --- a/src/ast/environment.rs +++ b/src/ast/environment.rs @@ -194,6 +194,16 @@ impl Environment { let (bound_ast, captures) = Binder::bind_root(self.global_names.clone(), &expanded_ast)?; let bound_ast = crate::ast::compiler::captures::CapturePass::apply(bound_ast, &captures); + // Pre-allocate global slots to prevent out-of-bounds during specialization/optimization + { + let mut values = self.global_values.borrow_mut(); + let names = self.global_names.borrow(); + let max_idx = names.values().map(|(idx, _)| idx.0).max().unwrap_or(0); + if (max_idx as usize) >= values.len() { + values.resize((max_idx + 1) as usize, Value::Void); + } + } + LambdaCollector::collect(&bound_ast, &mut self.function_registry.borrow_mut()); let checker = TypeChecker::new(self.global_types.clone()); diff --git a/src/ast/nodes.rs b/src/ast/nodes.rs index 9d303a2..89e07db 100644 --- a/src/ast/nodes.rs +++ b/src/ast/nodes.rs @@ -65,6 +65,8 @@ pub enum UntypedKind { Constant(Value), Identifier(Symbol), Parameter(Symbol), + /// A first-class field accessor (e.g. .name) + FieldAccessor(crate::ast::types::Keyword), If { cond: Box>, then_br: Box>, diff --git a/src/ast/parser.rs b/src/ast/parser.rs index ec36ec8..eb2c0e0 100644 --- a/src/ast/parser.rs +++ b/src/ast/parser.rs @@ -100,6 +100,9 @@ impl<'a> Parser<'a> { TokenKind::Keyword(k) => UntypedKind::Constant(Value::Keyword(Keyword::intern(&k))), TokenKind::Identifier(id) => match id.as_ref() { "..." => UntypedKind::Nop, + s if s.starts_with('.') && s.len() > 1 => { + UntypedKind::FieldAccessor(Keyword::intern(&s[1..])) + } _ => UntypedKind::Identifier(id.into()), }, _ => { diff --git a/src/ast/rtl/core.rs b/src/ast/rtl/core.rs index a3f1c0a..cb05341 100644 --- a/src/ast/rtl/core.rs +++ b/src/ast/rtl/core.rs @@ -366,6 +366,9 @@ fn register_comparison(env: &Environment) { (Value::Bool(a), Value::Bool(b)) => Value::Bool(a == b), (Value::Keyword(a), Value::Keyword(b)) => Value::Bool(a == b), (Value::DateTime(a), Value::DateTime(b)) => Value::Bool(a == b), + (Value::Record(la, va), Value::Record(lb, vb)) => { + Value::Bool(std::sync::Arc::ptr_eq(la, lb) && va == vb) + } (Value::Void, Value::Void) => Value::Bool(true), _ => Value::Bool(false), } diff --git a/src/ast/rtl/type_registry.rs b/src/ast/rtl/type_registry.rs index d9fcd1f..7882289 100644 --- a/src/ast/rtl/type_registry.rs +++ b/src/ast/rtl/type_registry.rs @@ -1,7 +1,6 @@ use crate::ast::types::{Keyword, Purity, Signature, StaticType, Value}; use std::any::TypeId; use std::collections::HashMap; -use std::rc::Rc; /// Represents a Rust type that can be exposed to the script environment. pub trait Scriptable: 'static + Sized { @@ -149,7 +148,7 @@ impl TypeBuilder { } pub fn build(self) -> StaticType { - StaticType::Record(Rc::new(self.fields)) + StaticType::Record(crate::ast::types::RecordLayout::get_or_create(self.fields)) } } @@ -202,20 +201,16 @@ mod tests { // Check static type let st = TypeRegistry::resolve_type::(®istry); - if let StaticType::Record(fields) = st { - assert!(fields.iter().any(|(k, _)| *k == Keyword::intern("greet"))); + if let StaticType::Record(layout) = st { + assert!(layout.fields.iter().any(|(k, _)| *k == Keyword::intern("greet"))); } else { panic!("Expected Record type"); } // Check runtime behavior - if let Value::Record(r) = wrapped { - let idx = r - .keys - .iter() - .position(|k| *k == Keyword::intern("greet")) - .unwrap(); - let greet_fn = &r.values[idx]; + if let Value::Record(layout, values) = wrapped { + let idx = layout.index_of(Keyword::intern("greet")).unwrap(); + let greet_fn = &values[idx]; if let Value::Function(f) = greet_fn { let res = (f.func)(vec![]); @@ -251,13 +246,9 @@ mod tests { if let Value::Function(f) = factory_val { let instance = (f.func)(vec![Value::Text("Bob".into()), Value::Int(40)]); - if let Value::Record(r) = instance { - let idx = r - .keys - .iter() - .position(|k| *k == Keyword::intern("greet")) - .unwrap(); - let greet_fn = &r.values[idx]; + if let Value::Record(layout, values) = instance { + let idx = layout.index_of(Keyword::intern("greet")).unwrap(); + let greet_fn = &values[idx]; if let Value::Function(gf) = greet_fn { let res = (gf.func)(vec![]); diff --git a/src/ast/types.rs b/src/ast/types.rs index 32fc9fd..9e9c61f 100644 --- a/src/ast/types.rs +++ b/src/ast/types.rs @@ -98,6 +98,84 @@ impl Keyword { } } +/// A shared schema for Records, providing O(1) field lookup via FMap optimization. +#[derive(Debug, Clone)] +pub struct RecordLayout { + pub fields: Vec<(Keyword, StaticType)>, + /// Optimization: maps (Keyword.idx - min_idx) to index in fields. + fmap: Vec, + min_idx: u32, +} + +impl PartialEq for RecordLayout { + fn eq(&self, other: &Self) -> bool { + self.fields == other.fields + } +} + +impl Eq for RecordLayout {} + +impl std::hash::Hash for RecordLayout { + fn hash(&self, state: &mut H) { + self.fields.hash(state); + } +} + +type LayoutMap = HashMap, std::sync::Arc>; + +static LAYOUT_REGISTRY: OnceLock> = OnceLock::new(); + +impl RecordLayout { + pub fn get_or_create(fields: Vec<(Keyword, StaticType)>) -> std::sync::Arc { + let mut reg = LAYOUT_REGISTRY + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .unwrap(); + + if let Some(layout) = reg.get(&fields) { + return layout.clone(); + } + + let mut min_idx = u32::MAX; + let mut max_idx = 0; + for (k, _) in &fields { + min_idx = min_idx.min(k.0); + max_idx = max_idx.max(k.0); + } + + let mut fmap = vec![-1; (max_idx - min_idx + 1) as usize]; + for (i, (k, _)) in fields.iter().enumerate() { + fmap[(k.0 - min_idx) as usize] = i as i32; + } + + let layout = std::sync::Arc::new(RecordLayout { + fields: fields.clone(), + fmap, + min_idx, + }); + + reg.insert(fields, layout.clone()); + layout + } + + #[inline(always)] + pub fn index_of(&self, key: Keyword) -> Option { + if key.0 < self.min_idx { + return None; + } + let offset = (key.0 - self.min_idx) as usize; + if offset >= self.fmap.len() { + return None; + } + let idx = self.fmap[offset]; + if idx < 0 { + None + } else { + Some(idx as usize) + } + } +} + /// Interface for custom objects (Closures, Series, Streams) pub trait Object: fmt::Debug { fn type_name(&self) -> &'static str; @@ -107,15 +185,6 @@ pub trait Object: fmt::Debug { /// A shared sequence of values, used by both Tuples and Records. pub type ValueList = Rc>; -/// Internal storage for Records to allow sharing schema (keys) between instances. -#[derive(Debug, Clone, PartialEq)] -pub struct RecordData { - /// Names for slots. - pub keys: Rc>, - /// The actual values, potentially shared with a Tuple. - pub values: ValueList, -} - /// Purity level of an expression or function. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Purity { @@ -150,7 +219,8 @@ pub enum Value { Text(Rc), Keyword(Keyword), Tuple(ValueList), - Record(Rc), + Record(std::sync::Arc, ValueList), + FieldAccessor(Keyword), Function(Rc), Object(Rc), // For compiled Closures and other opaque types Cell(Rc>), // Boxed value for captures @@ -168,7 +238,10 @@ impl PartialEq for Value { (Value::Text(a), Value::Text(b)) => a == b, (Value::Keyword(a), Value::Keyword(b)) => a == b, (Value::Tuple(a), Value::Tuple(b)) => a == b, - (Value::Record(a), Value::Record(b)) => a == b, + (Value::Record(la, va), Value::Record(lb, vb)) => { + std::sync::Arc::ptr_eq(la, lb) && va == vb + } + (Value::FieldAccessor(a), Value::FieldAccessor(b)) => a == b, (Value::Function(a), Value::Function(b)) => Rc::ptr_eq(a, b), (Value::Object(a), Value::Object(b)) => Rc::ptr_eq(a, b), (Value::Cell(a), Value::Cell(b)) => Rc::ptr_eq(a, b), @@ -214,7 +287,8 @@ pub enum StaticType { Tuple(Vec), // Heterogeneous fixed-size Vector(Box, usize), // Homogeneous fixed-size Matrix(Box, Vec), // Multi-dimensional homogeneous - Record(Rc>), + Record(std::sync::Arc), + FieldAccessor(Keyword), Function(Box), FunctionOverloads(Vec), Object(&'static str), @@ -253,9 +327,9 @@ impl fmt::Display for StaticType { } write!(f, "]>") } - StaticType::Record(fields) => { + StaticType::Record(layout) => { write!(f, "{{")?; - for (i, (k, v)) in fields.iter().enumerate() { + for (i, (k, v)) in layout.fields.iter().enumerate() { if i > 0 { write!(f, ", ")?; } @@ -263,6 +337,7 @@ impl fmt::Display for StaticType { } write!(f, "}}") } + StaticType::FieldAccessor(k) => write!(f, ".{}", k.name()), StaticType::Function(sig) => { write!(f, "fn({}) -> {}", sig.params, sig.ret) } @@ -312,6 +387,10 @@ impl StaticType { inner.is_assignable_from(&StaticType::Matrix(m_inner.clone(), sub_shape)) } } + // Records are assignable if their layouts match (Structural identity via interning) + (StaticType::Record(a), StaticType::Record(b)) => { + std::sync::Arc::ptr_eq(a, b) + } _ => false, } } @@ -320,6 +399,26 @@ impl StaticType { pub fn resolve_call(&self, args_ty: &StaticType) -> Option { match self { StaticType::Any => Some(StaticType::Any), + StaticType::FieldAccessor(k) => { + // (.name { :name val }) + // The args_ty should be a Tuple of [Record] + let rec_ty = if let StaticType::Tuple(t) = args_ty { + t.first()? + } else { + args_ty + }; + + match rec_ty { + StaticType::Record(layout) => { + if let Some(idx) = layout.index_of(*k) { + return Some(layout.fields[idx].1.clone()); + } + } + StaticType::Any => return Some(StaticType::Any), + _ => {} + } + None + } StaticType::Function(sig) => { if sig.params.is_assignable_from(args_ty) { Some(sig.ret.clone()) @@ -370,10 +469,12 @@ impl Value { } pub fn make_record(keys: Vec, values: Vec) -> Self { - Value::Record(Rc::new(RecordData { - keys: Rc::new(keys), - values: Rc::new(values), - })) + let fields: Vec<_> = keys + .into_iter() + .zip(values.iter().map(|v| v.static_type())) + .collect(); + let layout = RecordLayout::get_or_create(fields); + Value::Record(layout, Rc::new(values)) } pub fn make_function(purity: Purity, func: impl Fn(Vec) -> Value + 'static) -> Self { @@ -420,14 +521,9 @@ impl Value { StaticType::Tuple(element_types) } } - Value::Record(r) => { - let mut fields = Vec::with_capacity(r.values.len()); - for (i, v) in r.values.iter().enumerate() { - fields.push((r.keys[i], v.static_type())); - } - StaticType::Record(Rc::new(fields)) - } - Value::Function(_) => StaticType::Any, // Dynamic function + Value::Record(layout, _) => StaticType::Record(layout.clone()), + Value::FieldAccessor(k) => StaticType::FieldAccessor(*k), + Value::Function(_) => StaticType::Any, // Dynamic function Value::Object(o) => StaticType::Object(o.type_name()), Value::Cell(c) => c.borrow().static_type(), Value::TailCallRequest(_) => StaticType::Any, // Internal state, but typable as Any @@ -460,16 +556,17 @@ impl fmt::Display for Value { } write!(f, "]") } - Value::Record(r) => { + Value::Record(layout, values) => { write!(f, "{{")?; - for i in 0..r.values.len() { + for i in 0..values.len() { if i > 0 { write!(f, ", ")?; } - write!(f, ":{} {}", r.keys[i].name(), r.values[i])?; + write!(f, ":{} {}", layout.fields[i].0.name(), values[i])?; } write!(f, "}}") } + Value::FieldAccessor(k) => write!(f, ".{}", k.name()), Value::Function(f_meta) => write!(f, "", f_meta.purity), Value::Object(o) => write!(f, "<{}>", o.type_name()), Value::Cell(c) => write!(f, "{}", c.borrow()), diff --git a/src/ast/vm.rs b/src/ast/vm.rs index d6279ed..d1b949d 100644 --- a/src/ast/vm.rs +++ b/src/ast/vm.rs @@ -294,6 +294,22 @@ impl VM { Ok(val) } BoundKind::Get { addr, .. } => self.get_value(*addr), + + BoundKind::FieldAccessor(k) => Ok(Value::FieldAccessor(*k)), + + BoundKind::GetField { rec, field } => { + let rec_val = self.eval_internal(obs, rec)?; + if let Value::Record(layout, values) = rec_val { + if let Some(idx) = layout.index_of(*field) { + Ok(values[idx].clone()) + } else { + Err(format!("Record does not have field :{}", field.name())) + } + } else { + Err(format!("Attempt to access field on non-record: {}", rec_val)) + } + } + BoundKind::Set { addr, value } => { let val = self.eval_internal(obs, value)?; self.set_value(*addr, val.clone())?; @@ -368,6 +384,29 @@ impl VM { return Ok(Value::TailCallRequest(Box::new((obj, arg_vals)))); } Value::Function(f) => return Ok((f.func)(arg_vals)), + Value::FieldAccessor(k) => { + if arg_vals.len() != 1 { + return Err(format!( + "Field accessor .{} expects exactly 1 argument, got {}", + k.name(), + arg_vals.len() + )); + } + let rec = &arg_vals[0]; + if let Value::Record(layout, values) = rec { + if let Some(idx) = layout.index_of(k) { + return Ok(values[idx].clone()); + } else { + return Err(format!("Record does not have field :{}", k.name())); + } + } else { + return Err(format!( + "Field accessor .{} expects a record, got {}", + k.name(), + rec + )); + } + } _ => { return Err(format!( "Tail call target is not a function: {}", @@ -380,6 +419,29 @@ impl VM { loop { match func_val { Value::Function(f) => break Ok((f.func)(arg_vals)), + Value::FieldAccessor(k) => { + if arg_vals.len() != 1 { + break Err(format!( + "Field accessor .{} expects exactly 1 argument, got {}", + k.name(), + arg_vals.len() + )); + } + let rec = &arg_vals[0]; + if let Value::Record(layout, values) = rec { + if let Some(idx) = layout.index_of(k) { + break Ok(values[idx].clone()); + } else { + break Err(format!("Record does not have field :{}", k.name())); + } + } else { + break Err(format!( + "Field accessor .{} expects a record, got {}", + k.name(), + rec + )); + } + } Value::Object(obj) => { if let Some(closure) = obj.as_any().downcast_ref::() { let old_stack_top = self.stack.len(); diff --git a/src/integration_test.rs b/src/integration_test.rs index 953c006..1af3518 100644 --- a/src/integration_test.rs +++ b/src/integration_test.rs @@ -426,4 +426,85 @@ mod tests { "Definitions should be removed by DCE" ); } + + #[test] + fn test_record_basics() { + let env = Environment::new(); + let source = r#" + ((fn [user] [(.name user) (.age user)]) + {:name "Alice" :age 30}) + "#; + let res = env.run_script(source).unwrap(); + assert_eq!(format!("{}", res), "[\"Alice\" 30]"); + } + + #[test] + fn test_record_optimized_access() { + let env = Environment::new(); + let source = "(.price {:id 1 :price 99.5})"; + + // 1. Check result + let res = env.run_script(source).unwrap(); + assert_eq!(format!("{}", res), "99.5"); + + // 2. Verify optimization to GET_FIELD + let dump = env.dump_ast(source).unwrap(); + assert!(dump.contains("GetField: .price"), "Should be optimized to GetField. Dump:\n{}", dump); + } + + #[test] + fn test_first_class_field_accessor() { + let env = Environment::new(); + let source = r#" + (do + (def get-name .name) + ; Dynamic call to field accessor + (get-name {:name "Alice"})) + "#; + let res = env.run_script(source).unwrap(); + assert_eq!(format!("{}", res), "\"Alice\""); + } + + #[test] + fn test_record_constant_folding() { + let env = Environment::new(); + // Optimizer should fold (.x {:x 10}) into 10 + let source = "(.x {:x 10 :y 20})"; + let dump = env.dump_ast(source).unwrap(); + assert!(dump.contains("Constant: 10"), "Should be constant folded. Dump:\n{}", dump); + } + + #[test] + fn test_record_errors() { + let env = Environment::new(); + + // Both cases are reported by the TypeChecker since it can't resolve the call + // for a missing field or a non-record argument. + + // 1. Missing field + let res_missing = env.run_script("(.missing {:a 1})"); + assert!(res_missing.is_err()); + assert!(res_missing.unwrap_err().contains("Invalid arguments")); + + // 2. Not a record + let res_not_rec = env.run_script("(.name 123)"); + assert!(res_not_rec.is_err()); + assert!(res_not_rec.unwrap_err().contains("Invalid arguments")); + } + + #[test] + fn test_record_layout_interning() { + let env = Environment::new(); + let source = r#" + (do + (def r1 {:a 1 :b 2}) + (def r2 {:a 10 :b 20}) + ; Identical layouts result in identical types + (= r1 r2)) + "#; + // This will be false because values differ, but let's just check if it compiles and runs. + // To really test interning, we'd need a way to check if layouts are the same Arc. + let res = env.run_script(source).unwrap(); + assert_eq!(format!("{}", res), "false"); + } }