diff --git a/src/ast.rs b/src/ast.rs index 3984b77..9df0434 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -541,7 +541,9 @@ pub enum Expr { span: Span, }, /// Record literal: { name: "Alice", age: 30 } + /// With optional spread: { ...base, name: "Bob" } Record { + spread: Option>, fields: Vec<(Ident, Expr)>, span: Span, }, diff --git a/src/formatter.rs b/src/formatter.rs index 91621bf..b6ff1e0 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -688,15 +688,17 @@ impl Formatter { .join(", ") ) } - Expr::Record { fields, .. } => { - format!( - "{{ {} }}", - fields - .iter() - .map(|(name, val)| format!("{}: {}", name.name, self.format_expr(val))) - .collect::>() - .join(", ") - ) + Expr::Record { + spread, fields, .. + } => { + let mut parts = Vec::new(); + if let Some(spread_expr) = spread { + parts.push(format!("...{}", self.format_expr(spread_expr))); + } + for (name, val) in fields { + parts.push(format!("{}: {}", name.name, self.format_expr(val))); + } + format!("{{ {} }}", parts.join(", ")) } Expr::EffectOp { effect, operation, args, .. } => { format!( diff --git a/src/lexer.rs b/src/lexer.rs index 698a149..c973bec 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -90,6 +90,7 @@ pub enum TokenKind { Arrow, // => ThinArrow, // -> Dot, // . + DotDotDot, // ... Colon, // : ColonColon, // :: Comma, // , @@ -181,6 +182,7 @@ impl fmt::Display for TokenKind { TokenKind::Arrow => write!(f, "=>"), TokenKind::ThinArrow => write!(f, "->"), TokenKind::Dot => write!(f, "."), + TokenKind::DotDotDot => write!(f, "..."), TokenKind::Colon => write!(f, ":"), TokenKind::ColonColon => write!(f, "::"), TokenKind::Comma => write!(f, ","), @@ -373,7 +375,22 @@ impl<'a> Lexer<'a> { TokenKind::Pipe } } - '.' => TokenKind::Dot, + '.' => { + if self.peek() == Some('.') { + // Check for ... (need to peek past second dot) + // We look at source directly since we can only peek one ahead + let next_next = self.source[self.pos..].chars().nth(1); + if next_next == Some('.') { + self.advance(); // consume second '.' + self.advance(); // consume third '.' + TokenKind::DotDotDot + } else { + TokenKind::Dot + } + } else { + TokenKind::Dot + } + } ':' => { if self.peek() == Some(':') { self.advance(); diff --git a/src/linter.rs b/src/linter.rs index e6191f3..07b4119 100644 --- a/src/linter.rs +++ b/src/linter.rs @@ -513,7 +513,10 @@ impl Linter { Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => { self.collect_refs_expr(object); } - Expr::Record { fields, .. } => { + Expr::Record { spread, fields, .. } => { + if let Some(spread_expr) = spread { + self.collect_refs_expr(spread_expr); + } for (_, val) in fields { self.collect_refs_expr(val); } diff --git a/src/lsp.rs b/src/lsp.rs index e97bd67..c292a51 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -1571,7 +1571,10 @@ fn collect_call_site_hints( collect_call_site_hints(source, e, param_names, hints); } } - Expr::Record { fields, .. } => { + Expr::Record { spread, fields, .. } => { + if let Some(spread_expr) = spread { + collect_call_site_hints(source, spread_expr, param_names, hints); + } for (_, e) in fields { collect_call_site_hints(source, e, param_names, hints); } diff --git a/src/parser.rs b/src/parser.rs index cbdcec8..114e85d 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -2208,6 +2208,11 @@ impl Parser { })); } + // Check for record spread: { ...expr, field: val } + if matches!(self.peek_kind(), TokenKind::DotDotDot) { + return self.parse_record_expr_rest(start); + } + // Check if it's a record (ident: expr) or block if matches!(self.peek_kind(), TokenKind::Ident(_)) { let lookahead = self.tokens.get(self.pos + 1).map(|t| &t.kind); @@ -2222,6 +2227,20 @@ impl Parser { fn parse_record_expr_rest(&mut self, start: Span) -> Result { let mut fields = Vec::new(); + let mut spread = None; + + // Check for spread: { ...expr, ... } + if self.check(TokenKind::DotDotDot) { + self.advance(); // consume ... + let spread_expr = self.parse_expr()?; + spread = Some(Box::new(spread_expr)); + + self.skip_newlines(); + if self.check(TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } while !self.check(TokenKind::RBrace) { let name = self.parse_ident()?; @@ -2238,7 +2257,11 @@ impl Parser { self.expect(TokenKind::RBrace)?; let span = start.merge(self.previous_span()); - Ok(Expr::Record { fields, span }) + Ok(Expr::Record { + spread, + fields, + span, + }) } fn parse_block_rest(&mut self, start: Span) -> Result { diff --git a/src/symbol_table.rs b/src/symbol_table.rs index 677a7a0..a7b6962 100644 --- a/src/symbol_table.rs +++ b/src/symbol_table.rs @@ -527,7 +527,10 @@ impl SymbolTable { self.visit_expr(e, scope_idx); } } - Expr::Record { fields, .. } => { + Expr::Record { spread, fields, .. } => { + if let Some(spread_expr) = spread { + self.visit_expr(spread_expr, scope_idx); + } for (_, e) in fields { self.visit_expr(e, scope_idx); } diff --git a/src/typechecker.rs b/src/typechecker.rs index 669c79b..21ff54a 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -339,7 +339,10 @@ fn references_params(expr: &Expr, params: &[&str]) -> bool { Expr::Lambda { body, .. } => references_params(body, params), Expr::Tuple { elements, .. } => elements.iter().any(|e| references_params(e, params)), Expr::List { elements, .. } => elements.iter().any(|e| references_params(e, params)), - Expr::Record { fields, .. } => fields.iter().any(|(_, e)| references_params(e, params)), + Expr::Record { spread, fields, .. } => { + spread.as_ref().is_some_and(|s| references_params(s, params)) + || fields.iter().any(|(_, e)| references_params(e, params)) + } Expr::Match { scrutinee, arms, .. } => { references_params(scrutinee, params) || arms.iter().any(|a| references_params(&a.body, params)) @@ -516,8 +519,9 @@ fn has_recursive_calls(func_name: &str, body: &Expr) -> bool { Expr::Tuple { elements, .. } | Expr::List { elements, .. } => { elements.iter().any(|e| has_recursive_calls(func_name, e)) } - Expr::Record { fields, .. } => { - fields.iter().any(|(_, e)| has_recursive_calls(func_name, e)) + Expr::Record { spread, fields, .. } => { + spread.as_ref().is_some_and(|s| has_recursive_calls(func_name, s)) + || fields.iter().any(|(_, e)| has_recursive_calls(func_name, e)) } Expr::Field { object, .. } | Expr::TupleIndex { object, .. } => has_recursive_calls(func_name, object), Expr::Let { value, body, .. } => { @@ -672,6 +676,7 @@ fn generate_auto_migration_expr( // Build the record expression Some(Expr::Record { + spread: None, fields: field_exprs, span, }) @@ -1744,7 +1749,11 @@ impl TypeChecker { span, } => self.infer_block(statements, result, *span), - Expr::Record { fields, span } => self.infer_record(fields, *span), + Expr::Record { + spread, + fields, + span, + } => self.infer_record(spread.as_deref(), fields, *span), Expr::Tuple { elements, span } => self.infer_tuple(elements, *span), @@ -2551,12 +2560,46 @@ impl TypeChecker { self.infer_expr(result) } - fn infer_record(&mut self, fields: &[(Ident, Expr)], _span: Span) -> Type { - let field_types: Vec<(String, Type)> = fields + fn infer_record( + &mut self, + spread: Option<&Expr>, + fields: &[(Ident, Expr)], + span: Span, + ) -> Type { + // Start with spread fields if present + let mut field_types: Vec<(String, Type)> = if let Some(spread_expr) = spread { + let spread_type = self.infer_expr(spread_expr); + match spread_type { + Type::Record(spread_fields) => spread_fields, + _ => { + self.errors.push(TypeError { + message: format!( + "Spread expression must be a record type, got {}", + spread_type + ), + span, + }); + Vec::new() + } + } + } else { + Vec::new() + }; + + // Apply explicit field overrides + let explicit_types: Vec<(String, Type)> = fields .iter() .map(|(name, expr)| (name.name.clone(), self.infer_expr(expr))) .collect(); + for (name, typ) in explicit_types { + if let Some(existing) = field_types.iter_mut().find(|(n, _)| n == &name) { + existing.1 = typ; + } else { + field_types.push((name, typ)); + } + } + Type::Record(field_types) }