diff --git a/src/main.rs b/src/main.rs index 67128bd..3edb774 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4081,6 +4081,72 @@ c")"#; assert_eq!(eval("let x = { a: 1, b: 2 } == { a: 1, b: 3 }").unwrap(), "false"); } + #[test] + fn test_record_spread() { + let source = r#" + let base = { x: 1, y: 2, z: 3 } + let updated = { ...base, y: 20 } + let result = updated.y + "#; + assert_eq!(eval(source).unwrap(), "20"); + } + + #[test] + fn test_deep_path_record_update() { + // Basic deep path: { ...base, pos.x: val } desugars to { ...base, pos: { ...base.pos, x: val } } + let source = r#" + let npc = { name: "Goblin", pos: { x: 10, y: 20 } } + let moved = { ...npc, pos.x: 50, pos.y: 60 } + let result = moved.pos.x + "#; + assert_eq!(eval(source).unwrap(), "50"); + + // Verify other fields are preserved through spread + let source2 = r#" + let npc = { name: "Goblin", pos: { x: 10, y: 20 } } + let moved = { ...npc, pos.x: 50 } + let result = moved.pos.y + "#; + assert_eq!(eval(source2).unwrap(), "20"); + + // Verify top-level spread fields preserved + let source3 = r#" + let npc = { name: "Goblin", pos: { x: 10, y: 20 } } + let moved = { ...npc, pos.x: 50 } + let result = moved.name + "#; + assert_eq!(eval(source3).unwrap(), "\"Goblin\""); + + // Mix of flat and deep path fields + let source4 = r#" + let npc = { name: "Goblin", pos: { x: 10, y: 20 }, hp: 100 } + let updated = { ...npc, pos.x: 50, hp: 80 } + let result = (updated.pos.x, updated.hp, updated.name) + "#; + assert_eq!(eval(source4).unwrap(), "(50, 80, \"Goblin\")"); + } + + #[test] + fn test_deep_path_record_multilevel() { + // Multi-level deep path: world.physics.gravity + let source = r#" + let world = { name: "Earth", physics: { gravity: { x: 0, y: -10 }, drag: 1 } } + let updated = { ...world, physics.gravity.y: -20 } + let result = (updated.physics.gravity.y, updated.physics.drag, updated.name) + "#; + assert_eq!(eval(source).unwrap(), "(-20, 1, \"Earth\")"); + } + + #[test] + fn test_deep_path_conflict_error() { + // Field appears as both flat and deep path — should error + let result = eval(r#" + let base = { pos: { x: 1, y: 2 } } + let bad = { ...base, pos: { x: 10, y: 20 }, pos.x: 30 } + "#); + assert!(result.is_err()); + } + #[test] fn test_invalid_escape_sequence() { let result = eval(r#"let x = "\z""#); diff --git a/src/parser.rs b/src/parser.rs index 608d8b9..07891a7 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -2296,12 +2296,34 @@ impl Parser { return self.parse_record_expr_rest(start); } - // Check if it's a record (ident: expr) or block + // Check if it's a record (ident: expr or ident.path: expr) or block if matches!(self.peek_kind(), TokenKind::Ident(_)) { let lookahead = self.tokens.get(self.pos + 1).map(|t| &t.kind); if matches!(lookahead, Some(TokenKind::Colon)) { return self.parse_record_expr_rest(start); } + // Check for deep path record: { ident.ident...: expr } + if matches!(lookahead, Some(TokenKind::Dot)) { + let mut look = self.pos + 2; + loop { + match self.tokens.get(look).map(|t| &t.kind) { + Some(TokenKind::Ident(_)) => { + look += 1; + match self.tokens.get(look).map(|t| &t.kind) { + Some(TokenKind::Colon) => { + return self.parse_record_expr_rest(start); + } + Some(TokenKind::Dot) => { + look += 1; + continue; + } + _ => break, + } + } + _ => break, + } + } + } } // It's a block @@ -2309,8 +2331,9 @@ impl Parser { } fn parse_record_expr_rest(&mut self, start: Span) -> Result { - let mut fields = Vec::new(); + let mut raw_fields: Vec<(Vec, Expr)> = Vec::new(); let mut spread = None; + let mut has_deep_paths = false; // Check for spread: { ...expr, ... } if self.check(TokenKind::DotDotDot) { @@ -2327,9 +2350,21 @@ impl Parser { while !self.check(TokenKind::RBrace) { let name = self.parse_ident()?; + + // Check for dotted path: pos.x, pos.x.y, etc. + let mut path = vec![name]; + while self.check(TokenKind::Dot) { + self.advance(); // consume . + let segment = self.parse_ident()?; + path.push(segment); + } + if path.len() > 1 { + has_deep_paths = true; + } + self.expect(TokenKind::Colon)?; let value = self.parse_expr()?; - fields.push((name, value)); + raw_fields.push((path, value)); self.skip_newlines(); if self.check(TokenKind::Comma) { @@ -2340,10 +2375,119 @@ impl Parser { self.expect(TokenKind::RBrace)?; let span = start.merge(self.previous_span()); + + if has_deep_paths { + Self::desugar_deep_fields(spread, raw_fields, span) + } else { + // No deep paths — use flat fields directly (common case, no allocation overhead) + let fields = raw_fields + .into_iter() + .map(|(mut path, value)| (path.remove(0), value)) + .collect(); + Ok(Expr::Record { + spread, + fields, + span, + }) + } + } + + /// Desugar deep path record fields into nested record spread expressions. + /// `{ ...base, pos.x: vx, pos.y: vy }` becomes `{ ...base, pos: { ...base.pos, x: vx, y: vy } }` + fn desugar_deep_fields( + spread: Option>, + raw_fields: Vec<(Vec, Expr)>, + outer_span: Span, + ) -> Result { + use std::collections::HashMap; + + // Group fields by first path segment, preserving order + let mut groups: Vec<(String, Vec<(Vec, Expr)>)> = Vec::new(); + let mut group_map: HashMap = HashMap::new(); + + for (path, value) in raw_fields { + let key = path[0].name.clone(); + if let Some(&idx) = group_map.get(&key) { + groups[idx].1.push((path, value)); + } else { + group_map.insert(key.clone(), groups.len()); + groups.push((key, vec![(path, value)])); + } + } + + let mut fields = Vec::new(); + for (_, group) in groups { + let first_ident = group[0].0[0].clone(); + + let has_flat = group.iter().any(|(p, _)| p.len() == 1); + let has_deep = group.iter().any(|(p, _)| p.len() > 1); + + if has_flat && has_deep { + return Err(ParseError { + message: format!( + "Field '{}' appears as both a direct field and a deep path prefix", + first_ident.name + ), + span: first_ident.span, + }); + } + + if has_flat { + if group.len() > 1 { + return Err(ParseError { + message: format!("Duplicate field '{}'", first_ident.name), + span: group[1].0[0].span, + }); + } + let (_, value) = group.into_iter().next().unwrap(); + fields.push((first_ident, value)); + } else { + // Deep paths — create nested record with spread from parent + let sub_spread = spread.as_ref().map(|s| { + Box::new(Expr::Field { + object: s.clone(), + field: first_ident.clone(), + span: first_ident.span, + }) + }); + + // Strip first segment from all paths + let sub_fields: Vec<(Vec, Expr)> = group + .into_iter() + .map(|(mut path, value)| { + path.remove(0); + (path, value) + }) + .collect(); + + let has_nested_deep = sub_fields.iter().any(|(p, _)| p.len() > 1); + if has_nested_deep { + // Recursively desugar deeper paths + let nested = + Self::desugar_deep_fields(sub_spread, sub_fields, first_ident.span)?; + fields.push((first_ident, nested)); + } else { + // All sub-paths are single-segment — build Record directly + let flat_fields: Vec<(Ident, Expr)> = sub_fields + .into_iter() + .map(|(mut path, value)| (path.remove(0), value)) + .collect(); + fields.push(( + first_ident.clone(), + Expr::Record { + spread: sub_spread, + fields: flat_fields, + span: first_ident.span, + }, + )); + } + } + } + Ok(Expr::Record { spread, fields, - span, + span: outer_span, }) }