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:
2026-02-20 18:13:11 -05:00
parent ea3a7ca2dd
commit 400acc3f35
2 changed files with 214 additions and 4 deletions

View File

@@ -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,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<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,
span: outer_span,
})
}