lang: multi-iterable for loops — drop ':', add '..=', open ranges, arrow bodies

The for header is now a comma-separated list of iterables with a
positional capture group and no ':' separator:

    for xs (x) { }                    // collection
    for 0..n (i) { }                  // range (end exclusive)
    for 1..=5 (a) { }                 // ..= inclusive end
    for xs, 0.. (x, i) { }            // index idiom (replaces (x, i))
    for xs, ys (x, y) { }             // parallel (zip) iteration
    for xs (x) => sum += x;           // arrow body (full statement)

First-iterable-wins: the first iterable's length drives the loop and
must be bounded; the other positions follow by their own cursors (a
non-first range's end is not consulted or evaluated; a shorter
non-first collection is read past its length on mismatch). The old
single-iterable index capture is replaced by the trailing open range.

Capture/call disambiguation is positional: the paren group immediately
before '{' or '=>' is the capture, every earlier top-level group is a
call. 'for zip(a, b) (x, y)' calls zip; 'for f(n) { }' reads (n) as
the capture and errors with a parenthesize/add-capture hint. The old
':' form errors with a migration hint.

Lowering is unified across forms: one cursor slot per position (ranges
start at their start, collections at 0), all advanced together, the
first position's bound terminating. inline for keeps the single
bounded comptime range.

Migrated the full corpus (examples, library modules, issue repros,
in-source test strings). New coverage: examples/0050 (the full feature
surface) and examples/1149-1155 (seven diagnostic faces). specs.md For
Loop section + grammar rewritten; readme teaser updated.
This commit is contained in:
agra
2026-06-10 20:30:55 +03:00
parent c640e88513
commit 116af2359e
75 changed files with 701 additions and 391 deletions

View File

@@ -61,8 +61,10 @@ pub const ErrorAnalysis = struct {
self.collectErrorSites(w.body, tags, edges);
},
.for_expr => |f| {
self.collectErrorSites(f.iterable, tags, edges);
if (f.range_end) |re| self.collectErrorSites(re, tags, edges);
for (f.iterables) |it| {
self.collectErrorSites(it.expr, tags, edges);
if (it.range_end) |re| self.collectErrorSites(re, tags, edges);
}
self.collectErrorSites(f.body, tags, edges);
},
.return_stmt => |r| if (r.value) |v| self.collectErrorSites(v, tags, edges),
@@ -216,8 +218,10 @@ pub const ErrorAnalysis = struct {
self.collectClosureShapes(w.body);
},
.for_expr => |f| {
self.collectClosureShapes(f.iterable);
if (f.range_end) |re| self.collectClosureShapes(re);
for (f.iterables) |it| {
self.collectClosureShapes(it.expr);
if (it.range_end) |re| self.collectClosureShapes(re);
}
self.collectClosureShapes(f.body);
},
.return_stmt => |r| if (r.value) |v| self.collectClosureShapes(v),

View File

