refactor(ir): extract ErrorAnalysis (error_analysis.zig) for error-set convergence (A5.1 step 2)

Error-set convergence now lives in src/ir/error_analysis.zig behind a *Lowering
facade (ErrorAnalysis), mirroring the other domain extractions. Moved verbatim:
- convergeInferredErrorSets (whole-program inferred-`!` SCC fix-point),
- convergeClosureShapeSets,
- collectErrorSites / collectClosureShapes (the AST collectors).

Added ErrorFacts (the PLAN-ARCH shape: inferred_error_sets + shape_inferred_sets)
+ a facts() view over the maps, which stay on Lowering for now (consumers read
them via self.*). recordClosureShape and its deep type/shape helper web stay in
Lowering; it reaches the moved collectErrorSites via self.errorAnalysis().

Lowering keeps convergeInferredErrorSets / convergeClosureShapeSets as thin pub
wrappers (the lowering pipeline + the E1.4b unit test call them); collectErrorSites
/ collectClosureShapes are deleted (no fallback). New pub: isErrorTagLiteralNode /
callTargetName / astIsPureBareInferred / astPureNamedSet / containsTag /
namedSetTags / recordClosureShape (the moved collectors / facade reach them).
lower.zig net -216 lines.

The 2 convergence unit tests (transitive SCC across a try edge; closure-shape
union) moved from lower.test.zig to error_analysis.test.zig and now drive the
facade directly; the E1.4b test stays in lower.test.zig via the wrapper. Module
named error_analysis.zig, NOT errors.zig (src/errors.zig is the DiagnosticList).

zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir churn.
This commit is contained in:
agra
2026-06-02 23:11:18 +03:00
parent 9153f958ea
commit 667192c718
5 changed files with 393 additions and 330 deletions

View File

@@ -0,0 +1,95 @@
// Tests for error_analysis.zig — the error-set convergence owner
// (`ErrorAnalysis`). Reached via `ir.ErrorAnalysis{ .l = &lowering }`, mirroring
// the other facade tests. Moved here from lower.test.zig when the convergence
// traversals moved out of `Lowering` (A5.1 sub-step 2). The whole-program
// fix-point + closure-shape union are what A5.1 must preserve.
const std = @import("std");
const ast = @import("../ast.zig");
const Node = ast.Node;
const ir_mod = @import("ir.zig");
const TypeId = ir_mod.TypeId;
const Lowering = ir_mod.Lowering;
const ErrorAnalysis = ir_mod.ErrorAnalysis;
fn mk(alloc: std.mem.Allocator, data: ast.Node.Data) *Node {
const n = alloc.create(Node) catch unreachable;
n.* = .{ .span = .{ .start = 0, .end = 0 }, .data = data };
return n;
}
test "error_analysis: convergeInferredErrorSets propagates a callee set across a try edge" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
const ea = ErrorAnalysis{ .l = &lowering };
// raiser :: () -> ! { raise error.Foo; }
const r_rt = mk(alloc, .{ .error_type_expr = .{ .name = null } });
const r_err = mk(alloc, .{ .identifier = .{ .name = "error" } });
const r_fa = mk(alloc, .{ .field_access = .{ .object = r_err, .field = "Foo" } });
const r_raise = mk(alloc, .{ .raise_stmt = .{ .tag = r_fa } });
const r_body = mk(alloc, .{ .block = .{ .stmts = &[_]*Node{r_raise} } });
const raiser_fd = ast.FnDecl{ .name = "raiser", .params = &.{}, .return_type = r_rt, .body = r_body };
// caller :: () -> ! { try raiser(); } — no direct raise; inherits {Foo}.
const c_rt = mk(alloc, .{ .error_type_expr = .{ .name = null } });
const c_callee = mk(alloc, .{ .identifier = .{ .name = "raiser" } });
const c_call = mk(alloc, .{ .call = .{ .callee = c_callee, .args = &.{} } });
const c_try = mk(alloc, .{ .try_expr = .{ .operand = c_call } });
const c_body = mk(alloc, .{ .block = .{ .stmts = &[_]*Node{c_try} } });
const caller_fd = ast.FnDecl{ .name = "caller", .params = &.{}, .return_type = c_rt, .body = c_body };
lowering.program_index.fn_ast_map.put("raiser", &raiser_fd) catch unreachable;
lowering.program_index.fn_ast_map.put("caller", &caller_fd) catch unreachable;
ea.convergeInferredErrorSets();
const foo = module.types.internTag("Foo");
const raiser_set = lowering.inferred_error_sets.get("raiser") orelse unreachable;
try std.testing.expectEqual(@as(usize, 1), raiser_set.len);
try std.testing.expectEqual(foo, raiser_set[0]);
// The caller raises nothing directly but converges to {Foo} via the edge.
const caller_set = lowering.inferred_error_sets.get("caller") orelse unreachable;
try std.testing.expectEqual(@as(usize, 1), caller_set.len);
try std.testing.expectEqual(foo, caller_set[0]);
// facts() exposes the same converged store.
try std.testing.expect(ea.facts().inferred_error_sets.get("caller") != null);
}
test "error_analysis: convergeClosureShapeSets unions a bare-! closure literal's raises" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
const ea = ErrorAnalysis{ .l = &lowering };
// host :: () { () -> ! { raise error.Bar; }; } — a bare-`!` closure literal
// sitting in `host`'s body; its raises union into the shape set.
const lam_rt = mk(alloc, .{ .error_type_expr = .{ .name = null } });
const l_err = mk(alloc, .{ .identifier = .{ .name = "error" } });
const l_fa = mk(alloc, .{ .field_access = .{ .object = l_err, .field = "Bar" } });
const l_raise = mk(alloc, .{ .raise_stmt = .{ .tag = l_fa } });
const lam_body = mk(alloc, .{ .block = .{ .stmts = &[_]*Node{l_raise} } });
const lambda = mk(alloc, .{ .lambda = .{ .params = &.{}, .return_type = lam_rt, .body = lam_body } });
const host_body = mk(alloc, .{ .block = .{ .stmts = &[_]*Node{lambda} } });
const host_fd = ast.FnDecl{ .name = "host", .params = &.{}, .return_type = null, .body = host_body };
lowering.program_index.fn_ast_map.put("host", &host_fd) catch unreachable;
ea.convergeClosureShapeSets();
// Exactly one closure shape recorded, carrying {Bar}.
try std.testing.expectEqual(@as(u32, 1), lowering.shape_inferred_sets.count());
var it = lowering.shape_inferred_sets.valueIterator();
const tags = it.next().?.*;
try std.testing.expectEqual(@as(usize, 1), tags.len);
try std.testing.expectEqual(module.types.internTag("Bar"), tags[0]);
}

