Skip to content

Commit af967da

Browse files
timfennisclaude
andcommitted
Fix VM slot collision in nested list/map comprehensions
The accumulator slot for an outer list comprehension was assigned post-analysis as `max_outer_loop_var_slot + 1`. Inner loop variables were allocated from the same scope offset and therefore received the same slot number (e.g. outer acc = 3, inner `_` = 3). At runtime the inner `SetLocal` silently overwrote the outer accumulator list, causing a panic "ListPush expects a list". Fix: reserve the accumulator slot in the scope tree *before* analysing the body expression via a new `ScopeTree::reserve_anonymous_slot()`. This bumps the scope's offset so any nested loop variables receive strictly higher slot numbers. The old post-hoc calculation in `Expression::For` is removed. Adds regression test bug0008. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent dad2098 commit af967da

3 files changed

Lines changed: 42 additions & 38 deletions

File tree

ndc_interpreter/src/semantic/analyser.rs

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ use crate::semantic::ScopeTree;
33
use itertools::Itertools;
44
use ndc_lexer::Span;
55
use ndc_parser::{
6-
Binding, Expression, ExpressionLocation, ForBody, ForIteration, Lvalue, ResolvedVar,
7-
TypeSignature,
6+
Binding, Expression, ExpressionLocation, ForBody, ForIteration, Lvalue, TypeSignature,
87
};
98
use std::fmt::Debug;
109

@@ -193,20 +192,6 @@ impl Analyser {
193192
}
194193
Expression::For { iterations, body } => {
195194
let return_type = self.resolve_for_iterations(iterations, body, *span)?;
196-
// Assign the VM accumulator slot for list comprehensions. We do this after
197-
// resolution so we can derive the slot from the resolved loop-variable slots
198-
// without touching the scope tree (the interpreter doesn't need this slot).
199-
let max_loop_slot = iterations.iter().filter_map(iteration_local_slot).max();
200-
let acc_slot = Some(max_loop_slot.map_or(0, |s| s + 1));
201-
match body.as_mut() {
202-
ForBody::List {
203-
accumulator_slot, ..
204-
} => *accumulator_slot = acc_slot,
205-
ForBody::Map {
206-
accumulator_slot, ..
207-
} => *accumulator_slot = acc_slot,
208-
ForBody::Block(_) => {}
209-
}
210195
Ok(return_type)
211196
}
212197
Expression::Call {
@@ -379,13 +364,25 @@ impl Analyser {
379364
self.analyse(block)?;
380365
StaticType::unit()
381366
}
382-
ForBody::List { expr, .. } => StaticType::List(Box::new(self.analyse(expr)?)),
367+
ForBody::List {
368+
expr,
369+
accumulator_slot,
370+
..
371+
} => {
372+
// Reserve the accumulator slot BEFORE analysing the body so
373+
// that nested for-comprehensions receive strictly higher slot
374+
// numbers and cannot collide with this accumulator.
375+
*accumulator_slot = Some(self.scope_tree.reserve_anonymous_slot());
376+
StaticType::List(Box::new(self.analyse(expr)?))
377+
}
383378
ForBody::Map {
384379
key,
385380
value,
386381
default,
382+
accumulator_slot,
387383
..
388384
} => {
385+
*accumulator_slot = Some(self.scope_tree.reserve_anonymous_slot());
389386
let key_type = self.analyse(key)?;
390387
let value_type = if let Some(value) = value {
391388
self.analyse(value)?
@@ -580,27 +577,6 @@ impl Analyser {
580577
}
581578
}
582579

583-
/// Returns the highest local slot index used by the loop variable of `it`.
584-
/// Used to find the highest-numbered loop variable slot when assigning the comprehension
585-
/// accumulator slot (which must sit above all loop variable slots).
586-
fn iteration_local_slot(it: &ForIteration) -> Option<usize> {
587-
let ForIteration::Iteration { l_value, .. } = it else {
588-
return None;
589-
};
590-
max_lvalue_slot(l_value)
591-
}
592-
593-
fn max_lvalue_slot(lv: &Lvalue) -> Option<usize> {
594-
match lv {
595-
Lvalue::Identifier {
596-
resolved: Some(ResolvedVar::Local { slot }),
597-
..
598-
} => Some(*slot),
599-
Lvalue::Sequence(seq) => seq.iter().filter_map(max_lvalue_slot).max(),
600-
_ => None,
601-
}
602-
}
603-
604580
#[derive(thiserror::Error, Debug)]
605581
#[error("{text}")]
606582
pub struct AnalysisError {

ndc_interpreter/src/semantic/scope.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,14 @@ impl ScopeTree {
448448
}
449449
}
450450

451+
/// Reserve a slot in the current scope without creating a named binding.
452+
/// Used to allocate the list/map accumulator before analysing the body of a
453+
/// for-comprehension, so that any nested comprehensions receive strictly
454+
/// higher slot numbers and cannot collide with this accumulator.
455+
pub(crate) fn reserve_anonymous_slot(&mut self) -> usize {
456+
self.scopes[self.current_scope_idx].allocate("\x00".to_string(), StaticType::Any)
457+
}
458+
451459
pub(crate) fn update_binding_type(&mut self, var: ResolvedVar, new_type: StaticType) {
452460
match var {
453461
ResolvedVar::Local { slot } => {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// BUG#0008: The VM accumulator slot for an outer list comprehension was
2+
// computed post-analysis as max_outer_loop_var_slot + 1. Nested loop
3+
// variables were assigned slots from the same scope offset, so they
4+
// received the *same* number (e.g. outer acc = slot 3, inner _ = slot 3).
5+
// At runtime the inner SetLocal overwrote the outer list, causing a panic
6+
// "ListPush expects a list".
7+
8+
let height = 3;
9+
let width = 3;
10+
let grid = [[ 0 for _ in 0..width] for _ in 0..height];
11+
12+
assert_eq(grid, [[0,0,0],[0,0,0],[0,0,0]]);
13+
14+
// Also verify that a flat list comprehension with a guard (two iterations
15+
// plus a filter) isn't broken by the same class of slot collision.
16+
let found = [(y, x) for y in 0..height, x in 0..width, if y == x];
17+
assert_eq(found, [(0,0),(1,1),(2,2)]);
18+
19+
print("ok");
20+
// expect-output: ok

0 commit comments

Comments
 (0)