feat: add deep path record update syntax
Adds parser desugaring for `{ ...base, pos.x: val, pos.y: val2 }` which
expands to `{ ...base, pos: { ...base.pos, x: val, y: val2 } }`.
Supports arbitrary nesting depth (e.g. world.physics.gravity.y).
Detects conflicts between flat and deep path fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
66
src/main.rs
66
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""#);
|
||||
|
||||
150
src/parser.rs
150
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<Expr, ParseError> {
|
||||
let mut fields = Vec::new();
|
||||
let mut raw_fields: Vec<(Vec<Ident>, 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,12 +2375,121 @@ 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<Box<Expr>>,
|
||||
raw_fields: Vec<(Vec<Ident>, Expr)>,
|
||||
outer_span: Span,
|
||||
) -> Result<Expr, ParseError> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Group fields by first path segment, preserving order
|
||||
let mut groups: Vec<(String, Vec<(Vec<Ident>, Expr)>)> = Vec::new();
|
||||
let mut group_map: HashMap<String, usize> = 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<Ident>, 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: outer_span,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_block_rest(&mut self, start: Span) -> Result<Expr, ParseError> {
|
||||
let mut statements = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user