@@ -152,8 +152,10 @@ pub const ErrorFlow = struct {
return false;
},
.for_expr => |fe| {
self.flowExpr(fe.iterable, ctx, proven.*);
if (fe.range_end) |re| self.flowExpr(re, ctx, proven.*);
for (fe.iterables) |it| {
self.flowExpr(it.expr, ctx, proven.*);
if (it.range_end) |re| self.flowExpr(re, ctx, proven.*);
}
var loop_proven = self.provenClone(proven.*);
_ = self.flowWalk(fe.body, ctx, &loop_proven);
return false;

View File

@@ -1368,7 +1368,6 @@ pub const Lowering = struct {
pub const lowerWhile = lower_control_flow.lowerWhile;
pub const listView = lower_control_flow.listView;
pub const lowerFor = lower_control_flow.lowerFor;
pub const lowerRuntimeRangeFor = lower_control_flow.lowerRuntimeRangeFor;
pub const lowerInlineRangeFor = lower_control_flow.lowerInlineRangeFor;
pub const lowerMatch = lower_control_flow.lowerMatch;
pub const lowerBreak = lower_control_flow.lowerBreak;

View File

@@ -639,9 +639,12 @@ pub fn collectCaptures(self: *Lowering, node: *const Node, param_names: *std.Str
self.collectCaptures(de.operand, param_names, captures);
},
.for_expr => |fe| {
self.collectCaptures(fe.iterable, param_names, captures);
// Register capture name as local so it's not captured
param_names.put(fe.capture_name, {}) catch {};
for (fe.iterables) |it| {
self.collectCaptures(it.expr, param_names, captures);
if (it.range_end) |re| self.collectCaptures(re, param_names, captures);
}
// Register capture names as locals so they're not captured
for (fe.captures) |cap| param_names.put(cap.name, {}) catch {};
self.collectCaptures(fe.body, param_names, captures);
},
.slice_expr => |se| {

View File

@@ -277,48 +277,110 @@ pub fn listView(self: *Lowering, value: Ref, ty: TypeId) ?struct { data: Ref, da
};
}
/// Lowered prep for one position of a multi-iterable `for` header. Every
/// position gets its own s64 cursor slot (ranges start at their `start`,
/// collections at 0); all cursors advance by 1 per iteration, and ONLY the
/// first position's bound terminates the loop (first-iterable-wins).
const IterPrep = struct {
is_range: bool,
slot: Ref,
// Collection-only fields:
data: Ref = Ref.none,
data_ty: TypeId = .unresolved,
elem_ty: TypeId = .unresolved,
is_array: bool = false,
storage: ?Ref = null, // array's own alloca when addressable (not deref'd)
};
/// `for it1, it2, ... (c1, c2, ...) { }` — parallel iteration. The first
/// iterable's length/bound drives the loop; the others follow by position.
/// Consequences of first-iterable-wins: a non-first range's end is never
/// lowered (its side effects do not run), and a shorter non-first collection
/// is read past its length on mismatch — the first iterable is the
/// authoritative one.
pub fn lowerFor(self: *Lowering, fe: *const ast.ForExpr) Ref {
if (fe.range_end) |end_node| {
if (fe.is_inline) return self.lowerInlineRangeFor(fe, end_node);
return self.lowerRuntimeRangeFor(fe, end_node);
}
// Collection-form `for xs : (x)` over a pack: a pack has no runtime
// value to iterate (Decision 1) — point the user at `inline for`.
if (fe.iterable.data == .identifier and self.isPackName(fe.iterable.data.identifier.name)) {
return self.diagPackAsValue(fe.iterable.data.identifier.name, fe.iterable.span, .runtime_iter);
if (fe.is_inline) return self.lowerInlineRangeFor(fe);
// A pack has no runtime value to iterate (Decision 1) — point the user
// at `inline for`.
for (fe.iterables) |it| {
if (!it.is_range and it.expr.data == .identifier and self.isPackName(it.expr.data.identifier.name)) {
return self.diagPackAsValue(it.expr.data.identifier.name, it.expr.span, .runtime_iter);
}
}
// Lower iterable + resolve its static type.
var iterable = self.lowerExpr(fe.iterable);
var iterable_ty = self.inferExprType(fe.iterable);
var preps = std.ArrayList(IterPrep).empty;
defer preps.deinit(self.alloc);
var limit: Ref = Ref.none; // exclusive bound of position 0
// `*List` / `*[]T` etc. — deref to the collection value. Tracked because
// a deref'd iterable's identifier binding holds the POINTER, so its
// alloca is not the collection's storage.
var was_deref = false;
const ptr_info = if (iterable_ty.isBuiltin()) null else self.module.types.get(iterable_ty);
if (ptr_info != null and ptr_info.? == .pointer) {
iterable = self.builder.load(iterable, ptr_info.?.pointer.pointee);
iterable_ty = ptr_info.?.pointer.pointee;
was_deref = true;
for (fe.iterables, 0..) |it, i| {
if (it.is_range) {
const start_ref = self.lowerExpr(it.expr);
const slot = self.builder.alloca(.s64);
self.builder.store(slot, start_ref);
if (i == 0) {
// Parser guarantees the first iterable is bounded.
var end_ref = self.lowerExpr(it.range_end.?);
if (it.inclusive) end_ref = self.builder.add(end_ref, self.builder.constInt(1, .s64), .s64);
limit = end_ref;
}
preps.append(self.alloc, .{ .is_range = true, .slot = slot }) catch unreachable;
} else {
var data = self.lowerExpr(it.expr);
var data_ty = self.inferExprType(it.expr);
// `*List` / `*[]T` etc. — deref to the collection value. Tracked
// because a deref'd iterable's identifier binding holds the
// POINTER, so its alloca is not the collection's storage.
var was_deref = false;
const ptr_info = if (data_ty.isBuiltin()) null else self.module.types.get(data_ty);
if (ptr_info != null and ptr_info.? == .pointer) {
data = self.builder.load(data, ptr_info.?.pointer.pointee);
data_ty = ptr_info.?.pointer.pointee;
was_deref = true;
}
// A `List(T)`-like struct iterates its `items[0..len]`;
// arrays/slices use their intrinsic length.
var len: Ref = Ref.none;
if (self.listView(data, data_ty)) |lv| {
data = lv.data;
data_ty = lv.data_ty;
len = lv.len;
} else if (i == 0) {
len = self.builder.emit(.{ .length = .{ .operand = data } }, .s64);
}
const elem_ty = self.getElementType(data_ty);
if (elem_ty == .unresolved) {
// Not a collection. The common trip: `for f(n) { }` — the
// trailing parens are the CAPTURE, so the iterable is `f`.
if (self.diagnostics) |d| {
if (data_ty == .unresolved) {
d.addFmt(.err, it.expr.span, "cannot iterate this expression — if the parens were call arguments, a call iterable also needs a capture (`for f(n) (x) {{ }}`) or parentheses (`for (f(n)) {{ }}`)", .{});
} else {
d.addFmt(.err, it.expr.span, "cannot iterate a value of type '{s}' — if the parens were call arguments, a call iterable also needs a capture (`for f(n) (x) {{ }}`) or parentheses (`for (f(n)) {{ }}`)", .{self.module.types.typeName(data_ty)});
}
}
return self.builder.constInt(0, .void);
}
const is_array = !data_ty.isBuiltin() and self.module.types.get(data_ty) == .array;
const storage = if (is_array and !was_deref) self.getExprAlloca(it.expr) else null;
const slot = self.builder.alloca(.s64);
self.builder.store(slot, self.builder.constInt(0, .s64));
if (i == 0) limit = len;
preps.append(self.alloc, .{
.is_range = false,
.slot = slot,
.data = data,
.data_ty = data_ty,
.elem_ty = elem_ty,
.is_array = is_array,
.storage = storage,
}) catch unreachable;
}
}
// A `List(T)`-like struct iterates its `items[0..len]`; arrays/slices
// use their intrinsic length.
var len: Ref = undefined;
if (self.listView(iterable, iterable_ty)) |lv| {
iterable = lv.data;
iterable_ty = lv.data_ty;
len = lv.len;
} else {
len = self.builder.emit(.{ .length = .{ .operand = iterable } }, .s64);
}
// Create index variable
const idx_slot = self.builder.alloca(.s64);
const zero = self.builder.constInt(0, .s64);
self.builder.store(idx_slot, zero);
const header_bb = self.freshBlock("for.hdr");
const body_bb = self.freshBlock("for.body");
const inc_bb = self.freshBlock("for.inc");
@@ -326,49 +388,44 @@ pub fn lowerFor(self: *Lowering, fe: *const ast.ForExpr) Ref {
self.builder.br(header_bb, &.{});
// Header: compare index < length
// Header: first cursor against the first bound.
self.builder.switchToBlock(header_bb);
const idx_val = self.builder.load(idx_slot, .s64);
const cmp = self.builder.cmpLt(idx_val, len);
const cur0 = self.builder.load(preps.items[0].slot, .s64);
const cmp = self.builder.cmpLt(cur0, limit);
self.builder.condBr(cmp, body_bb, &.{}, exit_bb, &.{});
// Body
// Body: bind one capture per position (when captures are present).
self.builder.switchToBlock(body_bb);
// Bind element — resolve element type from iterable. `for xs: (*x)`
// binds a pointer into the collection (no per-element copy); `(x)`
// binds a value copy.
const elem_ty = self.getElementType(iterable_ty);
const bind_ty = if (fe.capture_by_ref) self.module.types.ptrTo(elem_ty) else elem_ty;
const is_array = !iterable_ty.isBuiltin() and self.module.types.get(iterable_ty) == .array;
const elem = if (fe.capture_by_ref) blk: {
// A slice value carries its backing pointer, so GEP on it writes
// through. An array is a value — GEP needs its storage (alloca) or
// mutations would hit a copy.
const base = if (is_array) (self.getExprAlloca(fe.iterable) orelse iterable) else iterable;
break :blk self.builder.emit(.{ .index_gep = .{ .lhs = base, .rhs = idx_val } }, bind_ty);
} else blk: {
// By-value over an array with addressable storage: GEP + load ONE
// element. `index_get` on the array VALUE spills the whole array to
// a temp on every iteration — O(N²) bytes copied per loop.
if (is_array and !was_deref) {
if (self.getExprAlloca(fe.iterable)) |storage| {
const elem_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = storage, .rhs = idx_val } }, self.module.types.ptrTo(elem_ty));
break :blk self.builder.load(elem_ptr, elem_ty);
}
}
break :blk self.builder.emit(.{ .index_get = .{ .lhs = iterable, .rhs = idx_val } }, bind_ty);
};
var body_scope = Scope.init(self.alloc, self.scope);
const old_scope = self.scope;
self.scope = &body_scope;
body_scope.put(fe.capture_name, .{ .ref = elem, .ty = bind_ty, .is_alloca = false, .is_ref_capture = fe.capture_by_ref });
// Bind index if requested
if (fe.index_name) |iname| {
body_scope.put(iname, .{ .ref = idx_val, .ty = .s64, .is_alloca = false });
for (fe.captures, 0..) |cap, i| {
const prep = preps.items[i];
const cur = if (i == 0) cur0 else self.builder.load(prep.slot, .s64);
if (prep.is_range) {
body_scope.put(cap.name, .{ .ref = cur, .ty = .s64, .is_alloca = false });
continue;
}
const bind_ty = if (cap.by_ref) self.module.types.ptrTo(prep.elem_ty) else prep.elem_ty;
const elem = if (cap.by_ref) blk: {
// A slice value carries its backing pointer, so GEP on it writes
// through. An array is a value — GEP needs its storage (alloca)
// or mutations would hit a copy.
const base = if (prep.is_array) (prep.storage orelse prep.data) else prep.data;
break :blk self.builder.emit(.{ .index_gep = .{ .lhs = base, .rhs = cur } }, bind_ty);
} else blk: {
// By-value over an array with addressable storage: GEP + load ONE
// element. `index_get` on the array VALUE spills the whole array
// to a temp on every iteration — O(N²) bytes copied per loop.
if (prep.storage) |storage| {
const elem_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = storage, .rhs = cur } }, self.module.types.ptrTo(prep.elem_ty));
break :blk self.builder.load(elem_ptr, prep.elem_ty);
}
break :blk self.builder.emit(.{ .index_get = .{ .lhs = prep.data, .rhs = cur } }, bind_ty);
};
body_scope.put(cap.name, .{ .ref = elem, .ty = bind_ty, .is_alloca = false, .is_ref_capture = cap.by_ref });
}
// Save and set loop targets
@@ -392,13 +449,15 @@ pub fn lowerFor(self: *Lowering, fe: *const ast.ForExpr) Ref {
self.builder.br(inc_bb, &.{});
}
// Increment block: increment index and jump back to header
// Increment block: advance every cursor and jump back to header.
self.builder.switchToBlock(inc_bb);
{
const cur_idx = self.builder.load(idx_slot, .s64);
const one = self.builder.constInt(1, .s64);
const next_idx = self.builder.add(cur_idx, one, .s64);
self.builder.store(idx_slot, next_idx);
for (preps.items) |prep| {
const cur = self.builder.load(prep.slot, .s64);
const next = self.builder.add(cur, one, .s64);
self.builder.store(prep.slot, next);
}
self.builder.br(header_bb, &.{});
}
@@ -407,81 +466,26 @@ pub fn lowerFor(self: *Lowering, fe: *const ast.ForExpr) Ref {
return self.builder.constInt(0, .void);
}
/// Runtime counting loop `for start..end (i) { }` — `i` (optional) is the
/// cursor, `end` is exclusive. Lowers to the same header/inc/exit shape as
/// the collection form, minus the element fetch.
pub fn lowerRuntimeRangeFor(self: *Lowering, fe: *const ast.ForExpr, end_node: *Node) Ref {
const start = self.lowerExpr(fe.iterable);
const end = self.lowerExpr(end_node);
const idx_slot = self.builder.alloca(.s64);
self.builder.store(idx_slot, start);
const header_bb = self.freshBlock("for.hdr");
const body_bb = self.freshBlock("for.body");
const inc_bb = self.freshBlock("for.inc");
const exit_bb = self.freshBlock("for.exit");
self.builder.br(header_bb, &.{});
self.builder.switchToBlock(header_bb);
const idx_val = self.builder.load(idx_slot, .s64);
const cmp = self.builder.cmpLt(idx_val, end);
self.builder.condBr(cmp, body_bb, &.{}, exit_bb, &.{});
self.builder.switchToBlock(body_bb);
var body_scope = Scope.init(self.alloc, self.scope);
const old_scope = self.scope;
self.scope = &body_scope;
if (fe.capture_name.len > 0) {
body_scope.put(fe.capture_name, .{ .ref = idx_val, .ty = .s64, .is_alloca = false });
}
const old_break = self.break_target;
const old_continue = self.continue_target;
const old_defer_base = self.loop_defer_base;
self.break_target = exit_bb;
self.continue_target = inc_bb;
self.loop_defer_base = self.defer_stack.items.len;
self.lowerBlock(fe.body);
self.break_target = old_break;
self.continue_target = old_continue;
self.loop_defer_base = old_defer_base;
self.scope = old_scope;
body_scope.deinit();
if (!self.currentBlockHasTerminator()) {
self.builder.br(inc_bb, &.{});
}
self.builder.switchToBlock(inc_bb);
{
const cur_idx = self.builder.load(idx_slot, .s64);
const one = self.builder.constInt(1, .s64);
const next_idx = self.builder.add(cur_idx, one, .s64);
self.builder.store(idx_slot, next_idx);
self.builder.br(header_bb, &.{});
}
self.builder.switchToBlock(exit_bb);
return self.builder.constInt(0, .void);
}
/// Comptime-unrolled `inline for start..end (i) { }`. `start`/`end` must be
/// comptime-known. The body is lowered `end - start` times with the cursor
/// bound as an `int_val` comptime constant, so `xs[i]` over a pack
/// Comptime-unrolled `inline for start..end (i) { }`. A single bounded range
/// with comptime-known bounds. The body is lowered once per value with the
/// cursor bound as an `int_val` comptime constant, so `xs[i]` over a pack
/// substitutes the concrete per-position argument each iteration.
pub fn lowerInlineRangeFor(self: *Lowering, fe: *const ast.ForExpr, end_node: *Node) Ref {
const start = self.evalComptimeInt(fe.iterable) orelse {
if (self.diagnostics) |d| d.addFmt(.err, fe.iterable.span, "inline for: range start is not a compile-time integer", .{});
pub fn lowerInlineRangeFor(self: *Lowering, fe: *const ast.ForExpr) Ref {
const it = fe.iterables[0];
if (fe.iterables.len != 1 or !it.is_range or it.range_end == null) {
if (self.diagnostics) |d| d.addFmt(.err, it.expr.span, "inline for: a single bounded range is required — `inline for 0..N (i) {{ }}`", .{});
return self.builder.constInt(0, .void);
}
const start = self.evalComptimeInt(it.expr) orelse {
if (self.diagnostics) |d| d.addFmt(.err, it.expr.span, "inline for: range start is not a compile-time integer", .{});
return self.builder.constInt(0, .void);
};
const end = self.evalComptimeInt(end_node) orelse {
if (self.diagnostics) |d| d.addFmt(.err, end_node.span, "inline for: range end is not a compile-time integer", .{});
var end = self.evalComptimeInt(it.range_end.?) orelse {
if (self.diagnostics) |d| d.addFmt(.err, it.range_end.?.span, "inline for: range end is not a compile-time integer", .{});
return self.builder.constInt(0, .void);
};
if (it.inclusive) end += 1;
const capture_name = if (fe.captures.len > 0) fe.captures[0].name else "";
var i: i64 = start;
while (i < end) : (i += 1) {
@@ -493,22 +497,22 @@ pub fn lowerInlineRangeFor(self: *Lowering, fe: *const ast.ForExpr, end_node: *N
// `print(i)`) and as a comptime constant (for `xs[i]` substitution).
var had_prev = false;
var prev: ComptimeValue = undefined;
if (fe.capture_name.len > 0) {
body_scope.put(fe.capture_name, .{ .ref = self.builder.constInt(i, .s64), .ty = .s64, .is_alloca = false });
if (self.comptime_constants.get(fe.capture_name)) |p| {
if (capture_name.len > 0) {
body_scope.put(capture_name, .{ .ref = self.builder.constInt(i, .s64), .ty = .s64, .is_alloca = false });
if (self.comptime_constants.get(capture_name)) |p| {
had_prev = true;
prev = p;
}
self.comptime_constants.put(fe.capture_name, .{ .int_val = i }) catch {};
self.comptime_constants.put(capture_name, .{ .int_val = i }) catch {};
}
self.lowerBlock(fe.body);
if (fe.capture_name.len > 0) {
if (capture_name.len > 0) {
if (had_prev) {
self.comptime_constants.put(fe.capture_name, prev) catch {};
self.comptime_constants.put(capture_name, prev) catch {};
} else {
_ = self.comptime_constants.remove(fe.capture_name);
_ = self.comptime_constants.remove(capture_name);
}
}

View File

@@ -158,10 +158,13 @@ pub const UnknownTypeChecker = struct {
self.checkBindingNames(we.body);
},
.for_expr => |fe| {
if (fe.capture_name.len != 0) self.checkBindingName(fe.capture_name, fe.capture_span, fe.capture_is_raw);
if (fe.index_name) |idx| self.checkBindingName(idx, fe.index_span, fe.index_is_raw);
self.checkBindingNames(fe.iterable);
if (fe.range_end) |re| self.checkBindingNames(re);
for (fe.captures) |cap| {
if (cap.name.len != 0) self.checkBindingName(cap.name, cap.span, cap.is_raw);
}
for (fe.iterables) |it| {
self.checkBindingNames(it.expr);
if (it.range_end) |re| self.checkBindingNames(re);
}
self.checkBindingNames(fe.body);
},
.match_expr => |me| {
@@ -417,8 +420,10 @@ pub const UnknownTypeChecker = struct {
self.harvestScopeDecls(we.body, out);
},
.for_expr => |fe| {
self.harvestScopeDecls(fe.iterable, out);
if (fe.range_end) |re| self.harvestScopeDecls(re, out);
for (fe.iterables) |it| {
self.harvestScopeDecls(it.expr, out);
if (it.range_end) |re| self.harvestScopeDecls(re, out);
}
self.harvestScopeDecls(fe.body, out);
},
.match_expr => |me| {
@@ -566,8 +571,10 @@ pub const UnknownTypeChecker = struct {
self.walkBodyTypes(we.body, declared, in_scope, type_vals);
},
.for_expr => |fe| {
self.walkBodyTypes(fe.iterable, declared, in_scope, type_vals);
if (fe.range_end) |re| self.walkBodyTypes(re, declared, in_scope, type_vals);
for (fe.iterables) |it| {
self.walkBodyTypes(it.expr, declared, in_scope, type_vals);
if (it.range_end) |re| self.walkBodyTypes(re, declared, in_scope, type_vals);
}
self.walkBodyTypes(fe.body, declared, in_scope, type_vals);
},
.match_expr => |me| {