diff --git a/examples/223-inferred-error-sets.sx b/examples/223-inferred-error-sets.sx new file mode 100644 index 0000000..32487b6 --- /dev/null +++ b/examples/223-inferred-error-sets.sx @@ -0,0 +1,40 @@ +// Whole-program inferred error sets (ERR step E1.4b). A bare `-> !` function's +// error set is INFERRED: the union of the tags it raises directly plus the +// sets of the failable functions it `try`s, converged across the whole call +// graph by a fix-point pass. Here `leaf` raises {Foo}; `mid` try-propagates +// leaf AND raises Bar, so `mid` converges to {Foo, Bar}; the named caller +// `run :: -> !A` then type-checks because mid's converged set is a subset of +// A. The rejection (a converged tag NOT in the caller's set) lives in +// `examples/224-inferred-widening-reject.sx`. + +#import "modules/std.sx"; + +A :: error { Foo, Bar } + +leaf :: (n: s32) -> ! { + if n < 0 { raise error.Foo; } + return; +} + +// Inferred set converges to {Foo, Bar}: {Foo} absorbed from `try leaf` plus +// the directly-raised Bar. +mid :: (n: s32) -> ! { + try leaf(n); + if n == 100 { raise error.Bar; } + return; +} + +// Named caller: mid's converged {Foo, Bar} is a subset of A -> widening OK. +run :: (n: s32) -> !A { + try mid(n); + return; +} + +main :: () -> s32 { + e := run(-1); // leaf raises Foo -> propagates out + r : s32 = 0; + if e == error.Foo { r = r + 7; } // true -> +7 + if e == error.Bar { r = r + 1; } // false (Foo escaped, not Bar) + print("inferred result: {}\n", r); // -> 7 + return r; +} diff --git a/examples/224-inferred-widening-reject.sx b/examples/224-inferred-widening-reject.sx new file mode 100644 index 0000000..8ab3f46 --- /dev/null +++ b/examples/224-inferred-widening-reject.sx @@ -0,0 +1,30 @@ +// Inferred-set widening rejection (ERR step E1.4b). When a named caller +// (`-> !A`) `try`s a bare-`!` callee, the callee's WHOLE-PROGRAM-CONVERGED +// inferred set must be a subset of A. Before the SCC pass this was a +// false-negative (the bare-`!` placeholder was empty, so the check trivially +// passed); now the converged tags are checked. `deep`'s converged set is +// {Foo} (raised transitively through `via`), which is not in A = {Bar}. +// The positive case lives in `examples/223-inferred-error-sets.sx`. + +#import "modules/std.sx"; + +A :: error { Bar } + +deep :: () -> ! { + raise error.Foo; // deep's inferred set = {Foo} +} + +via :: () -> ! { + try deep(); // via absorbs {Foo} + return; +} + +caller :: () -> !A { + try via(); // error: Foo (via's converged set) not in A + return; +} + +main :: () -> s32 { + e := caller(); + return 0; +} diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 2199a92..cf8f1fd 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -650,3 +650,63 @@ test "pack projection: same-name type-arg + method warns (Decision 4)" { try std.testing.expectEqual(Lowering.PackProjection{ .type_arg = 0 }, lowering.resolvePackProjection("Shadowy", "value", .type_position)); try std.testing.expectEqual(Lowering.PackProjection{ .method = 0 }, lowering.resolvePackProjection("Shadowy", "value", .value_position)); } + +test "E1.4b converge inferred error sets: empty -> warning, raising -> converged set" { + // The empty-inferred warning isn't user-visible yet (the compile driver + // only renders diagnostics on failure — a LANG follow-up), so validate the + // SCC's emission + set computation directly on the DiagnosticList. + 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 diags = errors.DiagnosticList.init(alloc, "", "test.sx"); + defer diags.deinit(); + + var lowering = Lowering.init(&module); + lowering.diagnostics = &diags; + + // stub :: () -> ! { return; } — bare `!`, never raises. + const stub_rt = alloc.create(Node) catch unreachable; + stub_rt.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .error_type_expr = .{ .name = null } } }; + const stub_ret = alloc.create(Node) catch unreachable; + stub_ret.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .return_stmt = .{ .value = null } } }; + const stub_body = alloc.create(Node) catch unreachable; + const stub_stmts: []const *Node = &.{stub_ret}; + stub_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = stub_stmts } } }; + const stub_fd = ast.FnDecl{ .name = "stub", .params = &.{}, .return_type = stub_rt, .body = stub_body }; + + // raiser :: () -> ! { raise error.Foo; } — bare `!`, raises Foo. + const r_rt = alloc.create(Node) catch unreachable; + r_rt.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .error_type_expr = .{ .name = null } } }; + const r_err = alloc.create(Node) catch unreachable; + r_err.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = "error" } } }; + const r_fa = alloc.create(Node) catch unreachable; + r_fa.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .field_access = .{ .object = r_err, .field = "Foo" } } }; + const r_raise = alloc.create(Node) catch unreachable; + r_raise.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .raise_stmt = .{ .tag = r_fa } } }; + const r_body = alloc.create(Node) catch unreachable; + const r_stmts: []const *Node = &.{r_raise}; + r_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = r_stmts } } }; + const raiser_fd = ast.FnDecl{ .name = "raiser", .params = &.{}, .return_type = r_rt, .body = r_body }; + + lowering.fn_ast_map.put("stub", &stub_fd) catch unreachable; + lowering.fn_ast_map.put("raiser", &raiser_fd) catch unreachable; + + lowering.convergeInferredErrorSets(); + + // raiser converges to {Foo} (non-empty); stub to ∅. + try std.testing.expectEqual(@as(usize, 1), (lowering.inferred_error_sets.get("raiser") orelse unreachable).len); + try std.testing.expectEqual(@as(usize, 0), (lowering.inferred_error_sets.get("stub") orelse unreachable).len); + + // The empty-set (stub) warns; the raising one does not. + var stub_warned = false; + var raiser_warned = false; + for (diags.items.items) |d| { + if (d.level != .warn) continue; + if (std.mem.indexOf(u8, d.message, "stub") != null) stub_warned = true; + if (std.mem.indexOf(u8, d.message, "raiser") != null) raiser_warned = true; + } + try std.testing.expect(stub_warned); + try std.testing.expect(!raiser_warned); +} diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 9affa08..2823a75 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -194,6 +194,13 @@ pub const Lowering = struct { comptime_constants: std.StringHashMap(ComptimeValue) = std.StringHashMap(ComptimeValue).init(std.heap.page_allocator), // compile-time known constants (e.g. OS, ARCH) diagnostics: ?*errors.DiagnosticList = null, // error reporting with source locations xx_reentrancy: std.AutoHashMap(u64, void) = std.AutoHashMap(u64, void).init(std.heap.page_allocator), // (src_ty, dst_ty) pairs currently being resolved through user-space Into; prevents infinite monomorphisation when a convert body re-enters the same xx + /// Whole-program-converged inferred error sets (ERR E1.4b): top-level + /// bare-`!` function name → its sorted escape-tag ids (literal raises + + /// pure-failable `try` edges, fix-pointed across the call graph). The + /// shared `!` placeholder TypeId stays empty; this side map holds the real + /// per-function sets (sidesteps the name-only error-set interning). Read by + /// `lowerTry`'s named-caller widening and the empty-inferred warning. + inferred_error_sets: std.StringHashMap([]const u32) = std.StringHashMap([]const u32).init(std.heap.page_allocator), pub const ComptimeValue = union(enum) { int_val: i64, @@ -315,6 +322,11 @@ pub const Lowering = struct { // entry. Only fires when the program imports `std.sx` (so Context + // Allocator + CAllocator are all registered). self.emitDefaultContextGlobal(); + // Pass 1d: converge inferred (`bare !`) error sets across the whole + // program (ERR E1.4b). Runs before body lowering so `lowerTry`'s + // named-caller widening sees each bare-`!` callee's converged set; also + // emits the empty-inferred warning. + self.convergeInferredErrorSets(); // Pass 2: lower main (and comptime side-effects) self.lowerMainAndComptime(decls); // Pass 3: lower deferred functions (any_to_string etc.) now that all types are registered @@ -2597,7 +2609,7 @@ pub const Lowering = struct { // mis-lower it through the optional-unwrap path below. if (self.exprIsFailable(bop.lhs)) { if (self.diagnostics) |diags| { - diags.addFmt(.err, bop.lhs.span, "failable `or` (chain / value terminator) is not yet lowered — pending ERR E1.4b; for now use a single `try` or a `catch`", .{}); + diags.addFmt(.err, bop.lhs.span, "failable `or` (chain / value terminator) is not yet lowered — pending ERR E2.4; for now use a single `try` or a `catch`", .{}); } return self.builder.constInt(0, .void); } @@ -15012,11 +15024,21 @@ pub const Lowering = struct { /// Diagnose every tag of `src` that is not also a member of `dst` (the /// enclosing function's named error set). Both must be `.error_set` types. fn checkErrorSetSubset(self: *Lowering, src: TypeId, dst: TypeId, span: ast.Span) void { - if (src.isBuiltin() or dst.isBuiltin()) return; + if (src.isBuiltin()) return; const src_info = self.module.types.get(src); + if (src_info != .error_set) return; + self.diagTagsNotInSet(src_info.error_set.tags, dst, span); + } + + /// Diagnose every tag id in `src_tags` that is not a member of the named + /// error set `dst`. Shared by the named-set subset check and E1.4b's + /// inferred-callee widening (where the callee's tags come from the SCC, + /// not a `.error_set` TypeId). + fn diagTagsNotInSet(self: *Lowering, src_tags: []const u32, dst: TypeId, span: ast.Span) void { + if (dst.isBuiltin()) return; const dst_info = self.module.types.get(dst); - if (src_info != .error_set or dst_info != .error_set) return; - for (src_info.error_set.tags) |tag| { + if (dst_info != .error_set) return; + for (src_tags) |tag| { var found = false; for (dst_info.error_set.tags) |d| { if (d == tag) { @@ -15146,7 +15168,18 @@ pub const Lowering = struct { // set. For an inferred caller (`!`) the absorption happens in the // whole-program SCC (E1.4b) — no check here. if (!self.isInferredErrorSet(caller_set)) { - self.checkErrorSetSubset(callee_set, caller_set, span); + if (self.isInferredErrorSet(callee_set)) { + // Bare-`!` callee: its escape set is the SCC-converged set + // (the empty placeholder TypeId carries no tags), so check the + // converged tags from the whole-program pass. + if (callTargetName(operand)) |nm| { + if (self.inferred_error_sets.get(nm)) |tags| { + self.diagTagsNotInSet(tags, caller_set, span); + } + } + } else { + self.checkErrorSetSubset(callee_set, caller_set, span); + } } // (4) Lower: evaluate the call (→ the error tag, 0 = success), branch. @@ -15179,11 +15212,202 @@ pub const Lowering = struct { fn bailTry(self: *Lowering, span: ast.Span, comptime what: []const u8) Ref { if (self.diagnostics) |diags| { - diags.addFmt(.err, span, "`try` with " ++ what ++ " is not yet lowered — pending the error-channel tuple ABI / fallback routing (ERR E1.4b/E2)", .{}); + diags.addFmt(.err, span, "`try` with " ++ what ++ " is not yet lowered — pending the error-channel tuple ABI (ERR E2)", .{}); } return self.builder.constInt(0, .void); } + // ── ERR E1.4b: whole-program inferred-error-set convergence ────────── + + /// The bare callee name of a call expression (`g(...)` → "g"), or null if + /// 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 { + if (node.data != .call) return null; + const callee = node.data.call.callee; + return if (callee.data == .identifier) callee.data.identifier.name else null; + } + + /// 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 { + 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 { + const n = rt orelse return null; + if (n.data != .error_type_expr) return null; + return n.data.error_type_expr.name; + } + + /// 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 { + const sid = self.module.types.internString(name); + const tid = self.module.types.findByName(sid) orelse return null; + if (tid.isBuiltin()) return null; + const info = self.module.types.get(tid); + 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). + 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.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.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.*}); + } + } + } + } + } + + fn containsTag(tags: []const u32, t: u32) bool { + for (tags) |x| if (x == t) return true; + return false; + } + fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 { return switch (op) { .add => "+", diff --git a/tests/expected/223-inferred-error-sets.exit b/tests/expected/223-inferred-error-sets.exit new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/tests/expected/223-inferred-error-sets.exit @@ -0,0 +1 @@ +7 diff --git a/tests/expected/223-inferred-error-sets.txt b/tests/expected/223-inferred-error-sets.txt new file mode 100644 index 0000000..1f9c426 --- /dev/null +++ b/tests/expected/223-inferred-error-sets.txt @@ -0,0 +1 @@ +inferred result: 7 diff --git a/tests/expected/224-inferred-widening-reject.exit b/tests/expected/224-inferred-widening-reject.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/expected/224-inferred-widening-reject.exit @@ -0,0 +1 @@ +1 diff --git a/tests/expected/224-inferred-widening-reject.txt b/tests/expected/224-inferred-widening-reject.txt new file mode 100644 index 0000000..f55d616 --- /dev/null +++ b/tests/expected/224-inferred-widening-reject.txt @@ -0,0 +1,5 @@ +error: error tag 'error.Foo' is not in caller's error set 'A' + --> /Users/agra/projects/sx/examples/224-inferred-widening-reject.sx:23:5 + | +23 | try via(); // error: Foo (via's converged set) not in A + | ^^^^^^^^^