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");
|
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]
|
#[test]
|
||||||
fn test_invalid_escape_sequence() {
|
fn test_invalid_escape_sequence() {
|
||||||
let result = eval(r#"let x = "\z""#);
|
let result = eval(r#"let x = "\z""#);
|
||||||
|
|||||||
152
src/parser.rs
152
src/parser.rs
@@ -2296,12 +2296,34 @@ impl Parser {
|
|||||||
return self.parse_record_expr_rest(start);
|
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(_)) {
|
if matches!(self.peek_kind(), TokenKind::Ident(_)) {
|
||||||
let lookahead = self.tokens.get(self.pos + 1).map(|t| &t.kind);
|
let lookahead = self.tokens.get(self.pos + 1).map(|t| &t.kind);
|
||||||
if matches!(lookahead, Some(TokenKind::Colon)) {
|
if matches!(lookahead, Some(TokenKind::Colon)) {
|
||||||
return self.parse_record_expr_rest(start);
|
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
|
// It's a block
|
||||||
@@ -2309,8 +2331,9 @@ impl Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_record_expr_rest(&mut self, start: Span) -> Result<Expr, ParseError> {
|
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 spread = None;
|
||||||
|
let mut has_deep_paths = false;
|
||||||
|
|
||||||
// Check for spread: { ...expr, ... }
|
// Check for spread: { ...expr, ... }
|
||||||
if self.check(TokenKind::DotDotDot) {
|
if self.check(TokenKind::DotDotDot) {
|
||||||
@@ -2327,9 +2350,21 @@ impl Parser {
|
|||||||
|
|
||||||
while !self.check(TokenKind::RBrace) {
|
while !self.check(TokenKind::RBrace) {
|
||||||
let name = self.parse_ident()?;
|
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)?;
|
self.expect(TokenKind::Colon)?;
|
||||||
let value = self.parse_expr()?;
|
let value = self.parse_expr()?;
|
||||||
fields.push((name, value));
|
raw_fields.push((path, value));
|
||||||
|
|
||||||
self.skip_newlines();
|
self.skip_newlines();
|
||||||
if self.check(TokenKind::Comma) {
|
if self.check(TokenKind::Comma) {
|
||||||
@@ -2340,10 +2375,119 @@ impl Parser {
|
|||||||
|
|
||||||
self.expect(TokenKind::RBrace)?;
|
self.expect(TokenKind::RBrace)?;
|
||||||
let span = start.merge(self.previous_span());
|
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 {
|
Ok(Expr::Record {
|
||||||
spread,
|
spread,
|
||||||
fields,
|
fields,
|
||||||
span,
|
span: outer_span,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user