ERR/E1.4b: whole-program inferred error sets + empty-inferred warning
The type-convergence side of E1.4 (the SCC slice). A bare `-> !` function's error set is now converged whole-program from its literal raises plus the sets of the pure-failable functions it `try`s. - convergeInferredErrorSets: a pre-lowering fix-point pass (lowerRoot Pass 1d, after scanDecls / before body lowering) that walks each top-level bare-`!` function's body AST (collectErrorSites, stopping at nested-fn boundaries) for literal `raise error.X` tags + pure `try g()` edges, then unions each set with its edges' sets until stable. Stored in a side map `inferred_error_sets` (fn name -> sorted []u32) — sidesteps the name-only error-set interning collision (the shared `!` placeholder stays empty). - lowerTry widening: a named caller `try`-ing a bare-`!` callee now checks the callee's converged set (previously a false-negative — the empty placeholder was trivially a subset). Factored diagTagsNotInSet out of checkErrorSetSubset. - empty-inferred warning: a top-level non-main bare-`!` function with an empty converged set warns. Not user-visible yet (the compile driver renders diagnostics only on failure — a LANG follow-up), so unit-tested on the DiagnosticList. - corrected two now-stale bail messages (failable-`or` -> E2.4; value-carrying `try` -> E2). Deferred to E2.4: failable-`or` chains / value-terminators (and `try` fallback routing) — gated on the value-carrying tuple ABI. Tests: examples/223-inferred-error-sets.sx (transitive convergence + widening passes, exit 7), examples/224-inferred-widening-reject.sx (transitive widening rejection, exit 1), unit test in lower.test.zig. Gates: zig build, zig build test, 262/262 examples.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
236
src/ir/lower.zig
236
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 => "+",
|
||||
|
||||
Reference in New Issue
Block a user