272
src/ir/error_analysis.zig Normal file
View File

@@ -0,0 +1,272 @@
const std = @import("std");
const ast = @import("../ast.zig");
const lower = @import("lower.zig");
const Node = ast.Node;
const Lowering = lower.Lowering;
/// The converged error-analysis facts lowering consumes (PLAN-ARCH A5.1): each
/// pure-failable function's inferred error-tag set, and each bare-`!` closure
/// SHAPE's inferred set. Backing maps currently live on `Lowering` (the facade
/// writes `self.l.*`); `facts()` returns a view over them.
pub const ErrorFacts = struct {
inferred_error_sets: std.StringHashMap([]const u32),
shape_inferred_sets: std.StringHashMap([]const u32),
};
/// Whole-program error-set convergence (architecture phase A5.1), extracted
/// from `Lowering`. Owns the fix-point traversals that converge inferred
/// `!` error sets (`convergeInferredErrorSets`) and bare-`!` closure-shape sets
/// (`convergeClosureShapeSets`), plus the AST collectors that feed them.
///
/// A `*Lowering` facade (Principle 5, like `CallResolver`/`ProtocolResolver`):
/// it reads the declaration map (`fn_ast_map`) + tag registry and writes the
/// `inferred_error_sets` / `shape_inferred_sets` maps that still live on
/// `Lowering` (consumers read them there). The per-closure-literal contribution
/// (`recordClosureShape`) + its type/shape helpers stay in `Lowering`; this
/// module calls back for that and reaches its own `collectErrorSites` via the
/// facade.
pub const ErrorAnalysis = struct {
l: *Lowering,
pub fn facts(self: ErrorAnalysis) ErrorFacts {
return .{
.inferred_error_sets = self.l.inferred_error_sets,
.shape_inferred_sets = self.l.shape_inferred_sets,
};
}
/// Collect the error TAGS raised + the `try`-call EDGES of a function body,
/// for the inferred-set fix-point. Stops at nested function boundaries.
pub fn collectErrorSites(self: ErrorAnalysis, node: *const Node, tags: *std.ArrayList(u32), edges: *std.ArrayList([]const u8)) void {
switch (node.data) {
.raise_stmt => |rs| {
if (Lowering.isErrorTagLiteralNode(rs.tag)) {
tags.append(self.l.alloc, self.l.module.types.internTag(rs.tag.data.field_access.field)) catch {};
}
self.collectErrorSites(rs.tag, tags, edges);
},
.try_expr => |te| {
if (Lowering.callTargetName(te.operand)) |nm| edges.append(self.l.alloc, nm) catch {};
self.collectErrorSites(te.operand, tags, edges);
},
.block => |b| for (b.stmts) |s| self.collectErrorSites(s, tags, edges),
.if_expr => |ie| {
self.collectErrorSites(ie.condition, tags, edges);
self.collectErrorSites(ie.then_branch, tags, edges);
if (ie.else_branch) |eb| self.collectErrorSites(eb, tags, edges);
},
.while_expr => |w| {
self.collectErrorSites(w.condition, tags, edges);
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);
self.collectErrorSites(f.body, tags, edges);
},
.return_stmt => |r| if (r.value) |v| self.collectErrorSites(v, tags, edges),
.var_decl => |v| if (v.value) |val| self.collectErrorSites(val, tags, edges),
.const_decl => |c| self.collectErrorSites(c.value, tags, edges),
.destructure_decl => |d| self.collectErrorSites(d.value, tags, edges),
.assignment => |a| {
self.collectErrorSites(a.target, tags, edges);
self.collectErrorSites(a.value, tags, edges);
},
.multi_assign => |m| {
for (m.targets) |t| self.collectErrorSites(t, tags, edges);
for (m.values) |v| self.collectErrorSites(v, tags, edges);
},
.call => |c| {
self.collectErrorSites(c.callee, tags, edges);
for (c.args) |a| self.collectErrorSites(a, tags, edges);
},
.binary_op => |b| {
self.collectErrorSites(b.lhs, tags, edges);
self.collectErrorSites(b.rhs, tags, edges);
},
.unary_op => |u| self.collectErrorSites(u.operand, tags, edges),
.deref_expr => |d| self.collectErrorSites(d.operand, tags, edges),
.force_unwrap => |fu| self.collectErrorSites(fu.operand, tags, edges),
.null_coalesce => |nc| {
self.collectErrorSites(nc.lhs, tags, edges);
self.collectErrorSites(nc.rhs, tags, edges);
},
.field_access => |fa| self.collectErrorSites(fa.object, tags, edges),
.index_expr => |ix| {
self.collectErrorSites(ix.object, tags, edges);
self.collectErrorSites(ix.index, tags, edges);
},
.spread_expr => |s| self.collectErrorSites(s.operand, tags, edges),
.catch_expr => |ce| {
self.collectErrorSites(ce.operand, tags, edges);
self.collectErrorSites(ce.body, tags, edges);
},
.defer_stmt => |d| self.collectErrorSites(d.expr, tags, edges),
.push_stmt => |p| {
self.collectErrorSites(p.context_expr, tags, edges);
self.collectErrorSites(p.body, tags, edges);
},
.array_literal => |al| for (al.elements) |el| self.collectErrorSites(el, tags, edges),
.tuple_literal => |tl| for (tl.elements) |el| self.collectErrorSites(el.value, tags, edges),
// Stop at nested function boundaries; leaves contribute nothing.
else => {},
}
}
/// Whole-program fix-point that converges each top-level bare-`!` function's
/// inferred error set (ERR E1.4b). Runs after `scanDecls` (ASTs + named
/// error sets registered) and before body lowering, so `lowerTry`'s
/// named-caller widening sees the converged callee sets. Also emits the
/// empty-inferred warning. Scope: pure-failable functions (value-carrying
/// raise/try aren't lowered yet — E2).
pub fn convergeInferredErrorSets(self: ErrorAnalysis) void {
const Node_ = struct {
tags: std.ArrayList(u32),
edges: std.ArrayList([]const u8),
rt: ?*const Node,
};
var work = std.StringHashMap(Node_).init(self.l.alloc);
defer work.deinit();
// Seed each bare-`!` function with its direct escape sites.
var it = self.l.program_index.fn_ast_map.iterator();
while (it.next()) |e| {
const fd = e.value_ptr.*;
if (!Lowering.astIsPureBareInferred(fd.return_type)) continue;
var tags = std.ArrayList(u32).empty;
var edges = std.ArrayList([]const u8).empty;
self.collectErrorSites(fd.body, &tags, &edges);
work.put(e.key_ptr.*, .{ .tags = tags, .edges = edges, .rt = fd.return_type }) catch {};
}
// Union edge contributions until no set grows (monotone → terminates).
var changed = true;
while (changed) {
changed = false;
var wit = work.iterator();
while (wit.next()) |we| {
for (we.value_ptr.edges.items) |callee| {
const callee_tags: []const u32 = blk: {
if (work.getPtr(callee)) |cc| break :blk cc.tags.items;
if (self.l.program_index.fn_ast_map.get(callee)) |cfd| {
if (Lowering.astPureNamedSet(cfd.return_type)) |nm| {
break :blk self.l.namedSetTags(nm) orelse &.{};
}
}
break :blk &.{};
};
for (callee_tags) |t| {
if (!Lowering.containsTag(we.value_ptr.tags.items, t)) {
we.value_ptr.tags.append(self.l.alloc, t) catch {};
changed = true;
}
}
}
}
}
// Store the converged sets (sorted) and warn on empty inferred sets.
var sit = work.iterator();
while (sit.next()) |se| {
const sorted = self.l.alloc.dupe(u32, se.value_ptr.tags.items) catch continue;
std.mem.sort(u32, sorted, {}, std.sort.asc(u32));
self.l.inferred_error_sets.put(se.key_ptr.*, sorted) catch {};
if (sorted.len == 0 and !std.mem.eql(u8, se.key_ptr.*, "main")) {
if (self.l.diagnostics) |diags| {
if (se.value_ptr.rt) |rt| {
diags.addFmt(.warn, rt.span, "function '{s}' is declared `!` but never errors — drop the `!`", .{se.key_ptr.*});
}
}
}
}
}
/// Whole-program union of each bare-`!` closure/fn-type SHAPE's escape set
/// (ERR E5.1 sub-feature 2). Walks every function body for closure literals;
/// each bare-`!` failable literal contributes its raises (+ `try named_fn()`
/// edges, resolved against the name-keyed converged sets) to the node shared
/// by all occurrences of its value-signature shape. A `try slot(x)` against
/// any matching-shape slot then widens against this union.
pub fn convergeClosureShapeSets(self: ErrorAnalysis) void {
var it = self.l.program_index.fn_ast_map.iterator();
while (it.next()) |e| {
self.collectClosureShapes(e.value_ptr.*.body);
}
}
/// Recurse the AST collecting closure-literal shape contributions. Unlike
/// `collectErrorSites`, this descends THROUGH lambda boundaries (a nested
/// closure is its own shape, and may itself contain closures). The
/// per-literal recording (`recordClosureShape`) stays in `Lowering`.
fn collectClosureShapes(self: ErrorAnalysis, node: *const Node) void {
switch (node.data) {
.lambda => |lam| {
self.l.recordClosureShape(&lam);
self.collectClosureShapes(lam.body);
},
.block => |b| for (b.stmts) |s| self.collectClosureShapes(s),
.if_expr => |ie| {
self.collectClosureShapes(ie.condition);
self.collectClosureShapes(ie.then_branch);
if (ie.else_branch) |eb| self.collectClosureShapes(eb);
},
.while_expr => |w| {
self.collectClosureShapes(w.condition);
self.collectClosureShapes(w.body);
},
.for_expr => |f| {
self.collectClosureShapes(f.iterable);
if (f.range_end) |re| self.collectClosureShapes(re);
self.collectClosureShapes(f.body);
},
.return_stmt => |r| if (r.value) |v| self.collectClosureShapes(v),
.raise_stmt => |rs| self.collectClosureShapes(rs.tag),
.var_decl => |v| if (v.value) |val| self.collectClosureShapes(val),
.const_decl => |c| self.collectClosureShapes(c.value),
.destructure_decl => |d| self.collectClosureShapes(d.value),
.assignment => |a| {
self.collectClosureShapes(a.target);
self.collectClosureShapes(a.value);
},
.multi_assign => |m| {
for (m.targets) |t| self.collectClosureShapes(t);
for (m.values) |v| self.collectClosureShapes(v);
},
.call => |c| {
self.collectClosureShapes(c.callee);
for (c.args) |a| self.collectClosureShapes(a);
},
.binary_op => |b| {
self.collectClosureShapes(b.lhs);
self.collectClosureShapes(b.rhs);
},
.unary_op => |u| self.collectClosureShapes(u.operand),
.deref_expr => |d| self.collectClosureShapes(d.operand),
.force_unwrap => |fu| self.collectClosureShapes(fu.operand),
.null_coalesce => |nc| {
self.collectClosureShapes(nc.lhs);
self.collectClosureShapes(nc.rhs);
},
.field_access => |fa| self.collectClosureShapes(fa.object),
.index_expr => |ix| {
self.collectClosureShapes(ix.object);
self.collectClosureShapes(ix.index);
},
.spread_expr => |s| self.collectClosureShapes(s.operand),
.try_expr => |te| self.collectClosureShapes(te.operand),
.catch_expr => |ce| {
self.collectClosureShapes(ce.operand);
self.collectClosureShapes(ce.body);
},
.defer_stmt => |d| self.collectClosureShapes(d.expr),
.push_stmt => |p| {
self.collectClosureShapes(p.context_expr);
self.collectClosureShapes(p.body);
},
.array_literal => |al| for (al.elements) |el| self.collectClosureShapes(el),
.tuple_literal => |tl| for (tl.elements) |el| self.collectClosureShapes(el.value),
else => {},
}
}
};

