#![allow(dead_code)]
use crate::parse::ast::{
Arg, ArrayElem, BindTarget, Expr, FStringPart, ObjField, PatchOp, PathStep, PipeStep, Step,
};
use std::collections::HashSet;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub(crate) enum RootRef {
Root,
Local(Arc<str>),
Current(u32),
Composite(Arc<Expr>),
}
impl PartialEq for RootRef {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(RootRef::Root, RootRef::Root) => true,
(RootRef::Local(a), RootRef::Local(b)) => a == b,
(RootRef::Current(a), RootRef::Current(b)) => a == b,
(RootRef::Composite(a), RootRef::Composite(b)) => Arc::ptr_eq(a, b),
_ => false,
}
}
}
impl Eq for RootRef {}
impl std::hash::Hash for RootRef {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
match self {
RootRef::Root => 0u8.hash(state),
RootRef::Local(name) => {
1u8.hash(state);
name.hash(state);
}
RootRef::Current(id) => {
2u8.hash(state);
id.hash(state);
}
RootRef::Composite(arc) => {
3u8.hash(state);
(Arc::as_ptr(arc) as usize).hash(state);
}
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct WriteEffect {
pub root: RootRef,
pub op: PatchOp,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct EffectSummary {
pub reads: HashSet<RootRef>,
pub writes: Vec<WriteEffect>,
pub has_writes: bool,
}
impl EffectSummary {
fn merge(&mut self, other: EffectSummary) {
self.reads.extend(other.reads);
self.writes.extend(other.writes);
if other.has_writes {
self.has_writes = true;
}
}
}
pub(crate) struct EffectAnalyzer {
aliases: Vec<(Arc<str>, RootRef)>,
next_scope: u32,
scope_stack: Vec<u32>,
}
impl Default for EffectAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl EffectAnalyzer {
pub fn new() -> Self {
Self {
aliases: Vec::new(),
next_scope: 1,
scope_stack: vec![0],
}
}
pub fn analyze(&mut self, expr: &Expr) -> EffectSummary {
self.visit(expr)
}
fn current_scope(&self) -> u32 {
*self.scope_stack.last().unwrap_or(&0)
}
fn fresh_scope(&mut self) -> u32 {
let id = self.next_scope;
self.next_scope += 1;
id
}
fn resolve(&self, name: &str) -> Option<RootRef> {
self.aliases
.iter()
.rev()
.find(|(n, _)| n.as_ref() == name)
.map(|(_, r)| r.clone())
}
fn canonical_root(&self, expr: &Expr) -> RootRef {
match expr {
Expr::Root => RootRef::Root,
Expr::Current => RootRef::Current(self.current_scope()),
Expr::Ident(name) => self
.resolve(name)
.unwrap_or_else(|| RootRef::Local(Arc::from(name.as_str()))),
other => RootRef::Composite(Arc::new(other.clone())),
}
}
fn visit(&mut self, expr: &Expr) -> EffectSummary {
match expr {
Expr::Null
| Expr::Bool(_)
| Expr::Int(_)
| Expr::Float(_)
| Expr::Str(_)
| Expr::DeleteMark => EffectSummary::default(),
Expr::Match { scrutinee, arms } => {
let mut s = self.visit(scrutinee);
for a in arms {
if let Some(g) = a.guard.as_ref() {
s.merge(self.visit(g));
}
s.merge(self.visit(&a.body));
}
s
}
Expr::Root => {
let mut s = EffectSummary::default();
s.reads.insert(RootRef::Root);
s
}
Expr::Current => {
let mut s = EffectSummary::default();
s.reads.insert(RootRef::Current(self.current_scope()));
s
}
Expr::Ident(name) => {
let mut s = EffectSummary::default();
let r = self
.resolve(name)
.unwrap_or_else(|| RootRef::Local(Arc::from(name.as_str())));
s.reads.insert(r);
s
}
Expr::FString(parts) => {
let mut s = EffectSummary::default();
for p in parts {
if let FStringPart::Interp { expr, .. } = p {
s.merge(self.visit(expr));
}
}
s
}
Expr::Chain(base, steps) => {
let mut s = self.visit(base);
for step in steps {
s.merge(self.visit_step(step));
}
s
}
Expr::BinOp(l, _, r) => {
let mut s = self.visit(l);
s.merge(self.visit(r));
s
}
Expr::UnaryNeg(inner) | Expr::Not(inner) => self.visit(inner),
Expr::Kind { expr, .. } => self.visit(expr),
Expr::Coalesce(l, r) => {
let mut s = self.visit(l);
s.merge(self.visit(r));
s
}
Expr::Object(fields) => {
let mut s = EffectSummary::default();
for f in fields {
s.merge(self.visit_obj_field(f));
}
s
}
Expr::Array(elems) => {
let mut s = EffectSummary::default();
for e in elems {
match e {
ArrayElem::Expr(x) | ArrayElem::Spread(x) => s.merge(self.visit(x)),
}
}
s
}
Expr::Pipeline { base, steps } => {
let mut s = self.visit(base);
for st in steps {
match st {
PipeStep::Forward(e) => s.merge(self.visit(e)),
PipeStep::Bind(_) => { }
}
}
s
}
Expr::ListComp {
expr,
vars,
iter,
cond,
}
| Expr::SetComp {
expr,
vars,
iter,
cond,
}
| Expr::GenComp {
expr,
vars,
iter,
cond,
} => {
let mut s = self.visit(iter);
let scope = self.fresh_scope();
self.scope_stack.push(scope);
for v in vars {
self.aliases
.push((Arc::from(v.as_str()), RootRef::Current(scope)));
}
let inner_summary = {
let mut inner = self.visit(expr);
if let Some(c) = cond {
inner.merge(self.visit(c));
}
inner
};
for _ in vars {
self.aliases.pop();
}
self.scope_stack.pop();
s.merge(inner_summary);
s
}
Expr::DictComp {
key,
val,
vars,
iter,
cond,
} => {
let mut s = self.visit(iter);
let scope = self.fresh_scope();
self.scope_stack.push(scope);
for v in vars {
self.aliases
.push((Arc::from(v.as_str()), RootRef::Current(scope)));
}
let inner_summary = {
let mut inner = self.visit(key);
inner.merge(self.visit(val));
if let Some(c) = cond {
inner.merge(self.visit(c));
}
inner
};
for _ in vars {
self.aliases.pop();
}
self.scope_stack.pop();
s.merge(inner_summary);
s
}
Expr::Lambda { params, body } => {
let scope = self.fresh_scope();
self.scope_stack.push(scope);
for p in params {
self.aliases
.push((Arc::from(p.as_str()), RootRef::Current(scope)));
}
let inner = self.visit(body);
for _ in params {
self.aliases.pop();
}
self.scope_stack.pop();
inner
}
Expr::Let { name, init, body } => {
let init_summary = self.visit(init);
let alias = self.canonical_root(init);
self.aliases.push((Arc::from(name.as_str()), alias));
let body_summary = self.visit(body);
self.aliases.pop();
let mut s = init_summary;
s.merge(body_summary);
s
}
Expr::IfElse { cond, then_, else_ } => {
let mut s = self.visit(cond);
s.merge(self.visit(then_));
s.merge(self.visit(else_));
s
}
Expr::Try { body, default } => {
let mut s = self.visit(body);
s.merge(self.visit(default));
s
}
Expr::GlobalCall { args, .. } => {
let mut s = EffectSummary::default();
for a in args {
s.merge(self.visit_arg(a));
}
s
}
Expr::Cast { expr, .. } => self.visit(expr),
Expr::Patch { root, ops } => {
let mut s = self.visit(root);
let target = self.canonical_root(root);
for op in ops {
s.merge(self.visit(&op.val));
if let Some(c) = &op.cond {
s.merge(self.visit(c));
}
for step in &op.path {
if let PathStep::DynIndex(e) = step {
s.merge(self.visit(e));
}
if let PathStep::WildcardFilter(e) = step {
s.merge(self.visit(e));
}
}
s.writes.push(WriteEffect {
root: target.clone(),
op: op.clone(),
});
s.has_writes = true;
}
s
}
Expr::UpdateBatch {
root,
selector,
ops,
} => {
let mut s = self.visit(root);
let target = self.canonical_root(root);
for op in ops {
s.merge(self.visit(&op.val));
if let Some(c) = &op.cond {
s.merge(self.visit(c));
}
for step in &op.path {
if let PathStep::DynIndex(e) = step {
s.merge(self.visit(e));
}
if let PathStep::WildcardFilter(e) = step {
s.merge(self.visit(e));
}
}
}
for step in selector {
if let PathStep::DynIndex(e) = step {
s.merge(self.visit(e));
}
if let PathStep::WildcardFilter(e) = step {
s.merge(self.visit(e));
}
}
if let Some(effects) = update_batch_as_write_effects(target, selector, ops) {
s.writes.extend(effects);
}
s.has_writes = true;
s
}
}
}
fn visit_step(&mut self, step: &Step) -> EffectSummary {
match step {
Step::Field(_)
| Step::OptField(_)
| Step::Descendant(_)
| Step::DescendAll
| Step::Index(_)
| Step::Slice(_, _, _)
| Step::Wildcard
| Step::Quantifier(_) => EffectSummary::default(),
Step::DynIndex(e) | Step::InlineFilter(e) => self.visit(e),
Step::Method(_, args) | Step::OptMethod(_, args) => {
let mut s = EffectSummary::default();
for a in args {
s.merge(self.visit_arg(a));
}
s
}
Step::DeepMatch { arms, .. } => {
let mut s = EffectSummary::default();
for arm in arms {
if let Some(g) = arm.guard.as_ref() {
s.merge(self.visit(g));
}
s.merge(self.visit(&arm.body));
}
s
}
}
}
fn visit_arg(&mut self, arg: &Arg) -> EffectSummary {
match arg {
Arg::Pos(e) | Arg::Named(_, e) => self.visit(e),
}
}
fn visit_obj_field(&mut self, f: &ObjField) -> EffectSummary {
match f {
ObjField::Kv { val, cond, .. } => {
let mut s = self.visit(val);
if let Some(c) = cond {
s.merge(self.visit(c));
}
s
}
ObjField::Short(name) => {
let mut s = EffectSummary::default();
let r = self
.resolve(name)
.unwrap_or_else(|| RootRef::Local(Arc::from(name.as_str())));
s.reads.insert(r);
s
}
ObjField::Dynamic { key, val } => {
let mut s = self.visit(key);
s.merge(self.visit(val));
s
}
ObjField::Spread(e) | ObjField::SpreadDeep(e) => self.visit(e),
}
}
}
fn update_batch_as_write_effects(
root: RootRef,
selector: &[PathStep],
ops: &[PatchOp],
) -> Option<Vec<WriteEffect>> {
let mut effects = Vec::with_capacity(ops.len());
for op in ops {
if path_reads_update_focus(selector)
|| path_reads_update_focus(&op.path)
|| expr_reads_update_focus(&op.val)
|| op.cond.as_ref().is_some_and(expr_reads_update_focus)
{
return None;
}
let mut path = selector.to_vec();
path.extend(op.path.iter().cloned());
effects.push(WriteEffect {
root: root.clone(),
op: PatchOp {
path,
val: op.val.clone(),
cond: op.cond.clone(),
},
});
}
Some(effects)
}
fn path_reads_update_focus(path: &[PathStep]) -> bool {
path.iter().any(|step| match step {
PathStep::DynIndex(expr) => expr_reads_update_focus(expr),
PathStep::WildcardFilter(expr) => expr_reads_update_focus(expr),
PathStep::Field(_) | PathStep::Index(_) | PathStep::Wildcard | PathStep::Descendant(_) => {
false
}
})
}
fn expr_reads_update_focus(expr: &Expr) -> bool {
match expr {
Expr::Ident(name) => name == crate::plan::update::UPDATE_FOCUS_BINDING,
Expr::Chain(base, steps) => {
expr_reads_update_focus(base)
|| steps.iter().any(|step| match step {
Step::DynIndex(expr) | Step::InlineFilter(expr) => expr_reads_update_focus(expr),
Step::Method(_, args) | Step::OptMethod(_, args) => {
args.iter().any(arg_reads_update_focus)
}
Step::DeepMatch { arms, .. } => arms.iter().any(match_arm_reads_update_focus),
Step::Field(_)
| Step::OptField(_)
| Step::Descendant(_)
| Step::DescendAll
| Step::Index(_)
| Step::Slice(_, _, _)
| Step::Wildcard
| Step::Quantifier(_) => false,
})
}
Expr::BinOp(lhs, _, rhs) | Expr::Coalesce(lhs, rhs) => {
expr_reads_update_focus(lhs) || expr_reads_update_focus(rhs)
}
Expr::UnaryNeg(inner)
| Expr::Not(inner)
| Expr::Kind { expr: inner, .. }
| Expr::Cast { expr: inner, .. } => expr_reads_update_focus(inner),
Expr::Object(fields) => fields.iter().any(|field| match field {
ObjField::Kv { val, cond, .. } => {
expr_reads_update_focus(val)
|| cond.as_ref().is_some_and(|expr| expr_reads_update_focus(expr))
}
ObjField::Dynamic { key, val } => {
expr_reads_update_focus(key) || expr_reads_update_focus(val)
}
ObjField::Spread(expr) | ObjField::SpreadDeep(expr) => expr_reads_update_focus(expr),
ObjField::Short(_) => false,
}),
Expr::Array(items) => items.iter().any(|item| match item {
ArrayElem::Expr(expr) | ArrayElem::Spread(expr) => expr_reads_update_focus(expr),
}),
Expr::Pipeline { base, steps } => {
expr_reads_update_focus(base)
|| steps.iter().any(|step| match step {
PipeStep::Forward(expr) => expr_reads_update_focus(expr),
PipeStep::Bind(_) => false,
})
}
Expr::ListComp {
expr, iter, cond, ..
}
| Expr::SetComp {
expr, iter, cond, ..
}
| Expr::GenComp {
expr, iter, cond, ..
} => {
expr_reads_update_focus(expr)
|| expr_reads_update_focus(iter)
|| cond.as_ref().is_some_and(|expr| expr_reads_update_focus(expr))
}
Expr::DictComp {
key,
val,
iter,
cond,
..
} => {
expr_reads_update_focus(key)
|| expr_reads_update_focus(val)
|| expr_reads_update_focus(iter)
|| cond.as_ref().is_some_and(|expr| expr_reads_update_focus(expr))
}
Expr::Lambda { body, .. } => expr_reads_update_focus(body),
Expr::Let { init, body, .. } => {
expr_reads_update_focus(init) || expr_reads_update_focus(body)
}
Expr::IfElse { cond, then_, else_ } => {
expr_reads_update_focus(cond)
|| expr_reads_update_focus(then_)
|| expr_reads_update_focus(else_)
}
Expr::Try { body, default } => {
expr_reads_update_focus(body) || expr_reads_update_focus(default)
}
Expr::GlobalCall { args, .. } => args.iter().any(arg_reads_update_focus),
Expr::Patch { root, ops } => {
expr_reads_update_focus(root)
|| ops.iter().any(|op| {
path_reads_update_focus(&op.path)
|| expr_reads_update_focus(&op.val)
|| op.cond.as_ref().is_some_and(expr_reads_update_focus)
})
}
Expr::UpdateBatch {
root,
selector,
ops,
} => {
expr_reads_update_focus(root)
|| path_reads_update_focus(selector)
|| ops.iter().any(|op| {
path_reads_update_focus(&op.path)
|| expr_reads_update_focus(&op.val)
|| op.cond.as_ref().is_some_and(expr_reads_update_focus)
})
}
Expr::FString(parts) => parts.iter().any(|part| match part {
FStringPart::Interp { expr, .. } => expr_reads_update_focus(expr),
FStringPart::Lit(_) => false,
}),
Expr::Match { scrutinee, arms } => {
expr_reads_update_focus(scrutinee) || arms.iter().any(match_arm_reads_update_focus)
}
Expr::Null
| Expr::Bool(_)
| Expr::Int(_)
| Expr::Float(_)
| Expr::Str(_)
| Expr::Root
| Expr::Current
| Expr::DeleteMark => false,
}
}
fn arg_reads_update_focus(arg: &Arg) -> bool {
match arg {
Arg::Pos(expr) | Arg::Named(_, expr) => expr_reads_update_focus(expr),
}
}
fn match_arm_reads_update_focus(arm: &crate::parse::ast::MatchArm) -> bool {
arm.guard
.as_ref()
.is_some_and(|expr| expr_reads_update_focus(expr))
|| expr_reads_update_focus(&arm.body)
}
#[allow(dead_code)]
fn _bind_target_witness(_b: &BindTarget) {}
pub(crate) fn fuse_writes(expr: Expr) -> Expr {
let mut ctx = FuseCtx::default();
let body = fuse_recursive(expr, &mut ctx);
ctx.flush_all(body)
}
fn fuse_subtree(child: Expr, ctx: &mut FuseCtx) -> Expr {
let saved = ctx.take_pending();
let inner = fuse_recursive(child, ctx);
let inner = ctx.flush_all(inner);
ctx.restore_pending(saved);
inner
}
const MAX_ALIAS_CHAIN_DEPTH: usize = 16;
#[derive(Debug, Clone)]
struct PendingBatch {
root_expr: Expr,
binding: String,
ops: Vec<PatchOp>,
}
#[derive(Default)]
struct FuseCtx {
aliases: Vec<(Arc<str>, RootRef)>,
scope_stack: Vec<u32>,
next_scope: u32,
next_synth: u32,
pending: indexmap::IndexMap<RootRef, PendingBatch>,
}
impl FuseCtx {
fn current_scope(&self) -> u32 {
*self.scope_stack.last().unwrap_or(&0)
}
fn fresh_scope(&mut self) -> u32 {
let id = self.next_scope;
self.next_scope += 1;
id
}
fn fresh_synth_name(&mut self) -> String {
let id = self.next_synth;
self.next_synth += 1;
format!("__patch_fuse_{}", id)
}
fn resolve(&self, name: &str) -> Option<RootRef> {
self.aliases
.iter()
.rev()
.find(|(n, _)| n.as_ref() == name)
.map(|(_, r)| r.clone())
}
fn canonical_root(&self, expr: &Expr) -> RootRef {
match expr {
Expr::Root => RootRef::Root,
Expr::Current => RootRef::Current(self.current_scope()),
Expr::Ident(name) => self.canonical_local_chain(name.as_str()),
other => RootRef::Composite(Arc::new(other.clone())),
}
}
fn canonical_local_chain(&self, seed: &str) -> RootRef {
let mut current_name: Arc<str> = Arc::from(seed);
let mut visited: Vec<Arc<str>> = Vec::new();
for _ in 0..MAX_ALIAS_CHAIN_DEPTH {
if visited.iter().any(|v| v == ¤t_name) {
return RootRef::Composite(Arc::new(Expr::Ident(seed.to_string())));
}
visited.push(current_name.clone());
match self.resolve(¤t_name) {
None => return RootRef::Local(current_name),
Some(RootRef::Local(next)) => {
current_name = next;
continue;
}
Some(other) => return other,
}
}
RootRef::Composite(Arc::new(Expr::Ident(seed.to_string())))
}
fn analyzer(&self) -> EffectAnalyzer {
let mut a = EffectAnalyzer::new();
a.aliases = self.aliases.clone();
a.scope_stack = self.scope_stack.clone();
a.next_scope = self.next_scope;
a
}
fn add_to_batch(&mut self, root: RootRef, root_expr: Expr, ops: Vec<PatchOp>) -> String {
if let Some(batch) = self.pending.get_mut(&root) {
batch.ops.extend(ops);
batch.binding.clone()
} else {
let binding = self.fresh_synth_name();
let batch = PendingBatch {
root_expr,
binding: binding.clone(),
ops,
};
self.pending.insert(root, batch);
binding
}
}
fn flush_all(&mut self, body: Expr) -> Expr {
let mut wrapped = body;
let drained: Vec<(RootRef, PendingBatch)> = self.pending.drain(..).collect();
for (_root, batch) in drained.into_iter().rev() {
wrapped = Expr::Let {
name: batch.binding,
init: Box::new(Expr::Patch {
root: Box::new(batch.root_expr),
ops: batch.ops,
}),
body: Box::new(wrapped),
};
}
wrapped
}
fn flush_root(&mut self, root: &RootRef, body: Expr) -> Expr {
if let Some(batch) = self.pending.shift_remove(root) {
Expr::Let {
name: batch.binding,
init: Box::new(Expr::Patch {
root: Box::new(batch.root_expr),
ops: batch.ops,
}),
body: Box::new(body),
}
} else {
body
}
}
fn take_pending(&mut self) -> indexmap::IndexMap<RootRef, PendingBatch> {
std::mem::take(&mut self.pending)
}
fn restore_pending(&mut self, prev: indexmap::IndexMap<RootRef, PendingBatch>) {
self.pending = prev;
}
}
fn fuse_recursive(expr: Expr, ctx: &mut FuseCtx) -> Expr {
match expr {
Expr::Null
| Expr::Bool(_)
| Expr::Int(_)
| Expr::Float(_)
| Expr::Str(_)
| Expr::Root
| Expr::Current
| Expr::Ident(_)
| Expr::DeleteMark => expr,
Expr::Match { scrutinee, arms } => Expr::Match {
scrutinee: Box::new(fuse_recursive(*scrutinee, ctx)),
arms: arms
.into_iter()
.map(|a| crate::parse::ast::MatchArm {
pat: a.pat,
guard: a.guard.map(|g| fuse_recursive(g, ctx)),
body: fuse_recursive(a.body, ctx),
})
.collect(),
},
Expr::FString(parts) => Expr::FString(
parts
.into_iter()
.map(|p| match p {
crate::parse::ast::FStringPart::Lit(s) => {
crate::parse::ast::FStringPart::Lit(s)
}
crate::parse::ast::FStringPart::Interp { expr, fmt } => {
crate::parse::ast::FStringPart::Interp {
expr: fuse_recursive(expr, ctx),
fmt,
}
}
})
.collect(),
),
Expr::Chain(base, steps) => {
let base = Box::new(fuse_recursive(*base, ctx));
let steps = steps.into_iter().map(|s| fuse_step(s, ctx)).collect();
Expr::Chain(base, steps)
}
Expr::BinOp(l, op, r) => Expr::BinOp(
Box::new(fuse_recursive(*l, ctx)),
op,
Box::new(fuse_recursive(*r, ctx)),
),
Expr::UnaryNeg(inner) => Expr::UnaryNeg(Box::new(fuse_recursive(*inner, ctx))),
Expr::Not(inner) => Expr::Not(Box::new(fuse_recursive(*inner, ctx))),
Expr::Kind { expr, ty, negate } => Expr::Kind {
expr: Box::new(fuse_recursive(*expr, ctx)),
ty,
negate,
},
Expr::Coalesce(l, r) => Expr::Coalesce(
Box::new(fuse_recursive(*l, ctx)),
Box::new(fuse_recursive(*r, ctx)),
),
Expr::Object(fields) => fuse_object(fields, ctx),
Expr::Array(elems) => Expr::Array(
elems
.into_iter()
.map(|e| match e {
ArrayElem::Expr(x) => ArrayElem::Expr(fuse_recursive(x, ctx)),
ArrayElem::Spread(x) => ArrayElem::Spread(fuse_recursive(x, ctx)),
})
.collect(),
),
Expr::Pipeline { base, steps } => fuse_pipeline(*base, steps, ctx),
Expr::ListComp {
expr,
vars,
iter,
cond,
} => {
let iter = Box::new(fuse_subtree(*iter, ctx));
with_lambda_scope(ctx, &vars, |ctx| Expr::ListComp {
expr: Box::new(fuse_subtree(*expr, ctx)),
vars: vars.clone(),
iter,
cond: cond.map(|c| Box::new(fuse_subtree(*c, ctx))),
})
}
Expr::SetComp {
expr,
vars,
iter,
cond,
} => {
let iter = Box::new(fuse_subtree(*iter, ctx));
with_lambda_scope(ctx, &vars, |ctx| Expr::SetComp {
expr: Box::new(fuse_subtree(*expr, ctx)),
vars: vars.clone(),
iter,
cond: cond.map(|c| Box::new(fuse_subtree(*c, ctx))),
})
}
Expr::GenComp {
expr,
vars,
iter,
cond,
} => {
let iter = Box::new(fuse_subtree(*iter, ctx));
with_lambda_scope(ctx, &vars, |ctx| Expr::GenComp {
expr: Box::new(fuse_subtree(*expr, ctx)),
vars: vars.clone(),
iter,
cond: cond.map(|c| Box::new(fuse_subtree(*c, ctx))),
})
}
Expr::DictComp {
key,
val,
vars,
iter,
cond,
} => {
let iter = Box::new(fuse_subtree(*iter, ctx));
with_lambda_scope(ctx, &vars, |ctx| Expr::DictComp {
key: Box::new(fuse_subtree(*key, ctx)),
val: Box::new(fuse_subtree(*val, ctx)),
vars: vars.clone(),
iter,
cond: cond.map(|c| Box::new(fuse_subtree(*c, ctx))),
})
}
Expr::Lambda { params, body } => with_lambda_scope(ctx, ¶ms, |ctx| Expr::Lambda {
params: params.clone(),
body: Box::new(fuse_subtree(*body, ctx)),
}),
Expr::Let { name, init, body } => fuse_let(name, *init, *body, ctx),
Expr::IfElse { cond, then_, else_ } => Expr::IfElse {
cond: Box::new(fuse_subtree(*cond, ctx)),
then_: Box::new(fuse_subtree(*then_, ctx)),
else_: Box::new(fuse_subtree(*else_, ctx)),
},
Expr::Try { body, default } => Expr::Try {
body: Box::new(fuse_subtree(*body, ctx)),
default: Box::new(fuse_subtree(*default, ctx)),
},
Expr::GlobalCall { name, args } => Expr::GlobalCall {
name,
args: args
.into_iter()
.map(|a| match a {
Arg::Pos(e) => Arg::Pos(fuse_recursive(e, ctx)),
Arg::Named(n, e) => Arg::Named(n, fuse_recursive(e, ctx)),
})
.collect(),
},
Expr::Cast { expr, ty } => Expr::Cast {
expr: Box::new(fuse_recursive(*expr, ctx)),
ty,
},
Expr::Patch { root, ops } => {
let root = Box::new(fuse_recursive(*root, ctx));
let ops = ops.into_iter().map(|op| fuse_patch_op(op, ctx)).collect();
Expr::Patch { root, ops }
}
Expr::UpdateBatch {
root,
selector,
ops,
} => {
let root = Box::new(fuse_recursive(*root, ctx));
let selector = selector
.into_iter()
.map(|step| fuse_path_step(step, ctx))
.collect();
let ops = ops.into_iter().map(|op| fuse_patch_op(op, ctx)).collect();
let update = Expr::UpdateBatch {
root,
selector,
ops,
};
match update_batch_to_patch(update.clone()) {
Some(patch) => patch,
None => update,
}
}
}
}
fn update_batch_to_patch(update: Expr) -> Option<Expr> {
let Expr::UpdateBatch {
root,
selector,
ops,
} = update
else {
return None;
};
let effects = update_batch_as_write_effects(RootRef::Root, &selector, &ops)?;
let ops = effects.into_iter().map(|effect| effect.op).collect();
Some(Expr::Patch { root, ops })
}
fn fuse_step(step: Step, ctx: &mut FuseCtx) -> Step {
match step {
Step::DynIndex(e) => Step::DynIndex(Box::new(fuse_recursive(*e, ctx))),
Step::InlineFilter(e) => Step::InlineFilter(Box::new(fuse_recursive(*e, ctx))),
Step::Method(n, args) => Step::Method(
n,
args.into_iter()
.map(|a| match a {
Arg::Pos(e) => Arg::Pos(fuse_recursive(e, ctx)),
Arg::Named(n, e) => Arg::Named(n, fuse_recursive(e, ctx)),
})
.collect(),
),
Step::OptMethod(n, args) => Step::OptMethod(
n,
args.into_iter()
.map(|a| match a {
Arg::Pos(e) => Arg::Pos(fuse_recursive(e, ctx)),
Arg::Named(n, e) => Arg::Named(n, fuse_recursive(e, ctx)),
})
.collect(),
),
other => other,
}
}
fn fuse_patch_op(op: PatchOp, ctx: &mut FuseCtx) -> PatchOp {
let PatchOp { path, val, cond } = op;
let path = path.into_iter().map(|s| fuse_path_step(s, ctx)).collect();
PatchOp {
path,
val: fuse_recursive(val, ctx),
cond: cond.map(|c| fuse_recursive(c, ctx)),
}
}
fn fuse_path_step(step: PathStep, ctx: &mut FuseCtx) -> PathStep {
match step {
PathStep::DynIndex(e) => PathStep::DynIndex(fuse_recursive(e, ctx)),
PathStep::WildcardFilter(e) => PathStep::WildcardFilter(Box::new(fuse_recursive(*e, ctx))),
other => other,
}
}
fn with_lambda_scope<R>(
ctx: &mut FuseCtx,
params: &[String],
f: impl FnOnce(&mut FuseCtx) -> R,
) -> R {
let scope = ctx.fresh_scope();
ctx.scope_stack.push(scope);
for p in params {
ctx.aliases
.push((Arc::from(p.as_str()), RootRef::Current(scope)));
}
let out = f(ctx);
for _ in params {
ctx.aliases.pop();
}
ctx.scope_stack.pop();
out
}
fn lift_chain_write_pipe_stage(stage: Expr) -> Result<Expr, Expr> {
let (base, steps) = match stage {
Expr::Chain(b, s) => (*b, s),
other => return Err(other),
};
let last = match steps.last() {
Some(s) => s,
None => return Err(Expr::Chain(Box::new(base), steps)),
};
let (name, args) = match last {
Step::Method(n, a) => (n.clone(), a.clone()),
_ => return Err(Expr::Chain(Box::new(base), steps)),
};
if !is_write_terminal(&name) {
return Err(Expr::Chain(Box::new(base), steps));
}
let allow = matches!(&base, Expr::Current | Expr::Ident(_) | Expr::Root);
if !allow {
return Err(Expr::Chain(Box::new(base), steps));
}
let prefix: Vec<Step> = steps[..steps.len() - 1].to_vec();
let path = match steps_to_path(&prefix) {
Some(p) => p,
None => return Err(Expr::Chain(Box::new(base), steps)),
};
let op = match build_write_patch_op(&name, &args, path) {
Some(o) => o,
None => return Err(Expr::Chain(Box::new(base), steps)),
};
Ok(Expr::Patch {
root: Box::new(base),
ops: vec![op],
})
}
fn is_write_terminal(name: &str) -> bool {
matches!(
name,
"set" | "modify" | "delete" | "unset" | "merge" | "deep_merge" | "deepMerge"
)
}
fn steps_to_path(steps: &[Step]) -> Option<Vec<PathStep>> {
let mut out = Vec::with_capacity(steps.len());
for s in steps {
match s {
Step::Field(f) | Step::OptField(f) => out.push(PathStep::Field(f.clone())),
Step::Index(i) => out.push(PathStep::Index(*i)),
Step::Descendant(f) => out.push(PathStep::Descendant(f.clone())),
Step::DynIndex(e) => out.push(PathStep::DynIndex((**e).clone())),
_ => return None,
}
}
Some(out)
}
fn build_write_patch_op(name: &str, args: &[Arg], path: Vec<PathStep>) -> Option<PatchOp> {
match name {
"set" => {
let v = arg_expr_owned(args.first()?);
Some(PatchOp {
path,
val: v,
cond: None,
})
}
"modify" => {
let v = match arg_expr_owned(args.first()?) {
Expr::Lambda { params, body } => {
if let Some(p) = params.into_iter().next() {
Expr::Let {
name: p,
init: Box::new(Expr::Current),
body,
}
} else {
*body
}
}
other => other,
};
Some(PatchOp {
path,
val: v,
cond: None,
})
}
"delete" => {
if !args.is_empty() {
return None;
}
Some(PatchOp {
path,
val: Expr::DeleteMark,
cond: None,
})
}
"unset" => {
let key = match arg_expr_owned(args.first()?) {
Expr::Str(s) => s,
Expr::Ident(s) => s,
_ => return None,
};
let mut p = path;
p.push(PathStep::Field(key));
Some(PatchOp {
path: p,
val: Expr::DeleteMark,
cond: None,
})
}
_ => None,
}
}
fn arg_expr_owned(a: &Arg) -> Expr {
match a {
Arg::Pos(e) | Arg::Named(_, e) => e.clone(),
}
}
fn fuse_pipeline(base: Expr, steps: Vec<PipeStep>, ctx: &mut FuseCtx) -> Expr {
let recursed_base: Expr = fuse_recursive(base, ctx);
let original_base_for_revert: Option<Expr>;
let mut acc: Expr = match lift_chain_write_pipe_stage(recursed_base) {
Ok(lifted) => {
original_base_for_revert = Some(unlift_chain_write_pipe_stage(&lifted));
lifted
}
Err(other) => {
original_base_for_revert = None;
other
}
};
let mut fusion_fired = false;
let mut new_steps: Vec<PipeStep> = Vec::with_capacity(steps.len());
for st in steps {
match st {
PipeStep::Bind(t) => {
new_steps.push(PipeStep::Bind(t));
}
PipeStep::Forward(stage_expr) => {
let stage_expr = fuse_recursive(stage_expr, ctx);
let stage_expr = match lift_chain_write_pipe_stage(stage_expr) {
Ok(p) => p,
Err(other) => other,
};
if let Some(merged) = try_merge_pipeline_stage(&acc, &new_steps, &stage_expr, ctx) {
acc = merged;
fusion_fired = true;
} else {
new_steps.push(PipeStep::Forward(stage_expr));
}
}
}
}
if !fusion_fired {
if let Some(orig) = original_base_for_revert {
acc = orig;
}
}
if new_steps.is_empty() {
acc
} else {
Expr::Pipeline {
base: Box::new(acc),
steps: new_steps,
}
}
}
fn unlift_chain_write_pipe_stage(patch: &Expr) -> Expr {
let (root, ops) = match patch {
Expr::Patch { root, ops } => (root.as_ref(), ops),
other => return other.clone(),
};
if ops.len() != 1 {
return patch.clone();
}
let op = &ops[0];
if op.cond.is_some() {
return patch.clone();
}
let mut steps: Vec<Step> = Vec::with_capacity(op.path.len() + 1);
for ps in &op.path {
match ps {
PathStep::Field(f) => steps.push(Step::Field(f.clone())),
PathStep::Index(i) => steps.push(Step::Index(*i)),
PathStep::Descendant(f) => steps.push(Step::Descendant(f.clone())),
PathStep::DynIndex(e) => steps.push(Step::DynIndex(Box::new(e.clone()))),
_ => return patch.clone(),
}
}
let (method_name, method_args): (String, Vec<Arg>) = match &op.val {
Expr::DeleteMark => {
return patch.clone();
}
Expr::Let { init, .. } if matches!(init.as_ref(), Expr::Current) => {
return patch.clone();
}
v => ("set".to_string(), vec![Arg::Pos(v.clone())]),
};
steps.push(Step::Method(method_name, method_args));
Expr::Chain(Box::new(root.clone()), steps)
}
fn try_merge_pipeline_stage(
acc: &Expr,
pending_steps: &[PipeStep],
stage: &Expr,
ctx: &FuseCtx,
) -> Option<Expr> {
if !pending_steps.is_empty() {
return None;
}
let (acc_root, acc_ops) = match acc {
Expr::Patch { root, ops } => (root.as_ref(), ops),
_ => return None,
};
let (stage_root, stage_ops) = match stage {
Expr::Patch { root, ops } => (root.as_ref(), ops),
_ => return None,
};
if acc_ops.iter().any(|o| o.cond.is_some()) || stage_ops.iter().any(|o| o.cond.is_some()) {
return None;
}
let acc_root_ref = ctx.canonical_root(acc_root);
if matches!(acc_root_ref, RootRef::Composite(_)) {
return None;
}
let stage_root_ref = ctx.canonical_root(stage_root);
let same_root =
stage_root_ref == acc_root_ref || stage_root_ref == RootRef::Current(ctx.current_scope());
if !same_root {
return None;
}
let mut a = ctx.analyzer();
for op in stage_ops {
let val_summary = a.analyze(&op.val);
if val_summary.reads.contains(&acc_root_ref) {
return None;
}
if let Some(c) = &op.cond {
if a.analyze(c).reads.contains(&acc_root_ref) {
return None;
}
}
for s in &op.path {
if let PathStep::DynIndex(e) = s {
if a.analyze(e).reads.contains(&acc_root_ref) {
return None;
}
}
if let PathStep::WildcardFilter(e) = s {
if a.analyze(e).reads.contains(&acc_root_ref) {
return None;
}
}
}
}
let mut merged_ops = acc_ops.clone();
merged_ops.extend(stage_ops.iter().cloned());
Some(Expr::Patch {
root: Box::new(acc_root.clone()),
ops: merged_ops,
})
}
fn fuse_object(fields: Vec<ObjField>, ctx: &mut FuseCtx) -> Expr {
let recursed: Vec<ObjField> = fields
.into_iter()
.map(|f| match f {
ObjField::Kv {
key,
val,
optional,
cond,
} => ObjField::Kv {
key,
val: fuse_recursive(val, ctx),
optional,
cond: cond.map(|c| fuse_recursive(c, ctx)),
},
ObjField::Short(n) => ObjField::Short(n),
ObjField::Dynamic { key, val } => ObjField::Dynamic {
key: fuse_recursive(key, ctx),
val: fuse_recursive(val, ctx),
},
ObjField::Spread(e) => ObjField::Spread(fuse_recursive(e, ctx)),
ObjField::SpreadDeep(e) => ObjField::SpreadDeep(fuse_recursive(e, ctx)),
})
.collect();
let mut patch_indices: Vec<usize> = Vec::new();
let mut shared_root: Option<RootRef> = None;
let mut shared_root_expr: Option<Expr> = None;
for (i, f) in recursed.iter().enumerate() {
if let ObjField::Kv {
val: Expr::Patch { root, ops },
cond: None,
..
} = f
{
if ops.iter().any(|o| o.cond.is_some()) {
continue;
}
let r = ctx.canonical_root(root);
if matches!(r, RootRef::Composite(_)) {
continue;
}
match &shared_root {
None => {
shared_root = Some(r.clone());
shared_root_expr = Some((**root).clone());
patch_indices.push(i);
}
Some(existing) if existing == &r => {
patch_indices.push(i);
}
_ => {
}
}
}
}
if patch_indices.len() < 2 {
return Expr::Object(recursed);
}
let shared_root = shared_root.unwrap();
let shared_root_expr = shared_root_expr.unwrap();
for (i, f) in recursed.iter().enumerate() {
if patch_indices.contains(&i) {
continue;
}
let summary = ctx.analyzer().analyze_obj_field(f);
if summary.reads.contains(&shared_root) {
return Expr::Object(recursed);
}
if summary.writes.iter().any(|w| w.root == shared_root) {
return Expr::Object(recursed);
}
}
let mut merged_ops: Vec<PatchOp> = Vec::new();
for &i in &patch_indices {
if let ObjField::Kv {
val: Expr::Patch { ops, .. },
..
} = &recursed[i]
{
merged_ops.extend(ops.iter().cloned());
}
}
let synth_name = ctx.fresh_synth_name();
let synth_arc: Arc<str> = Arc::from(synth_name.as_str());
let new_fields: Vec<ObjField> = recursed
.into_iter()
.enumerate()
.map(|(i, f)| {
if patch_indices.contains(&i) {
if let ObjField::Kv {
key,
optional,
cond,
..
} = f
{
ObjField::Kv {
key,
val: Expr::Ident(synth_name.clone()),
optional,
cond,
}
} else {
f
}
} else {
f
}
})
.collect();
let object_expr = Expr::Object(new_fields);
ctx.aliases.push((synth_arc, shared_root.clone()));
let body = object_expr;
ctx.aliases.pop();
Expr::Let {
name: synth_name,
init: Box::new(Expr::Patch {
root: Box::new(shared_root_expr),
ops: merged_ops,
}),
body: Box::new(body),
}
}
fn try_lift_chain_write(expr: &Expr) -> Option<Expr> {
let (base, steps) = match expr {
Expr::Chain(b, s) => (b.as_ref(), s.as_slice()),
_ => return None,
};
let last = steps.last()?;
let (name, args) = match last {
Step::Method(n, a) => (n.clone(), a.clone()),
_ => return None,
};
if !is_write_terminal(&name) {
return None;
}
if !matches!(base, Expr::Current | Expr::Ident(_) | Expr::Root) {
return None;
}
let prefix: Vec<Step> = steps[..steps.len() - 1].to_vec();
let path = steps_to_path(&prefix)?;
let op = build_write_patch_op(&name, &args, path)?;
Some(Expr::Patch {
root: Box::new(base.clone()),
ops: vec![op],
})
}
fn fuse_let(name: String, init: Expr, body: Expr, ctx: &mut FuseCtx) -> Expr {
let init = fuse_recursive(init, ctx);
let alias = ctx.canonical_root(&init_target(&init));
ctx.aliases.push((Arc::from(name.as_str()), alias.clone()));
let body = fuse_recursive(body, ctx);
let merged = try_let_init_body_fusion(&name, &alias, &init, &body, ctx);
ctx.aliases.pop();
match merged {
Some(new_let) => new_let,
None => Expr::Let {
name,
init: Box::new(init),
body: Box::new(body),
},
}
}
fn init_target(init: &Expr) -> Expr {
match init {
Expr::Patch { root, .. } => (**root).clone(),
other => other.clone(),
}
}
fn try_let_init_body_fusion(
name: &str,
alias: &RootRef,
init: &Expr,
body: &Expr,
ctx: &FuseCtx,
) -> Option<Expr> {
let (init_root, init_ops) = match init {
Expr::Patch { root, ops } => (root, ops),
_ => return None,
};
if init_ops.iter().any(|o| o.cond.is_some()) {
return None;
}
let init_root_ref = ctx.canonical_root(init_root);
if matches!(init_root_ref, RootRef::Composite(_)) {
return None;
}
let body_patch_owned;
let body_patch: &Expr = if matches!(body, Expr::Patch { .. }) {
body
} else if let Some(lifted) = try_lift_chain_write(body) {
body_patch_owned = lifted;
&body_patch_owned
} else {
return None;
};
let (body_root, body_ops) = match body_patch {
Expr::Patch { root, ops } => (root, ops),
_ => return None,
};
if body_ops.iter().any(|o| o.cond.is_some()) {
return None;
}
let body_root_ref = canonical_root_with_alias(body_root, name, alias, ctx);
if body_root_ref != init_root_ref {
return None;
}
let mut merged = init_ops.clone();
merged.extend(body_ops.iter().cloned());
Some(Expr::Let {
name: name.to_string(),
init: Box::new(Expr::Patch {
root: init_root.clone(),
ops: merged,
}),
body: Box::new(Expr::Ident(name.to_string())),
})
}
fn canonical_root_with_alias(expr: &Expr, name: &str, alias: &RootRef, ctx: &FuseCtx) -> RootRef {
match expr {
Expr::Ident(n) if n == name => alias.clone(),
Expr::Root => RootRef::Root,
Expr::Current => RootRef::Current(ctx.current_scope()),
other => ctx.canonical_root(other),
}
}
impl EffectAnalyzer {
pub(crate) fn analyze_obj_field(&mut self, f: &ObjField) -> EffectSummary {
self.visit_obj_field(f)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::parser::parse;
fn analyze_str(s: &str) -> EffectSummary {
let expr = parse(s).expect("parse");
EffectAnalyzer::new().analyze(&expr)
}
#[test]
fn pure_read_has_no_writes() {
let s = analyze_str("$.a.b");
assert!(s.writes.is_empty());
assert!(!s.has_writes);
assert!(s.reads.contains(&RootRef::Root));
}
#[test]
fn literal_has_empty_summary() {
let s = analyze_str("42");
assert!(s.writes.is_empty());
assert!(s.reads.is_empty());
}
#[test]
fn single_set_records_write_against_root() {
let s = analyze_str("$.a.set(1)");
assert_eq!(s.writes.len(), 1);
assert_eq!(s.writes[0].root, RootRef::Root);
assert!(s.has_writes);
}
#[test]
fn pipe_chain_collects_writes_in_order() {
let s = analyze_str("$.a.set(1) | $.b.set(2) | $.c.set(3)");
assert_eq!(s.writes.len(), 3);
for w in &s.writes {
assert_eq!(w.root, RootRef::Root);
}
assert!(s.has_writes);
}
#[test]
fn let_aliases_root_for_reads() {
let s = analyze_str("let x = $ in (x.a | $.b.set(2))");
assert_eq!(s.writes.len(), 1);
assert_eq!(s.writes[0].root, RootRef::Root);
assert!(s.reads.contains(&RootRef::Root));
}
#[test]
fn object_field_writes_collected() {
let s = analyze_str("{a: $.x.set(1), b: $.y.set(2)}");
assert_eq!(s.writes.len(), 2);
assert_eq!(s.writes[0].root, RootRef::Root);
assert_eq!(s.writes[1].root, RootRef::Root);
}
#[test]
fn lambda_scope_isolation() {
let s = analyze_str("$.list.map(lambda o: o.id.set(1))");
let outer_root_writes = s.writes.iter().filter(|w| w.root == RootRef::Root).count();
assert_eq!(outer_root_writes, 0);
}
#[test]
fn read_then_write_collects_both() {
let s = analyze_str("$.a + $.b.set(1)");
assert!(s.reads.contains(&RootRef::Root));
assert_eq!(s.writes.len(), 1);
assert_eq!(s.writes[0].root, RootRef::Root);
}
#[test]
fn nested_let_alias_chain_canonicalizes_reads() {
let s = analyze_str("let x = $ in (let y = x in y.a)");
assert!(s.reads.contains(&RootRef::Root));
for r in &s.reads {
match r {
RootRef::Local(name) => panic!("unexpected Local read: {}", name),
_ => {}
}
}
}
#[test]
fn comprehension_does_not_leak_inner_writes_to_root() {
let s = analyze_str("[o.id.set(1) for o in $.list]");
assert!(s.reads.contains(&RootRef::Root));
let outer_root_writes = s.writes.iter().filter(|w| w.root == RootRef::Root).count();
assert_eq!(outer_root_writes, 0);
}
#[test]
fn ifelse_merges_branch_writes() {
let s = analyze_str("$.a.set(1) if $.flag else $.b.set(2)");
assert_eq!(s.writes.len(), 2);
}
#[test]
fn alias_chain_cycle_bails_to_composite() {
let mut ctx = FuseCtx::default();
ctx.aliases
.push((Arc::from("a"), RootRef::Local(Arc::from("b"))));
ctx.aliases
.push((Arc::from("b"), RootRef::Local(Arc::from("a"))));
let r = ctx.canonical_root(&Expr::Ident("a".to_string()));
assert!(
matches!(r, RootRef::Composite(_)),
"cycle should fall back to Composite, got {:?}",
r
);
}
#[test]
fn alias_chain_depth_cap_bails_to_composite() {
let mut ctx = FuseCtx::default();
let len = MAX_ALIAS_CHAIN_DEPTH + 2;
for i in 0..len {
let name: Arc<str> = Arc::from(format!("n{}", i));
let next: Arc<str> = Arc::from(format!("n{}", i + 1));
ctx.aliases.push((name, RootRef::Local(next)));
}
let r = ctx.canonical_root(&Expr::Ident("n0".to_string()));
assert!(
matches!(r, RootRef::Composite(_)),
"over-deep chain should bail to Composite, got {:?}",
r
);
}
#[test]
fn alias_chain_within_cap_resolves_to_root() {
let mut ctx = FuseCtx::default();
let len = MAX_ALIAS_CHAIN_DEPTH - 1;
for i in 0..len {
let name: Arc<str> = Arc::from(format!("n{}", i));
let next: Arc<str> = Arc::from(format!("n{}", i + 1));
ctx.aliases.push((name, RootRef::Local(next)));
}
ctx.aliases
.push((Arc::from(format!("n{}", len)), RootRef::Root));
let r = ctx.canonical_root(&Expr::Ident("n0".to_string()));
assert_eq!(r, RootRef::Root);
}
#[test]
fn pending_batch_add_and_flush_emits_let_wrapper() {
let mut ctx = FuseCtx::default();
let op = PatchOp {
path: vec![PathStep::Field("k".into())],
val: Expr::Int(1),
cond: None,
};
let _binding = ctx.add_to_batch(RootRef::Root, Expr::Root, vec![op]);
let body = Expr::Bool(true);
let wrapped = ctx.flush_all(body);
match wrapped {
Expr::Let { name, init, body } => {
assert!(name.starts_with("__patch_fuse_"));
assert!(matches!(*init, Expr::Patch { .. }));
assert!(matches!(*body, Expr::Bool(true)));
}
other => panic!("expected Let wrapper, got {:?}", other),
}
assert!(ctx.pending.is_empty());
}
#[test]
fn flush_root_only_drains_named_root() {
let mut ctx = FuseCtx::default();
let op_a = PatchOp {
path: vec![PathStep::Field("a".into())],
val: Expr::Int(1),
cond: None,
};
let op_b = PatchOp {
path: vec![PathStep::Field("b".into())],
val: Expr::Int(2),
cond: None,
};
ctx.add_to_batch(RootRef::Root, Expr::Root, vec![op_a]);
ctx.add_to_batch(
RootRef::Local(Arc::from("x")),
Expr::Ident("x".into()),
vec![op_b],
);
let body = ctx.flush_root(&RootRef::Root, Expr::Bool(true));
assert!(matches!(body, Expr::Let { .. }));
assert_eq!(ctx.pending.len(), 1);
assert!(ctx.pending.contains_key(&RootRef::Local(Arc::from("x"))));
}
}