From 667192c71826856d51d9edbc8e9f738d649baa1a Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 2 Jun 2026 23:11:18 +0300 Subject: [PATCH] refactor(ir): extract ErrorAnalysis (error_analysis.zig) for error-set convergence (A5.1 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ir/error_analysis.test.zig | 95 ++++++++++++ src/ir/error_analysis.zig | 272 +++++++++++++++++++++++++++++++++ src/ir/ir.zig | 4 + src/ir/lower.test.zig | 92 ----------- src/ir/lower.zig | 260 +++---------------------------- 5 files changed, 393 insertions(+), 330 deletions(-) create mode 100644 src/ir/error_analysis.test.zig create mode 100644 src/ir/error_analysis.zig diff --git a/src/ir/error_analysis.test.zig b/src/ir/error_analysis.test.zig new file mode 100644 index 0000000..62ff795 --- /dev/null +++ b/src/ir/error_analysis.test.zig @@ -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]); +} diff --git a/src/ir/error_analysis.zig b/src/ir/error_analysis.zig new file mode 100644 index 0000000..1540187 --- /dev/null +++ b/src/ir/error_analysis.zig @@ -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 => {}, + } + } +}; diff --git a/src/ir/ir.zig b/src/ir/ir.zig index b43c508..d20b868 100644 --- a/src/ir/ir.zig +++ b/src/ir/ir.zig @@ -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"); diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index f57af99..5cba51e 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -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]); -} diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 9e0da23..316146b 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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 {};