View File

@@ -12,6 +12,7 @@ pub const calls = @import("calls.zig");
pub const generics = @import("generics.zig");
pub const protocols = @import("protocols.zig");
pub const conversions = @import("conversions.zig");
pub const error_analysis = @import("error_analysis.zig");
pub const semantic_diagnostics = @import("semantic_diagnostics.zig");
pub const TypeId = types.TypeId;
@@ -50,6 +51,8 @@ pub const GenericResolver = generics.GenericResolver;
pub const ProtocolResolver = protocols.ProtocolResolver;
pub const CoercionResolver = conversions.CoercionResolver;
pub const CoercionPlan = conversions.CoercionResolver.CoercionPlan;
pub const ErrorAnalysis = error_analysis.ErrorAnalysis;
pub const ErrorFacts = error_analysis.ErrorFacts;
pub const compiler_hooks = @import("compiler_hooks.zig");
pub const emit_llvm = @import("emit_llvm.zig");
@@ -76,6 +79,7 @@ pub const calls_tests = @import("calls.test.zig");
pub const generics_tests = @import("generics.test.zig");
pub const protocols_tests = @import("protocols.test.zig");
pub const conversions_tests = @import("conversions.test.zig");
pub const error_analysis_tests = @import("error_analysis.test.zig");
pub const type_bridge_tests = @import("type_bridge.test.zig");
pub const emit_llvm_tests = @import("emit_llvm.test.zig");
pub const jni_descriptor_tests = @import("jni_descriptor.test.zig");

