ERR/E5.1: program-wide inferred-! union per closure/fn shape

All occurrences of Closure(<sig>) -> (T, !) with a structurally identical
value-signature now share one inferred error-set node; every bare-!
closure literal of that shape unions its escape tags in, and a
`try slot(x)` against any matching-shape slot widens the caller's named
set against that union. This closes the gap where a slot call (no static
function name) skipped the widening check entirely.

- shape_inferred_sets keyed by closureShapeKey (params + value-return via
  mangleTypeName, error slot excluded) so bare-!, non-failable, .function
  and .closure of one value-sig collapse to a single key.
- convergeClosureShapeSets pre-pass (lowerRoot Pass 1d', after the
  name-keyed convergeInferredErrorSets): collectClosureShapes walks fn
  bodies through lambda boundaries; recordClosureShape resolves each
  concrete bare-! literal's shape and unions its raises (+ try named_fn()
  edges via calleeEscapeTags) into the shape node.
- checkEscapeWidening falls back to shapeKeyOfCallee for bare-! slot calls
  (computed from the callee expr's .function/.closure type). Empty union
  is silently allowed (sub-feature 6).

Scope: concrete shapes only (generic lambdas skipped); closure-to-closure
try edges are not fix-pointed (under-approximation = a missed diagnostic,
never a miscompile).

Tests: 1041 (positive — union composes, runs), 1042 (reject — two
widening diagnostics, exit 1).
This commit is contained in:
agra
2026-06-01 22:01:38 +03:00
parent 0e1afa3eba
commit 39c21468ee
9 changed files with 299 additions and 7 deletions

View File

@@ -0,0 +1,36 @@
// Program-wide inferred-`!` union per closure shape (ERR E5.1 sub-feature 2).
// All occurrences of `Closure(s32) -> (s32, !)` share ONE inferred error set;
// every bare-`!` closure literal of that shape unions its raised tags in. A
// `try slot(x)` against any matching-shape slot widens against that union — so
// a caller whose named set covers { Negative, Other } type-checks, and the
// error channel actually carries each closure's own tag at runtime.
#import "modules/std.sx";
All :: error { Negative, Other }
// `h` is a bare-`!` Closure slot; the caller declares the union as `!All`.
dispatch :: (h: Closure(s32) -> (s32, !), x: s32) -> (s32, !All) {
return try h(x);
}
main :: () -> s32 {
gpa := GPA.init();
push Context.{ allocator = xx gpa } {
// Two literals of the SAME shape raising DIFFERENT tags both feed the
// one shared `Closure(s32)->(s32,!)` union node.
handlers : List(Closure(s32) -> (s32, !)) = .{};
handlers.append(closure((x: s32) -> (s32, !) { if x < 0 { raise error.Negative; } return x * 2; }));
handlers.append(closure((x: s32) -> (s32, !) { if x == 0 { raise error.Other; } return x + 100; }));
// success paths
print("ok0={}\n", dispatch(handlers.items[0], 5) catch e 0); // 10
print("ok1={}\n", dispatch(handlers.items[1], 7) catch e 0); // 107
// failure paths: each closure raises its own tag, which propagates
// through `try` and is absorbed by the call-site `catch` fallback
print("err0={}\n", dispatch(handlers.items[0], -1) catch e -1); // raised Negative → -1
print("err1={}\n", dispatch(handlers.items[1], 0) catch e -2); // raised Other → -2
}
return 0;
}

View File

@@ -0,0 +1,25 @@
// Program-wide closure-shape union — widening REJECTION (ERR E5.1 sub-feature 2).
// Two closure literals of shape `Closure(s32)->(s32,!)` raise `Negative` /
// `Other`; the shared inferred-`!` node for that shape is { Negative, Other }.
// A caller that `try`s a slot of this shape but declares only `!Small` (which
// omits both tags) is rejected — the union is checked against the caller's set
// even though the call goes through a slot with no static function name.
#import "modules/std.sx";
Small :: error { Unrelated }
reject :: (h: Closure(s32) -> (s32, !), x: s32) -> (s32, !Small) {
return try h(x); // Negative, Other ∉ Small → two diagnostics
}
main :: () -> s32 {
gpa := GPA.init();
push Context.{ allocator = xx gpa } {
handlers : List(Closure(s32) -> (s32, !)) = .{};
handlers.append(closure((x: s32) -> (s32, !) { if x < 0 { raise error.Negative; } return x; }));
handlers.append(closure((x: s32) -> (s32, !) { if x == 0 { raise error.Other; } return x; }));
print("r={}\n", reject(handlers.items[0], 5) catch e 0);
}
return 0;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,4 @@
ok0=10
ok1=107
err0=-1
err1=-2

View File

@@ -0,0 +1,11 @@
error: error tag 'error.Negative' is not in caller's error set 'Small'
--> /Users/agra/projects/sx/examples/1042-errors-failable-closure-shape-union-reject.sx:13:12
|
13 | return try h(x); // Negative, Other ∉ Small → two diagnostics
| ^^^^^^^^
error: error tag 'error.Other' is not in caller's error set 'Small'
--> /Users/agra/projects/sx/examples/1042-errors-failable-closure-shape-union-reject.sx:13:12
|
13 | return try h(x); // Negative, Other ∉ Small → two diagnostics
| ^^^^^^^^

View File

@@ -215,6 +215,13 @@ pub const Lowering = struct {
/// 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),
/// Whole-program-converged inferred error sets keyed by closure/function
/// VALUE-signature shape (ERR E5.1 sub-feature 2): every occurrence of
/// `Closure(<sig>) -> (T, !)` with a structurally identical value-signature
/// shares one node; each bare-`!` closure literal of that shape unions its
/// escape tags in. Read by `checkEscapeWidening` when a `try` operand is a
/// closure/fn-type SLOT call (no static fn name). Key = `closureShapeKey`.
shape_inferred_sets: std.StringHashMap([]const u32) = std.StringHashMap([]const u32).init(std.heap.page_allocator),
pub const ComptimeValue = union(enum) {
int_val: i64,
@@ -347,6 +354,12 @@ pub const Lowering = struct {
// named-caller widening sees each bare-`!` callee's converged set; also
// emits the empty-inferred warning.
self.convergeInferredErrorSets();
// Pass 1d': converge inferred (`bare !`) error sets per closure/fn-type
// SHAPE (ERR E5.1 sub-feature 2). Runs after the name-keyed pass so a
// closure's `try named_fn()` edge resolves against the converged
// top-level sets; before body lowering so `try slot(x)` widening sees
// the full per-shape union.
self.convergeClosureShapeSets();
// 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
@@ -16187,14 +16200,25 @@ pub const Lowering = struct {
/// Shared by `try` propagation and a failable `or` chain's final operand.
fn checkEscapeWidening(self: *Lowering, callee_node: *const Node, callee_set: TypeId, caller_set: TypeId, span: ast.Span) void {
if (self.isInferredErrorSet(caller_set)) return;
if (self.isInferredErrorSet(callee_set)) {
if (callTargetName(callee_node)) |nm| {
if (self.inferred_error_sets.get(nm)) |tags| {
self.diagTagsNotInSet(tags, caller_set, span);
}
}
} else {
if (!self.isInferredErrorSet(callee_set)) {
self.checkErrorSetSubset(callee_set, caller_set, span);
return;
}
// Bare-`!` callee: either a named top-level function (its converged set
// is name-keyed) or a closure/fn-type SLOT (its set is shape-keyed,
// shared program-wide by value-signature).
if (callTargetName(callee_node)) |nm| {
if (self.inferred_error_sets.get(nm)) |tags| {
self.diagTagsNotInSet(tags, caller_set, span);
return;
}
}
if (self.shapeKeyOfCallee(callee_node)) |key| {
if (self.shape_inferred_sets.get(key)) |tags| {
self.diagTagsNotInSet(tags, caller_set, span);
}
// Empty union (no closure of this shape ever raises) → silently
// allowed: the slot's `!` resolves to ∅ (ERR E5.1 sub-feature 6).
}
}
@@ -16576,6 +16600,194 @@ pub const Lowering = struct {
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.
pub fn convergeClosureShapeSets(self: *Lowering) void {
var it = self.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 => {},
}
}
/// 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 {
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);
const es = self.errorChannelOf(ret) orelse return; // not failable
if (!self.isInferredErrorSet(es)) return; // `!Named` → its own set, not the inferred union
var ptys = std.ArrayList(TypeId).empty;
defer ptys.deinit(self.alloc);
for (lam.params) |p| {
if (p.is_variadic or p.is_pack or p.is_comptime) return; // not a plain fn-type slot
ptys.append(self.alloc, self.resolveType(p.type_expr)) catch return;
}
const key = self.closureShapeKey(ptys.items, self.returnValuePart(ret));
var tags = std.ArrayList(u32).empty;
defer tags.deinit(self.alloc);
var edges = std.ArrayList([]const u8).empty;
defer edges.deinit(self.alloc);
self.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 {};
}
}
self.unionShapeTags(key, tags.items);
}
/// The escape tags of a callee referenced by name from a `try g()` edge:
/// a bare-`!` callee's converged set, or a `-> !Named` callee's declared set.
fn calleeEscapeTags(self: *Lowering, callee: []const u8) []const u32 {
if (self.inferred_error_sets.get(callee)) |t| return t;
if (self.fn_ast_map.get(callee)) |cfd| {
if (astPureNamedSet(cfd.return_type)) |nm| return self.namedSetTags(nm) orelse &.{};
}
return &.{};
}
/// Merge `new_tags` into the shape node `key` (sorted, deduped). The map is
/// content-keyed (StringHashMap), so re-`put` with a fresh equal key string
/// overwrites the existing node's value in place.
fn unionShapeTags(self: *Lowering, key: []const u8, new_tags: []const u32) void {
var list = std.ArrayList(u32).empty;
defer list.deinit(self.alloc);
if (self.shape_inferred_sets.get(key)) |existing| list.appendSlice(self.alloc, existing) catch {};
for (new_tags) |t| {
if (!containsTag(list.items, t)) list.append(self.alloc, t) catch {};
}
const sorted = self.alloc.dupe(u32, list.items) catch return;
std.mem.sort(u32, sorted, {}, std.sort.asc(u32));
self.shape_inferred_sets.put(key, sorted) catch {};
}
/// Canonical key for a callable VALUE-signature: param types + the value
/// part of the return (error slot excluded). Bare-`!` and non-failable
/// shapes of the same value-sig — and `.function` vs `.closure` of that
/// sig — collapse to one key, so all occurrences share one inferred node.
fn closureShapeKey(self: *Lowering, params: []const TypeId, value_ret: TypeId) []const u8 {
var buf = std.ArrayList(u8).empty;
buf.appendSlice(self.alloc, "shape") catch return "shape";
for (params) |p| {
buf.append(self.alloc, '_') catch return "shape";
buf.appendSlice(self.alloc, self.mangleTypeName(p)) catch return "shape";
}
buf.appendSlice(self.alloc, "__") catch return "shape";
buf.appendSlice(self.alloc, self.mangleTypeName(value_ret)) catch return "shape";
return buf.items;
}
/// The value part of a (possibly failable) return type, error slot dropped:
/// `(T, !)` → T (or a value-tuple); pure `-> !` → void; non-failable → self.
fn returnValuePart(self: *Lowering, ret: TypeId) TypeId {
const es = self.errorChannelOf(ret) orelse return ret;
if (ret == es) return .void;
return self.failableSuccessType(ret);
}
/// Shape key of a call's callee expression when it's a closure/fn-type slot
/// (variable, field, index — anything with a `.closure`/`.function` type),
/// for the program-wide shape-union widening lookup. Null for non-callables.
fn shapeKeyOfCallee(self: *Lowering, node: *const Node) ?[]const u8 {
if (node.data != .call) return null;
const fty = self.inferExprType(node.data.call.callee);
if (fty.isBuiltin()) return null;
const info = self.module.types.get(fty);
const params: []const TypeId = switch (info) {
.closure => |c| c.params,
.function => |f| f.params,
else => return null,
};
const ret: TypeId = switch (info) {
.closure => |c| c.ret,
.function => |f| f.ret,
else => return null,
};
return self.closureShapeKey(params, self.returnValuePart(ret));
}
fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 {
return switch (op) {
.add => "+",