diff --git a/examples/anon-record.ilo b/examples/anon-record.ilo new file mode 100644 index 00000000..d460aed5 --- /dev/null +++ b/examples/anon-record.ilo @@ -0,0 +1,75 @@ +-- ILO-54: Anonymous record literals — no typedef required. +-- +-- Exercises: construction, field access, pass-to-fn, return-from-fn, +-- destructure, and `with` update. + +-- Pass anonymous record to a function +greet x:_>t + x.name + +-- Return anonymous record from a function +make-point ax:n ay:n>_ + {x:ax y:ay} + +-- Basic construction and field access +access-name>t + r = {name:"alice" age:30} + r.name + +access-age>n + r = {name:"alice" age:30} + r.age + +-- Pass anonymous record to a function +pass-to-fn>t + greet {name:"bob"} + +-- Return anonymous record from a function and access fields +return-x>n + p = make-point 3 4 + p.x + +return-y>n + p = make-point 3 4 + p.y + +-- Destructure anonymous record +destruct-name>t + r = {name:"alice" age:30} + {name;age} = r + name + +destruct-age>n + r = {name:"alice" age:30} + {name;age} = r + age + +-- Update via with +with-name>t + r = {name:"alice" age:30} + r2 = r with name:"carol" + r2.name + +with-age>n + r = {name:"alice" age:30} + r2 = r with name:"carol" + r2.age + +-- run: access-name +-- out: alice +-- run: access-age +-- out: 30 +-- run: pass-to-fn +-- out: bob +-- run: return-x +-- out: 3 +-- run: return-y +-- out: 4 +-- run: destruct-name +-- out: alice +-- run: destruct-age +-- out: 30 +-- run: with-name +-- out: carol +-- run: with-age +-- out: 30 diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 66607678..5a502468 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -339,6 +339,13 @@ pub enum Expr { fields: Vec<(String, Expr)>, }, + /// Anonymous record literal: `{field:val field:val}` — no typename required. + /// Type checker synthesises a structural type; runtime uses `"__anon"` as the + /// Value::Record type_name since engines only care about field names. + AnonRecord { + fields: Vec<(String, Expr)>, + }, + /// Match expression: `?expr{arms}` or `?{arms}` used as value Match { subject: Option>, @@ -650,7 +657,7 @@ fn resolve_aliases_expr(expr: &mut Expr) { resolve_aliases_expr(item); } } - Expr::Record { fields, .. } => { + Expr::Record { fields, .. } | Expr::AnonRecord { fields } => { for (_, val) in fields { resolve_aliases_expr(val); } @@ -714,6 +721,12 @@ pub fn desugar_dot_var_index(program: &mut Program) { record_fields.insert(p.name.clone()); } } + // Also collect field names from anonymous record literals so that + // `r.name` where `name` happens to be a local variable is NOT + // rewritten to `at r name` — anonymous records are still records. + if let Decl::Function { body, .. } = decl { + collect_anon_record_fields_stmts(body, &mut record_fields); + } } for decl in &mut program.declarations { @@ -848,7 +861,7 @@ fn desugar_expr(expr: &mut Expr, scope: &[String], rf: &std::collections::HashSe desugar_expr(it, scope, rf); } } - Expr::Record { fields, .. } => { + Expr::Record { fields, .. } | Expr::AnonRecord { fields } => { for (_, v) in fields { desugar_expr(v, scope, rf); } @@ -913,6 +926,102 @@ fn desugar_expr(expr: &mut Expr, scope: &[String], rf: &std::collections::HashSe } } +/// Collect field names from all AnonRecord literals in a statement list. +fn collect_anon_record_fields_stmts( + stmts: &[Spanned], + out: &mut std::collections::HashSet, +) { + for stmt in stmts { + collect_anon_record_fields_stmt(&stmt.node, out); + } +} + +fn collect_anon_record_fields_stmt(stmt: &Stmt, out: &mut std::collections::HashSet) { + match stmt { + Stmt::Let { value, .. } => collect_anon_record_fields_expr(value, out), + Stmt::Expr(e) | Stmt::Return(e) => collect_anon_record_fields_expr(e, out), + Stmt::Break(Some(e)) => collect_anon_record_fields_expr(e, out), + Stmt::Guard { + condition, + body, + else_body, + .. + } => { + collect_anon_record_fields_expr(condition, out); + collect_anon_record_fields_stmts(body, out); + if let Some(eb) = else_body { + collect_anon_record_fields_stmts(eb, out); + } + } + Stmt::While { condition, body } => { + collect_anon_record_fields_expr(condition, out); + collect_anon_record_fields_stmts(body, out); + } + Stmt::ForEach { + collection, body, .. + } => { + collect_anon_record_fields_expr(collection, out); + collect_anon_record_fields_stmts(body, out); + } + Stmt::Destructure { value, .. } => collect_anon_record_fields_expr(value, out), + _ => {} + } +} + +fn collect_anon_record_fields_expr(expr: &Expr, out: &mut std::collections::HashSet) { + match expr { + Expr::AnonRecord { fields } => { + for (name, val) in fields { + out.insert(name.clone()); + collect_anon_record_fields_expr(val, out); + } + } + Expr::Record { fields, .. } => { + for (_, val) in fields { + collect_anon_record_fields_expr(val, out); + } + } + Expr::Call { args, .. } => { + for arg in args { + collect_anon_record_fields_expr(arg, out); + } + } + Expr::BinOp { left, right, .. } => { + collect_anon_record_fields_expr(left, out); + collect_anon_record_fields_expr(right, out); + } + Expr::UnaryOp { operand, .. } => collect_anon_record_fields_expr(operand, out), + Expr::Field { object, .. } => collect_anon_record_fields_expr(object, out), + Expr::Index { object, .. } => collect_anon_record_fields_expr(object, out), + Expr::With { object, updates } => { + collect_anon_record_fields_expr(object, out); + for (_, val) in updates { + collect_anon_record_fields_expr(val, out); + } + } + Expr::List(items) => { + for item in items { + collect_anon_record_fields_expr(item, out); + } + } + Expr::Ok(e) | Expr::Err(e) => collect_anon_record_fields_expr(e, out), + Expr::Ternary { + condition, + then_expr, + else_expr, + } => { + collect_anon_record_fields_expr(condition, out); + collect_anon_record_fields_expr(then_expr, out); + collect_anon_record_fields_expr(else_expr, out); + } + Expr::NilCoalesce { value, default } => { + collect_anon_record_fields_expr(value, out); + collect_anon_record_fields_expr(default, out); + } + _ => {} + } +} + /// Cycle-capability classifier for runtime values of a given static type. /// /// Background: ilo's runtime is reference-counted (Arc in the tree diff --git a/src/codegen/fmt.rs b/src/codegen/fmt.rs index 38805c0d..ac901ec3 100644 --- a/src/codegen/fmt.rs +++ b/src/codegen/fmt.rs @@ -578,6 +578,13 @@ fn fmt_expr(expr: &Expr, mode: FmtMode) -> String { let items_str: Vec = items.iter().map(|i| fmt_expr(i, mode)).collect(); format!("[{}]", items_str.join(", ")) } + Expr::AnonRecord { fields } => { + let fields_str: Vec = fields + .iter() + .map(|(n, v)| format!("{}:{}", n, fmt_expr(v, mode))) + .collect(); + format!("{{{}}}", fields_str.join(" ")) + } Expr::Record { type_name, fields } => { if fields.is_empty() { return type_name.clone(); diff --git a/src/codegen/python.rs b/src/codegen/python.rs index 5dab9ccb..88ecbda3 100644 --- a/src/codegen/python.rs +++ b/src/codegen/python.rs @@ -105,7 +105,9 @@ fn expr_uses_rd(expr: &Expr) -> bool { Expr::Ok(e) | Expr::Err(e) => expr_uses_rd(e), Expr::Field { object, .. } | Expr::Index { object, .. } => expr_uses_rd(object), Expr::List(items) => items.iter().any(expr_uses_rd), - Expr::Record { fields, .. } => fields.iter().any(|(_, e)| expr_uses_rd(e)), + Expr::Record { fields, .. } | Expr::AnonRecord { fields } => { + fields.iter().any(|(_, e)| expr_uses_rd(e)) + } Expr::Match { subject, arms } => { subject.as_ref().is_some_and(|s| expr_uses_rd(s)) || arms @@ -171,7 +173,9 @@ fn expr_uses_unwrap(expr: &Expr) -> bool { Expr::Ok(e) | Expr::Err(e) => expr_uses_unwrap(e), Expr::Field { object, .. } | Expr::Index { object, .. } => expr_uses_unwrap(object), Expr::List(items) => items.iter().any(expr_uses_unwrap), - Expr::Record { fields, .. } => fields.iter().any(|(_, e)| expr_uses_unwrap(e)), + Expr::Record { fields, .. } | Expr::AnonRecord { fields } => { + fields.iter().any(|(_, e)| expr_uses_unwrap(e)) + } Expr::Match { subject, arms } => { subject.as_ref().is_some_and(|s| expr_uses_unwrap(s)) || arms @@ -964,6 +968,13 @@ fn emit_expr(out: &mut String, level: usize, expr: &Expr) -> String { let items_str: Vec = items.iter().map(|i| emit_expr(out, level, i)).collect(); format!("[{}]", items_str.join(", ")) } + Expr::AnonRecord { fields } => { + let mut parts = Vec::new(); + for (name, val) in fields { + parts.push(format!("\"{}\": {}", name, emit_expr(out, level, val))); + } + format!("{{{}}}", parts.join(", ")) + } Expr::Record { type_name, fields } => { let mut parts = vec![format!("\"_type\": \"{}\"", type_name)]; for (name, val) in fields { diff --git a/src/graph.rs b/src/graph.rs index 518a8586..560192af 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -89,6 +89,11 @@ fn collect_calls(expr: &Expr, calls: &mut BTreeSet, types: &mut BTreeSet collect_calls(arg, calls, types); } } + Expr::AnonRecord { fields } => { + for (_, val) in fields { + collect_calls(val, calls, types); + } + } Expr::Record { type_name, fields, .. } => { diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 40e7151a..cc85b112 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -7983,7 +7983,9 @@ fn expr_refers_to(name: &str, expr: &Expr) -> bool { Expr::UnaryOp { operand, .. } => expr_refers_to(name, operand), Expr::Ok(inner) | Expr::Err(inner) => expr_refers_to(name, inner), Expr::List(items) => items.iter().any(|e| expr_refers_to(name, e)), - Expr::Record { fields, .. } => fields.iter().any(|(_, e)| expr_refers_to(name, e)), + Expr::Record { fields, .. } | Expr::AnonRecord { fields } => { + fields.iter().any(|(_, e)| expr_refers_to(name, e)) + } // Conservative: assume Match arms might reference `name`. Falls back // to the general path, which is correct (just slower) in the rare // case where a self-rebind RHS is wrapped in a match. @@ -8603,6 +8605,16 @@ fn eval_expr(env: &mut Env, expr: &Expr) -> Result { } Ok(Value::List(Arc::new(vals))) } + Expr::AnonRecord { fields } => { + let mut field_map = HashMap::new(); + for (name, val_expr) in fields { + field_map.insert(name.clone(), eval_expr(env, val_expr)?); + } + Ok(Value::Record { + type_name: "__anon".to_string(), + fields: field_map, + }) + } Expr::Record { type_name, fields } => { let mut field_map = HashMap::new(); for (name, val_expr) in fields { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 8604f9e1..4f0e64d9 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -969,7 +969,12 @@ impl Parser { // the dense single-line workaround they've been settling for. // Skip the brace-block path when the leading `{` is a destructure // pattern (`f p:pt>n;{x}=p;...`) — that's a statement, not a wrap. - let body = if self.peek() == Some(&Token::LBrace) && !self.is_destructure_pattern() { + // Also skip when it looks like an anonymous record literal `{field:val ...}`: + // that's a return-expression, not a brace-wrapped body. + let body = if self.peek() == Some(&Token::LBrace) + && !self.is_destructure_pattern() + && !self.is_anon_record_literal() + { self.parse_brace_body_or_record(&name)? } else { self.parse_body_or_record(&name)? @@ -3530,6 +3535,41 @@ or write `({fmt_name} \"...\" ...)` so its args are grouped." Ok(Expr::Record { type_name, fields }) } + /// Lookahead: does `{` start an anonymous record literal? + /// + /// Returns true when the token stream looks like `{ ident : ...` — i.e. + /// the first token inside the braces is an identifier immediately followed + /// by a colon. This is unambiguous: a destructure pattern `{a;b}=` uses + /// semicolons, a match/guard body block never starts with `ident:`, and + /// the existing map-literal friendly-error fires only on text/number heads. + fn is_anon_record_literal(&self) -> bool { + // Current token must be `{`; pos+1 is the first field name; pos+2 is `:`. + self.peek() == Some(&Token::LBrace) + && matches!(self.token_at(self.pos + 1), Some(Token::Ident(_))) + && self.token_at(self.pos + 2) == Some(&Token::Colon) + } + + /// Parse the body of an anonymous record literal (after `{` has been consumed). + /// + /// Grammar: `ident:atom (ident:atom)*` then expects `}` from caller. + fn parse_anon_record_body(&mut self) -> Result { + let mut fields = Vec::new(); + while self.is_named_field_ahead() { + let fname = self.expect_ident()?; + self.expect(&Token::Colon)?; + let value = self.parse_atom()?; + fields.push((fname, value)); + } + if fields.is_empty() { + return Err(self.error_hint( + "ILO-P009", + "anonymous record literal `{...}` must have at least one field".into(), + "use `{field:value}` syntax, e.g. `{name:\"alice\" age:30}`".into(), + )); + } + Ok(Expr::AnonRecord { fields }) + } + /// Lookahead: does the token at `pos` start a prefix binary operator /// (operator followed by 2+ simple atoms before the next operator/terminator)? /// @@ -3839,6 +3879,10 @@ results first: `r={first_op}a b;…r` keeps each step explicit." /// Can the current token start an atom? fn can_start_atom(&self) -> bool { + // Anonymous record literal `{field:val ...}` is also a valid atom start. + if self.is_anon_record_literal() { + return true; + } matches!( self.peek(), Some(Token::Ident(_)) @@ -4083,6 +4127,13 @@ results first: `r={first_op}a b;…r` keeps each step explicit." self.expect(&Token::RBracket)?; Ok(Expr::List(items)) } + Some(Token::LBrace) if self.is_anon_record_literal() => { + self.advance(); // consume `{` + let expr = self.parse_anon_record_body()?; + self.expect(&Token::RBrace)?; + let expr = self.parse_field_chain(expr, None)?; + Ok(expr) + } Some(Token::Ident(name)) => { self.advance(); // Zero-arg builtins used as operands (arguments to other calls) @@ -4580,7 +4631,7 @@ For variable-position list indexing bind the head first: \ self.collect_free_in_expr(i, params, local, free); } } - Expr::Record { fields, .. } => { + Expr::Record { fields, .. } | Expr::AnonRecord { fields } => { for (_, v) in fields { self.collect_free_in_expr(v, params, local, free); } diff --git a/src/verify.rs b/src/verify.rs index 4f4bf3fe..5f6c8c9e 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -19,6 +19,10 @@ pub enum Ty { /// Function type: params then return. `F n n` = Fn(vec![Number], Number). Fn(Vec, Box), Named(String), + /// Structural record type inferred from an anonymous record literal `{f:v ...}`. + /// Two `AnonRecord` types are compatible when they have exactly the same field + /// names and compatible field types (order-independent). + AnonRecord(Vec<(String, Ty)>), Unknown, } @@ -48,6 +52,10 @@ impl std::fmt::Display for Ty { write!(f, " {ret}") } Ty::Named(name) => write!(f, "{name}"), + Ty::AnonRecord(fields) => { + let parts: Vec = fields.iter().map(|(n, t)| format!("{n}:{t}")).collect(); + write!(f, "{{{}}}", parts.join(" ")) + } Ty::Unknown => write!(f, "_"), } } @@ -223,6 +231,18 @@ fn compatible(a: &Ty, b: &Ty) -> bool { && compatible(ar, br) } (Ty::Named(a), Ty::Named(b)) => a == b, + // Two anonymous records unify when they have the same field names (order-independent) + // and compatible field types. + (Ty::AnonRecord(a_fields), Ty::AnonRecord(b_fields)) => { + if a_fields.len() != b_fields.len() { + return false; + } + let b_map: std::collections::HashMap<&str, &Ty> = + b_fields.iter().map(|(n, t)| (n.as_str(), t)).collect(); + a_fields + .iter() + .all(|(n, t)| b_map.get(n.as_str()).is_some_and(|bt| compatible(t, bt))) + } _ => false, } } @@ -4384,6 +4404,27 @@ impl VerifyContext { Stmt::Destructure { bindings, value } => { let record_ty = self.infer_expr(func, scope, value, span); match &record_ty { + Ty::AnonRecord(fields_ty) => { + let fields_ty = fields_ty.clone(); + for binding in bindings { + if let Some((_, fty)) = fields_ty.iter().find(|(n, _)| n == binding) { + scope_insert(scope, binding.clone(), fty.clone()); + } else { + let field_names: Vec = + fields_ty.iter().map(|(n, _)| n.clone()).collect(); + let hint = closest_match(binding, field_names.iter()) + .map(|s| format!("did you mean '{s}'?")); + self.err( + "ILO-T019", + func, + format!("no field '{binding}' on anonymous record"), + hint, + Some(span), + ); + scope_insert(scope, binding.clone(), Ty::Unknown); + } + } + } Ty::Named(type_name) => { if let Some(type_def) = self.types.get(type_name).cloned() { for binding in bindings { @@ -5323,6 +5364,16 @@ impl VerifyContext { } } + Expr::AnonRecord { fields } => { + // Infer each field's type and return a structural AnonRecord type. + // No declaration required; shape is derived entirely from the literal. + let inferred: Vec<(String, Ty)> = fields + .iter() + .map(|(n, e)| (n.clone(), self.infer_expr(func, scope, e, span))) + .collect(); + Ty::AnonRecord(inferred) + } + Expr::Record { type_name, fields } => { if let Some(type_def) = self.types.get(type_name) { let def_fields = type_def.fields.clone(); @@ -5402,6 +5453,24 @@ impl VerifyContext { return Ty::Nil; } match &obj_ty { + Ty::AnonRecord(fields_ty) => { + if let Some((_, fty)) = fields_ty.iter().find(|(n, _)| n == field) { + fty.clone() + } else { + let field_names: Vec = + fields_ty.iter().map(|(n, _)| n.clone()).collect(); + let hint = closest_match(field, field_names.iter()) + .map(|s| format!("did you mean '{s}'?")); + self.err( + "ILO-T019", + func, + format!("no field '{field}' on anonymous record"), + hint, + Some(span), + ); + Ty::Unknown + } + } Ty::Named(type_name) => { if let Some(type_def) = self.types.get(type_name) { if let Some((_, fty)) = type_def.fields.iter().find(|(n, _)| n == field) @@ -5614,6 +5683,22 @@ ilo has no tuple type." Expr::With { object, updates } => { let obj_ty = self.infer_expr(func, scope, object, span); match &obj_ty { + Ty::AnonRecord(fields_ty) => { + // Build updated fields: carry through unchanged fields, replace updated ones. + let def_fields = fields_ty.clone(); + let mut new_fields = def_fields.clone(); + for (fname, expr) in updates { + if let Some(pos) = new_fields.iter().position(|(n, _)| n == fname) { + let actual = self.infer_expr(func, scope, expr, span); + new_fields[pos] = (fname.clone(), actual); + } else { + // New field being added via `with` — allowed for anonymous records + let actual = self.infer_expr(func, scope, expr, span); + new_fields.push((fname.clone(), actual)); + } + } + Ty::AnonRecord(new_fields) + } Ty::Named(type_name) => { if let Some(type_def) = self.types.get(type_name) { let def_fields = type_def.fields.clone(); diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 3ab6d8c0..b6b3854f 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -5757,6 +5757,81 @@ impl RegCompiler { } } + Expr::AnonRecord { fields } => { + // Anonymous record: synthesize a stable type name from the sorted + // field list so that two literals with the same shape share one + // registry entry (matching the structural unification the verifier + // promises). The name is internal — agents never see it. + let mut sorted_names: Vec<&str> = fields.iter().map(|(n, _)| n.as_str()).collect(); + sorted_names.sort_unstable(); + let type_name = format!("__anon_{}", sorted_names.join("_")); + let fields_owned: Vec<(String, _)> = fields.clone(); + // Delegate to the same logic as named Record by building an + // owned Vec and reusing the same bytecode path inline. + let type_id = match self.type_registry.name_to_id.get(&type_name) { + Some(&id) => id, + None => { + let field_names: Vec = + fields_owned.iter().map(|(n, _)| n.clone()).collect(); + self.type_registry + .register(type_name.clone(), field_names, 0) + } + }; + let canonical_order: Vec = + self.type_registry.types[type_id as usize].fields.clone(); + let source_fields: HashMap<&str, &Expr> = + fields_owned.iter().map(|(n, e)| (n.as_str(), e)).collect(); + let n = canonical_order.len(); + let pre_reg = self.next_reg as usize; + let fits_contiguous = n <= 255 && type_id <= 255 && pre_reg + 2 * n < 255; + assert!( + type_id <= 255, + "type_id {} exceeds 8-bit limit in OP_RECNEW", + type_id + ); + if fits_contiguous { + let ordered_regs: Vec = canonical_order + .iter() + .map(|fname| { + let expr = source_fields[fname.as_str()]; + self.compile_expr(expr) + }) + .collect(); + let a = self.alloc_reg(); + let fields_base = self.next_reg; + assert!( + (self.next_reg as usize) + ordered_regs.len() <= 255, + "register overflow: anonymous record literal requires too many register slots" + ); + self.next_reg += ordered_regs.len() as u8; + if self.next_reg > self.max_reg { + self.max_reg = self.next_reg; + } + for (i, &field_reg) in ordered_regs.iter().enumerate() { + let target = fields_base + i as u8; + if field_reg != target { + self.emit_abc(OP_MOVE, target, field_reg, 0); + } + } + let bx = (type_id << 8) | ordered_regs.len() as u16; + self.emit_abx(OP_RECNEW, a, bx); + self.reg_record_type[a as usize] = type_id; + a + } else { + let a = self.alloc_reg(); + self.emit_abx(OP_RECNEW_EMPTY, a, type_id); + let after_result = self.next_reg; + for (i, fname) in canonical_order.iter().enumerate() { + let expr = source_fields[fname.as_str()]; + let val_reg = self.compile_expr(expr); + self.emit_abc(OP_RECSETFIELD, a, val_reg, i as u8); + self.next_reg = after_result; + } + self.reg_record_type[a as usize] = type_id; + a + } + } + Expr::Record { type_name, fields } => { // Look up or auto-register type in registry let type_id = match self.type_registry.name_to_id.get(type_name) {