View File

@@ -904,95 +904,3 @@ test "conversions: optionalOfFlattened wraps once, flattening a nested optional"
try std.testing.expect(module.types.optionalOf(opt_s64) != opt_s64);
}
// ── A5.1 test-first scaffolding: error-set convergence ───────────────
// Lock the whole-program inferred-error-set + closure-shape convergence
// (`convergeInferredErrorSets` / `convergeClosureShapeSets`, both already pub)
// before they move to `src/ir/error_analysis.zig`. The transitive-convergence
// case (a caller inheriting a callee's set across a `try` edge) is the SCC
// fixpoint A5.1 must preserve; today's E1.4b test only covers a direct raiser.
test "errors: convergeInferredErrorSets propagates a callee set across a try edge" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
const mk = struct {
fn node(a: std.mem.Allocator, data: ast.Node.Data) *Node {
const n = a.create(Node) catch unreachable;
n.* = .{ .span = .{ .start = 0, .end = 0 }, .data = data };
return n;
}
};
// raiser :: () -> ! { raise error.Foo; }
const r_rt = mk.node(alloc, .{ .error_type_expr = .{ .name = null } });
const r_err = mk.node(alloc, .{ .identifier = .{ .name = "error" } });
const r_fa = mk.node(alloc, .{ .field_access = .{ .object = r_err, .field = "Foo" } });
const r_raise = mk.node(alloc, .{ .raise_stmt = .{ .tag = r_fa } });
const r_body = mk.node(alloc, .{ .block = .{ .stmts = &[_]*Node{r_raise} } });
const raiser_fd = ast.FnDecl{ .name = "raiser", .params = &.{}, .return_type = r_rt, .body = r_body };
// caller :: () -> ! { try raiser(); } — no direct raise; inherits {Foo}.
const c_rt = mk.node(alloc, .{ .error_type_expr = .{ .name = null } });
const c_callee = mk.node(alloc, .{ .identifier = .{ .name = "raiser" } });
const c_call = mk.node(alloc, .{ .call = .{ .callee = c_callee, .args = &.{} } });
const c_try = mk.node(alloc, .{ .try_expr = .{ .operand = c_call } });
const c_body = mk.node(alloc, .{ .block = .{ .stmts = &[_]*Node{c_try} } });
const caller_fd = ast.FnDecl{ .name = "caller", .params = &.{}, .return_type = c_rt, .body = c_body };
lowering.program_index.fn_ast_map.put("raiser", &raiser_fd) catch unreachable;
lowering.program_index.fn_ast_map.put("caller", &caller_fd) catch unreachable;
lowering.convergeInferredErrorSets();
const foo = module.types.internTag("Foo");
const raiser_set = lowering.inferred_error_sets.get("raiser") orelse unreachable;
try std.testing.expectEqual(@as(usize, 1), raiser_set.len);
try std.testing.expectEqual(foo, raiser_set[0]);
// The caller raises nothing directly but converges to {Foo} via the edge.
const caller_set = lowering.inferred_error_sets.get("caller") orelse unreachable;
try std.testing.expectEqual(@as(usize, 1), caller_set.len);
try std.testing.expectEqual(foo, caller_set[0]);
}
test "errors: convergeClosureShapeSets unions a bare-! closure literal's raises" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
const mk = struct {
fn node(a: std.mem.Allocator, data: ast.Node.Data) *Node {
const n = a.create(Node) catch unreachable;
n.* = .{ .span = .{ .start = 0, .end = 0 }, .data = data };
return n;
}
};
// host :: () { () -> ! { raise error.Bar; }; } — a bare-`!` closure literal
// sitting in `host`'s body; its raises union into the shape set.
const lam_rt = mk.node(alloc, .{ .error_type_expr = .{ .name = null } });
const l_err = mk.node(alloc, .{ .identifier = .{ .name = "error" } });
const l_fa = mk.node(alloc, .{ .field_access = .{ .object = l_err, .field = "Bar" } });
const l_raise = mk.node(alloc, .{ .raise_stmt = .{ .tag = l_fa } });
const lam_body = mk.node(alloc, .{ .block = .{ .stmts = &[_]*Node{l_raise} } });
const lambda = mk.node(alloc, .{ .lambda = .{ .params = &.{}, .return_type = lam_rt, .body = lam_body } });
const host_body = mk.node(alloc, .{ .block = .{ .stmts = &[_]*Node{lambda} } });
const host_fd = ast.FnDecl{ .name = "host", .params = &.{}, .return_type = null, .body = host_body };
lowering.program_index.fn_ast_map.put("host", &host_fd) catch unreachable;
lowering.convergeClosureShapeSets();
// Exactly one closure shape recorded, carrying {Bar}.
try std.testing.expectEqual(@as(u32, 1), lowering.shape_inferred_sets.count());
var it = lowering.shape_inferred_sets.valueIterator();
const tags = it.next().?.*;
try std.testing.expectEqual(@as(usize, 1), tags.len);
try std.testing.expectEqual(module.types.internTag("Bar"), tags[0]);
}

View File

@@ -27,6 +27,7 @@ const CallResolver = @import("calls.zig").CallResolver;
const GenericResolver = @import("generics.zig").GenericResolver;
const ProtocolResolver = @import("protocols.zig").ProtocolResolver;
const CoercionResolver = @import("conversions.zig").CoercionResolver;
const ErrorAnalysis = @import("error_analysis.zig").ErrorAnalysis;
const semantic_diagnostics = @import("semantic_diagnostics.zig");
const TypeId = types.TypeId;
@@ -13845,6 +13846,10 @@ pub const Lowering = struct {
return .{ .l = self };
}
pub fn errorAnalysis(self: *Lowering) ErrorAnalysis {
return .{ .l = self };
}
/// Lower the `xx` operator (type coercion).
/// Uses self.target_type for context when available. Handles:
/// - Any → concrete type: unbox_any
@@ -14662,7 +14667,7 @@ pub const Lowering = struct {
/// True when `node` is an `error.X` tag literal (`field_access` whose
/// object is the `error` keyword, parsed as identifier "error").
fn isErrorTagLiteralNode(node: *const Node) bool {
pub fn isErrorTagLiteralNode(node: *const Node) bool {
if (node.data != .field_access) return false;
const obj = node.data.field_access.object;
return obj.data == .identifier and std.mem.eql(u8, obj.data.identifier.name, "error");
@@ -15519,7 +15524,7 @@ pub const Lowering = struct {
/// the node isn't a direct call to a named function. E1.4b resolves only
/// the bare identifier (top-level functions); UFCS / mangled-local callees
/// aren't tracked by the SCC.
fn callTargetName(node: *const Node) ?[]const u8 {
pub fn callTargetName(node: *const Node) ?[]const u8 {
if (node.data != .call) return null;
const callee = node.data.call.callee;
return if (callee.data == .identifier) callee.data.identifier.name else null;
@@ -15527,14 +15532,14 @@ pub const Lowering = struct {
/// True when `rt` is a pure bare-`!` failable return (`-> !`, the inferred
/// set) — NOT `!Named` and NOT a value-carrying `-> (T..., !)` tuple.
fn astIsPureBareInferred(rt: ?*const Node) bool {
pub fn astIsPureBareInferred(rt: ?*const Node) bool {
const n = rt orelse return false;
return n.data == .error_type_expr and n.data.error_type_expr.name == null;
}
/// The named-set name of a pure `-> !Named` return (`"Named"`), or null for
/// bare-`!`, value-carrying, or non-failable returns.
fn astPureNamedSet(rt: ?*const Node) ?[]const u8 {
pub fn astPureNamedSet(rt: ?*const Node) ?[]const u8 {
const n = rt orelse return null;
if (n.data != .error_type_expr) return null;
return n.data.error_type_expr.name;
@@ -15542,7 +15547,7 @@ pub const Lowering = struct {
/// The declared tags of a named error set, by name; null if not a
/// registered error set.
fn namedSetTags(self: *Lowering, name: []const u8) ?[]const u32 {
pub fn namedSetTags(self: *Lowering, name: []const u8) ?[]const u32 {
const sid = self.module.types.internString(name);
const tid = self.module.types.findByName(sid) orelse return null;
if (tid.isBuiltin()) return null;
@@ -15550,251 +15555,30 @@ pub const Lowering = struct {
return if (info == .error_set) info.error_set.tags else null;
}
/// Walk a function body's AST collecting its DIRECT escape sites: literal
/// `raise error.X` tags (→ `tags`) and standalone `try g()` callee names
/// (→ `edges`). Recurses through statement/expression containers but stops
/// at nested function boundaries (a nested fn/lambda's raises are its own).
/// Variable `raise e` and value-carrying `try` are out of E1.4b scope.
fn collectErrorSites(self: *Lowering, node: *const Node, tags: *std.ArrayList(u32), edges: *std.ArrayList([]const u8)) void {
switch (node.data) {
.raise_stmt => |rs| {
if (isErrorTagLiteralNode(rs.tag)) {
tags.append(self.alloc, self.module.types.internTag(rs.tag.data.field_access.field)) catch {};
}
self.collectErrorSites(rs.tag, tags, edges);
},
.try_expr => |te| {
if (callTargetName(te.operand)) |nm| edges.append(self.alloc, nm) catch {};
self.collectErrorSites(te.operand, tags, edges);
},
.block => |b| for (b.stmts) |s| self.collectErrorSites(s, tags, edges),
.if_expr => |ie| {
self.collectErrorSites(ie.condition, tags, edges);
self.collectErrorSites(ie.then_branch, tags, edges);
if (ie.else_branch) |eb| self.collectErrorSites(eb, tags, edges);
},
.while_expr => |w| {
self.collectErrorSites(w.condition, tags, edges);
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);
self.collectErrorSites(f.body, tags, edges);
},
.return_stmt => |r| if (r.value) |v| self.collectErrorSites(v, tags, edges),
.var_decl => |v| if (v.value) |val| self.collectErrorSites(val, tags, edges),
.const_decl => |c| self.collectErrorSites(c.value, tags, edges),
.destructure_decl => |d| self.collectErrorSites(d.value, tags, edges),
.assignment => |a| {
self.collectErrorSites(a.target, tags, edges);
self.collectErrorSites(a.value, tags, edges);
},
.multi_assign => |m| {
for (m.targets) |t| self.collectErrorSites(t, tags, edges);
for (m.values) |v| self.collectErrorSites(v, tags, edges);
},
.call => |c| {
self.collectErrorSites(c.callee, tags, edges);
for (c.args) |a| self.collectErrorSites(a, tags, edges);
},
.binary_op => |b| {
self.collectErrorSites(b.lhs, tags, edges);
self.collectErrorSites(b.rhs, tags, edges);
},
.unary_op => |u| self.collectErrorSites(u.operand, tags, edges),
.deref_expr => |d| self.collectErrorSites(d.operand, tags, edges),
.force_unwrap => |fu| self.collectErrorSites(fu.operand, tags, edges),
.null_coalesce => |nc| {
self.collectErrorSites(nc.lhs, tags, edges);
self.collectErrorSites(nc.rhs, tags, edges);
},
.field_access => |fa| self.collectErrorSites(fa.object, tags, edges),
.index_expr => |ix| {
self.collectErrorSites(ix.object, tags, edges);
self.collectErrorSites(ix.index, tags, edges);
},
.spread_expr => |s| self.collectErrorSites(s.operand, tags, edges),
.catch_expr => |ce| {
self.collectErrorSites(ce.operand, tags, edges);
self.collectErrorSites(ce.body, tags, edges);
},
.defer_stmt => |d| self.collectErrorSites(d.expr, tags, edges),
.push_stmt => |p| {
self.collectErrorSites(p.context_expr, tags, edges);
self.collectErrorSites(p.body, tags, edges);
},
.array_literal => |al| for (al.elements) |el| self.collectErrorSites(el, tags, edges),
.tuple_literal => |tl| for (tl.elements) |el| self.collectErrorSites(el.value, tags, edges),
// Stop at nested function boundaries; leaves contribute nothing.
else => {},
}
}
/// Whole-program fix-point that converges each top-level bare-`!` function's
/// inferred error set (ERR E1.4b). Runs after `scanDecls` (ASTs + named
/// error sets registered) and before body lowering, so `lowerTry`'s
/// named-caller widening sees the converged callee sets. Also emits the
/// empty-inferred warning. Scope: pure-failable functions (value-carrying
/// raise/try aren't lowered yet — E2).
/// Whole-program inferred-error-set convergence. Thin delegation to the
/// canonical owner (`ErrorAnalysis`, `error_analysis.zig`); kept on
/// `Lowering` as a `pub` entry point because the lowering pipeline + the
/// E1.4b unit test call it.
pub fn convergeInferredErrorSets(self: *Lowering) void {
const Node_ = struct {
tags: std.ArrayList(u32),
edges: std.ArrayList([]const u8),
rt: ?*const Node,
};
var work = std.StringHashMap(Node_).init(self.alloc);
defer work.deinit();
// Seed each bare-`!` function with its direct escape sites.
var it = self.program_index.fn_ast_map.iterator();
while (it.next()) |e| {
const fd = e.value_ptr.*;
if (!astIsPureBareInferred(fd.return_type)) continue;
var tags = std.ArrayList(u32).empty;
var edges = std.ArrayList([]const u8).empty;
self.collectErrorSites(fd.body, &tags, &edges);
work.put(e.key_ptr.*, .{ .tags = tags, .edges = edges, .rt = fd.return_type }) catch {};
}
// Union edge contributions until no set grows (monotone → terminates).
var changed = true;
while (changed) {
changed = false;
var wit = work.iterator();
while (wit.next()) |we| {
for (we.value_ptr.edges.items) |callee| {
const callee_tags: []const u32 = blk: {
if (work.getPtr(callee)) |cc| break :blk cc.tags.items;
if (self.program_index.fn_ast_map.get(callee)) |cfd| {
if (astPureNamedSet(cfd.return_type)) |nm| {
break :blk self.namedSetTags(nm) orelse &.{};
}
}
break :blk &.{};
};
for (callee_tags) |t| {
if (!containsTag(we.value_ptr.tags.items, t)) {
we.value_ptr.tags.append(self.alloc, t) catch {};
changed = true;
}
}
}
}
}
// Store the converged sets (sorted) and warn on empty inferred sets.
var sit = work.iterator();
while (sit.next()) |se| {
const sorted = self.alloc.dupe(u32, se.value_ptr.tags.items) catch continue;
std.mem.sort(u32, sorted, {}, std.sort.asc(u32));
self.inferred_error_sets.put(se.key_ptr.*, sorted) catch {};
if (sorted.len == 0 and !std.mem.eql(u8, se.key_ptr.*, "main")) {
if (self.diagnostics) |diags| {
if (se.value_ptr.rt) |rt| {
diags.addFmt(.warn, rt.span, "function '{s}' is declared `!` but never errors — drop the `!`", .{se.key_ptr.*});
}
}
}
}
self.errorAnalysis().convergeInferredErrorSets();
}
fn containsTag(tags: []const u32, t: u32) bool {
pub fn containsTag(tags: []const u32, t: u32) bool {
for (tags) |x| if (x == t) return true;
return false;
}
/// Whole-program union of each bare-`!` closure/fn-type SHAPE's escape set
/// (ERR E5.1 sub-feature 2). Walks every function body for closure literals;
/// each bare-`!` failable literal contributes its raises (+ `try named_fn()`
/// edges, resolved against the name-keyed converged sets) to the node shared
/// by all occurrences of its value-signature shape. A `try slot(x)` against
/// any matching-shape slot then widens against this union.
/// Whole-program closure-shape error-set convergence. Thin delegation to the
/// canonical owner (`ErrorAnalysis`, `error_analysis.zig`); kept on
/// `Lowering` as a `pub` entry point because the lowering pipeline calls it.
pub fn convergeClosureShapeSets(self: *Lowering) void {
var it = self.program_index.fn_ast_map.iterator();
while (it.next()) |e| {
self.collectClosureShapes(e.value_ptr.*.body);
}
}
/// Recurse the AST collecting closure-literal shape contributions. Unlike
/// `collectErrorSites`, this descends THROUGH lambda boundaries (a nested
/// closure is its own shape, and may itself contain closures).
fn collectClosureShapes(self: *Lowering, node: *const Node) void {
switch (node.data) {
.lambda => |lam| {
self.recordClosureShape(&lam);
self.collectClosureShapes(lam.body);
},
.block => |b| for (b.stmts) |s| self.collectClosureShapes(s),
.if_expr => |ie| {
self.collectClosureShapes(ie.condition);
self.collectClosureShapes(ie.then_branch);
if (ie.else_branch) |eb| self.collectClosureShapes(eb);
},
.while_expr => |w| {
self.collectClosureShapes(w.condition);
self.collectClosureShapes(w.body);
},
.for_expr => |f| {
self.collectClosureShapes(f.iterable);
if (f.range_end) |re| self.collectClosureShapes(re);
self.collectClosureShapes(f.body);
},
.return_stmt => |r| if (r.value) |v| self.collectClosureShapes(v),
.raise_stmt => |rs| self.collectClosureShapes(rs.tag),
.var_decl => |v| if (v.value) |val| self.collectClosureShapes(val),
.const_decl => |c| self.collectClosureShapes(c.value),
.destructure_decl => |d| self.collectClosureShapes(d.value),
.assignment => |a| {
self.collectClosureShapes(a.target);
self.collectClosureShapes(a.value);
},
.multi_assign => |m| {
for (m.targets) |t| self.collectClosureShapes(t);
for (m.values) |v| self.collectClosureShapes(v);
},
.call => |c| {
self.collectClosureShapes(c.callee);
for (c.args) |a| self.collectClosureShapes(a);
},
.binary_op => |b| {
self.collectClosureShapes(b.lhs);
self.collectClosureShapes(b.rhs);
},
.unary_op => |u| self.collectClosureShapes(u.operand),
.deref_expr => |d| self.collectClosureShapes(d.operand),
.force_unwrap => |fu| self.collectClosureShapes(fu.operand),
.null_coalesce => |nc| {
self.collectClosureShapes(nc.lhs);
self.collectClosureShapes(nc.rhs);
},
.field_access => |fa| self.collectClosureShapes(fa.object),
.index_expr => |ix| {
self.collectClosureShapes(ix.object);
self.collectClosureShapes(ix.index);
},
.spread_expr => |s| self.collectClosureShapes(s.operand),
.try_expr => |te| self.collectClosureShapes(te.operand),
.catch_expr => |ce| {
self.collectClosureShapes(ce.operand);
self.collectClosureShapes(ce.body);
},
.defer_stmt => |d| self.collectClosureShapes(d.expr),
.push_stmt => |p| {
self.collectClosureShapes(p.context_expr);
self.collectClosureShapes(p.body);
},
.array_literal => |al| for (al.elements) |el| self.collectClosureShapes(el),
.tuple_literal => |tl| for (tl.elements) |el| self.collectClosureShapes(el.value),
else => {},
}
self.errorAnalysis().convergeClosureShapeSets();
}
/// Record one closure literal's contribution to its value-signature shape's
/// inferred-`!` union. No-op unless the literal is a CONCRETE (non-generic)
/// bare-`!` failable closure; named-set / non-failable literals add no tags.
fn recordClosureShape(self: *Lowering, lam: *const ast.Lambda) void {
pub fn recordClosureShape(self: *Lowering, lam: *const ast.Lambda) void {
if (lam.type_params.len > 0) return; // generic shapes out of scope (sub-feature 8)
const rt_node = lam.return_type orelse return; // no annotation → non-failable infer
const ret = self.resolveType(rt_node);
@@ -15813,7 +15597,7 @@ pub const Lowering = struct {
defer tags.deinit(self.alloc);
var edges = std.ArrayList([]const u8).empty;
defer edges.deinit(self.alloc);
self.collectErrorSites(lam.body, &tags, &edges);
self.errorAnalysis().collectErrorSites(lam.body, &tags, &edges);
for (edges.items) |callee| {
for (self.calleeEscapeTags(callee)) |t| {
if (!containsTag(tags.items, t)) tags.append(self.alloc, t) catch {};