fix: gate implicit optional unwrap on flow narrowing (issue 0179)
Optional (?T) operands were implicitly unwrapped without proof of
presence, silently miscompiling a NULL ?T to garbage. Unwraps in
binary ops and other expression positions are now gated on flow
narrowing: a ?T value is only auto-unwrapped where control flow has
established it is non-null (the narrowed_refs set). Outside a narrowed
region, an implicit unwrap is rejected rather than producing garbage.
Touches the lowering pipeline (lower.zig + lower/{call,closure,coerce,
comptime,control_flow,expr,ffi,generic,pack,stmt}.zig). Adds optionals
examples 0919-0923 and closures example 0312 covering flow narrowing,
binop narrowing, no-implicit-unwrap rejection, and no closure leak of
narrowed state. Updates specs.md and readme.md.
This commit is contained in:
@@ -284,6 +284,19 @@ pub const Lowering = struct {
|
||||
current_runtime_method: ?ast.RuntimeMethodDecl = null, // the specific method whose body is being lowered; `super.<same_name>(...)` reuses its signature
|
||||
type_bindings: ?std.StringHashMap(TypeId) = null, // generic type param bindings ($T → concrete TypeId)
|
||||
current_match_tags: ?[]const u64 = null, // type tags for current match arm (for runtime dispatch)
|
||||
/// Flow-sensitive narrowing (issue 0179). The set of local variable names
|
||||
/// currently PROVEN present (`?T` known to carry a value) by a `!= null`
|
||||
/// guard / branch. Region-scoped: `lowerBlock` snapshots+restores it, the
|
||||
/// if-then branch narrows on `!= null`, a divergent `== null` guard narrows
|
||||
/// the rest of the enclosing block, and an assignment kills the name's
|
||||
/// narrowing. Consulted at the implicit `?T → concrete` unwrap (`coerceMode`):
|
||||
/// a non-narrowed unwrap is REJECTED instead of silently yielding the zero
|
||||
/// payload of a null optional.
|
||||
narrowed: std.StringHashMap(void) = undefined,
|
||||
/// The SSA `Ref`s produced by lowering a narrowed identifier — the bridge
|
||||
/// from name-keyed narrowing to the Ref-keyed `coerceMode` unwrap gate.
|
||||
/// Cleared per function body (the `Ref` space is per-function).
|
||||
narrowed_refs: std.AutoHashMap(Ref, void) = undefined,
|
||||
force_block_value: bool = false, // set by lowerBlockValue to extract if-else values
|
||||
block_terminated: bool = false, // set when constant-folded if emits a return/br into current block
|
||||
in_lambda_body: bool = false, // true while lowering a closure-literal body; sharpens the `raise`-not-failable diagnostic (ERR E5.1: tell the user to annotate `-> (T, !)`)
|
||||
@@ -474,6 +487,8 @@ pub const Lowering = struct {
|
||||
pack_param_count: ?std.StringHashMap(u32),
|
||||
pack_arg_types: ?std.StringHashMap([]const TypeId),
|
||||
inline_return_target: ?InlineReturnInfo,
|
||||
narrowed: std.StringHashMap(void),
|
||||
narrowed_refs: std.AutoHashMap(Ref, void),
|
||||
|
||||
pub fn enter(l: *Lowering) FnBodyReentry {
|
||||
const g = FnBodyReentry{
|
||||
@@ -491,7 +506,14 @@ pub const Lowering = struct {
|
||||
.pack_param_count = l.pack_param_count,
|
||||
.pack_arg_types = l.pack_arg_types,
|
||||
.inline_return_target = l.inline_return_target,
|
||||
// Flow narrowing is lexical to one function body — a nested
|
||||
// (closure / local-fn) lowering starts with a fresh, empty
|
||||
// narrowing state and the outer state is restored after.
|
||||
.narrowed = l.narrowed,
|
||||
.narrowed_refs = l.narrowed_refs,
|
||||
};
|
||||
l.narrowed = std.StringHashMap(void).init(l.alloc);
|
||||
l.narrowed_refs = std.AutoHashMap(Ref, void).init(l.alloc);
|
||||
// The `#jni_env` Ref stack is lexical to ONE function's instruction
|
||||
// stream; move the visible base to the current top. Pack-fn mono
|
||||
// state is likewise lexical to the pack-fn body — null it so a
|
||||
@@ -523,6 +545,38 @@ pub const Lowering = struct {
|
||||
l.pack_param_count = g.pack_param_count;
|
||||
l.pack_arg_types = g.pack_arg_types;
|
||||
l.inline_return_target = g.inline_return_target;
|
||||
l.narrowed.deinit();
|
||||
l.narrowed = g.narrowed;
|
||||
l.narrowed_refs.deinit();
|
||||
l.narrowed_refs = g.narrowed_refs;
|
||||
}
|
||||
};
|
||||
|
||||
/// Save + clear + restore JUST the flow-narrowing state (issue 0179) around
|
||||
/// a nested body lowering that does NOT go through `FnBodyReentry` —
|
||||
/// closure literals, generic/pack/comptime monomorphization. Each lowers a
|
||||
/// SEPARATE function whose `Ref` space (reset by `beginFunction`) OVERLAPS
|
||||
/// the outer function's, so the outer `narrowed_refs` indices would falsely
|
||||
/// match the nested body's `Ref`s and permit an UNSOUND unwrap of a
|
||||
/// non-present optional. Clearing on entry isolates the nested body (it
|
||||
/// builds its own narrowing from scratch); restore re-arms the outer.
|
||||
pub const NarrowGuard = struct {
|
||||
l: *Lowering,
|
||||
narrowed: std.StringHashMap(void),
|
||||
narrowed_refs: std.AutoHashMap(Ref, void),
|
||||
|
||||
pub fn enter(l: *Lowering) NarrowGuard {
|
||||
const g = NarrowGuard{ .l = l, .narrowed = l.narrowed, .narrowed_refs = l.narrowed_refs };
|
||||
l.narrowed = std.StringHashMap(void).init(l.alloc);
|
||||
l.narrowed_refs = std.AutoHashMap(Ref, void).init(l.alloc);
|
||||
return g;
|
||||
}
|
||||
|
||||
pub fn restore(g: NarrowGuard) void {
|
||||
g.l.narrowed.deinit();
|
||||
g.l.narrowed = g.narrowed;
|
||||
g.l.narrowed_refs.deinit();
|
||||
g.l.narrowed_refs = g.narrowed_refs;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -548,6 +602,8 @@ pub const Lowering = struct {
|
||||
.struct_const_map = std.StringHashMap(StructConstInfo).init(module.alloc),
|
||||
.extern_name_map = std.StringHashMap([]const u8).init(module.alloc),
|
||||
.comptime_constants = std.StringHashMap(ComptimeValue).init(module.alloc),
|
||||
.narrowed = std.StringHashMap(void).init(module.alloc),
|
||||
.narrowed_refs = std.AutoHashMap(Ref, void).init(module.alloc),
|
||||
.xx_reentrancy = std.AutoHashMap(u64, void).init(module.alloc),
|
||||
.inferred_error_sets = std.StringHashMap([]const u32).init(module.alloc),
|
||||
.impl_method_names = std.StringHashMap(void).init(module.alloc),
|
||||
@@ -1685,6 +1741,13 @@ pub const Lowering = struct {
|
||||
|
||||
// --- moved to lower/control_flow.zig (lower_control_flow) ---
|
||||
pub const lowerIfExpr = lower_control_flow.lowerIfExpr;
|
||||
pub const narrowableLocal = lower_control_flow.narrowableLocal;
|
||||
pub const nullCmpName = lower_control_flow.nullCmpName;
|
||||
pub const collectPresentIfTrue = lower_control_flow.collectPresentIfTrue;
|
||||
pub const collectPresentIfFalse = lower_control_flow.collectPresentIfFalse;
|
||||
pub const narrowSnapshot = lower_control_flow.narrowSnapshot;
|
||||
pub const narrowRestore = lower_control_flow.narrowRestore;
|
||||
pub const applyNarrowing = lower_control_flow.applyNarrowing;
|
||||
pub const tryConstBoolCondition = lower_control_flow.tryConstBoolCondition;
|
||||
pub const lowerWhile = lower_control_flow.lowerWhile;
|
||||
pub const listView = lower_control_flow.listView;
|
||||
@@ -2008,6 +2071,7 @@ pub const Lowering = struct {
|
||||
pub const lowerTupleLiteral = lower_expr.lowerTupleLiteral;
|
||||
pub const lowerDerefExpr = lower_expr.lowerDerefExpr;
|
||||
pub const lowerForceUnwrap = lower_expr.lowerForceUnwrap;
|
||||
pub const diagOptionalOperand = lower_expr.diagOptionalOperand;
|
||||
pub const lowerNullCoalesce = lower_expr.lowerNullCoalesce;
|
||||
pub const resolveOptionalInner = lower_expr.resolveOptionalInner;
|
||||
pub const lowerExpr = lower_expr.lowerExpr;
|
||||
|
||||
@@ -563,6 +563,9 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
|
||||
const ty_info = self.module.types.get(binding.ty);
|
||||
if (ty_info == .closure) {
|
||||
const callee_ref = if (binding.is_alloca) self.builder.load(binding.ref, binding.ty) else binding.ref;
|
||||
// Coerce user args to the closure's param types
|
||||
// (issue 0186) — a `?T` param must wrap the arg.
|
||||
coerceClosureCallArgs(self, args.items, ty_info.closure.params);
|
||||
// Closure trampolines carry `__sx_ctx` at
|
||||
// slot 0; emit_llvm's `call_closure` builds
|
||||
// the call as [ctx, env, user_args], so we
|
||||
@@ -656,6 +659,17 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
|
||||
const bti = self.module.types.get(binding.ty);
|
||||
break :blk if (bti == .function) bti.function.ret else .i64;
|
||||
} else .i64;
|
||||
// Coerce user args to the fn-pointer's param types (issue
|
||||
// 0186) — same as the closure-value and global-fn-pointer
|
||||
// paths. The arg loop already applied implicit address-of
|
||||
// for `*T` params (resolveCallParamTypes now surfaces the
|
||||
// `.function` param types), so this completes value
|
||||
// coercions like a `?T` wrap. Without it a concrete arg to a
|
||||
// `?T` fn-ptr param reaches `call_indirect` unconverted.
|
||||
if (!binding.ty.isBuiltin()) {
|
||||
const bti = self.module.types.get(binding.ty);
|
||||
if (bti == .function) coerceClosureCallArgs(self, args.items, bti.function.params);
|
||||
}
|
||||
var final_args = std.ArrayList(Ref).empty;
|
||||
defer final_args.deinit(self.alloc);
|
||||
if (self.fnPtrTypeWantsCtx(binding.ty)) {
|
||||
@@ -965,6 +979,8 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
|
||||
agg = self.builder.load(obj, oi.pointer.pointee);
|
||||
}
|
||||
const closure_val = self.builder.structGet(agg, @intCast(fi), f.ty);
|
||||
// Coerce user args to the closure's param types (issue 0186).
|
||||
coerceClosureCallArgs(self, args.items, fti.closure.params);
|
||||
// Prepend ctx for sx-side closure call ABI.
|
||||
const owned = if (self.implicit_ctx_enabled) blk: {
|
||||
const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable;
|
||||
@@ -1368,6 +1384,8 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
|
||||
const cti = self.module.types.get(callee_ty);
|
||||
if (cti == .closure) {
|
||||
const callee_ref = self.lowerExpr(c.callee);
|
||||
// Coerce user args to the closure's param types (issue 0186).
|
||||
coerceClosureCallArgs(self, args.items, cti.closure.params);
|
||||
// Prepend implicit ctx for the sx-side closure call ABI
|
||||
// (emit_llvm builds the call as [ctx, env, user_args]).
|
||||
const owned = if (self.implicit_ctx_enabled) blk: {
|
||||
@@ -2635,6 +2653,22 @@ pub fn userParamTypes(self: *Lowering, func: *const Function) []TypeId {
|
||||
/// nothing as nominal names in that module: without this call's inferred
|
||||
/// `$T → concrete` bindings the pin would resolve `T` as an undeclared
|
||||
/// type in a non-main module and diagnose it unknown.
|
||||
/// Coerce already-lowered closure-call arguments to the closure's declared
|
||||
/// parameter types (issue 0186). The arg-lowering loop only sets `target_type`
|
||||
/// (which steers literal lowering) but does NOT itself coerce, so a concrete
|
||||
/// `7` flowing into a `?i64` param would reach `call_closure` as a bare `i64`
|
||||
/// (read ABSENT by the callee) and a `null` as a bare pointer (LLVM verifier
|
||||
/// failure). `args` are the USER args (no implicit ctx); `params` the closure's
|
||||
/// user-visible param types. Coerces in place.
|
||||
fn coerceClosureCallArgs(self: *Lowering, args: []Ref, params: []const TypeId) void {
|
||||
const n = @min(args.len, params.len);
|
||||
for (0..n) |i| {
|
||||
if (args[i].isNone()) continue; // spread placeholder
|
||||
const at = self.builder.getRefType(args[i]);
|
||||
if (at != params[i]) args[i] = self.coerceToType(args[i], at, params[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn astCalleeParamTypes(self: *Lowering, fd: *const ast.FnDecl, args: []const *const Node) []const TypeId {
|
||||
const saved_bindings = self.type_bindings;
|
||||
defer self.type_bindings = saved_bindings;
|
||||
@@ -2788,6 +2822,22 @@ pub fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call, sel_author: ?*
|
||||
}
|
||||
if (c.callee.data != .identifier) return &.{};
|
||||
const bare_name = c.callee.data.identifier.name;
|
||||
// Closure / fn-pointer VALUE bound in scope (`g := () => ...; g(args)`):
|
||||
// type each arg against the callee value's declared parameter types so a
|
||||
// `?T` param wraps the argument (issue 0186) — without this the args lower
|
||||
// with no target type and reach `call_closure` unconverted (a concrete arg
|
||||
// arrives as a bare payload that reads ABSENT; `null` reaches a `{T,i1}`
|
||||
// slot as a bare pointer → LLVM verifier failure). A local value shadows a
|
||||
// same-named function, so this precedes the function-name resolution below.
|
||||
if (self.scope) |scope| {
|
||||
if (scope.lookup(bare_name)) |binding| {
|
||||
if (!binding.ty.isBuiltin()) {
|
||||
const bti = self.module.types.get(binding.ty);
|
||||
if (bti == .closure) return bti.closure.params;
|
||||
if (bti == .function) return bti.function.params;
|
||||
}
|
||||
}
|
||||
}
|
||||
const name = blk: {
|
||||
const scoped = if (self.scope) |scope| scope.lookupFn(bare_name) orelse bare_name else bare_name;
|
||||
if (self.program_index.ufcs_alias_map.get(bare_name)) |target| {
|
||||
|
||||
@@ -15,6 +15,15 @@ const Lowering = lower.Lowering;
|
||||
const Scope = lower.Scope;
|
||||
|
||||
pub fn lowerLambda(self: *Lowering, lam: *const ast.Lambda) Ref {
|
||||
// Flow narrowing (issue 0179) does NOT cross into the lambda body: the
|
||||
// body is a separate function whose `Ref` space overlaps the enclosing
|
||||
// function's, so the outer `narrowed_refs` would falsely match body `Ref`s
|
||||
// (unsound unwrap of a captured-but-not-proven-present optional). The body
|
||||
// builds its own narrowing from scratch; the outer state is restored on
|
||||
// return (re-arming narrowing for the rest of the enclosing expression).
|
||||
var narrow_guard = Lowering.NarrowGuard.enter(self);
|
||||
defer narrow_guard.restore();
|
||||
|
||||
// Lower the lambda body as a new anonymous function
|
||||
var buf: [64]u8 = undefined;
|
||||
const name = std.fmt.bufPrint(&buf, "__lambda_{d}", .{self.block_counter}) catch "__lambda";
|
||||
|
||||
@@ -641,8 +641,22 @@ pub fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mod
|
||||
}
|
||||
return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty);
|
||||
},
|
||||
// Optional → Concrete unwrapping (flow-sensitive narrowing coercion)
|
||||
// Optional → Concrete unwrapping — ONLY when the value is PROVEN
|
||||
// present by flow narrowing (issue 0179). An un-narrowed `?T` flowing
|
||||
// into a concrete slot used to unwrap UNCONDITIONALLY, yielding the
|
||||
// zero payload of a null optional with no diagnostic (silent
|
||||
// miscompile across the whole `?T → concrete` family). Per spec the
|
||||
// only legal extractions are `!` / `??` / binding / match / a `!= null`
|
||||
// guard; reject everything else loudly. `lowerIdentifier` tags the
|
||||
// loaded `Ref` of a guard-narrowed local into `narrowed_refs`.
|
||||
.optional_unwrap => {
|
||||
if (!self.narrowed_refs.contains(val)) {
|
||||
if (self.diagnostics) |d| {
|
||||
const cs = self.builder.current_span;
|
||||
d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "cannot use a value of type '{s}' where '{s}' is expected: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? <default>', bind it (`if v := ...`), or guard with '!= null'", .{ self.formatTypeName(src_ty), self.formatTypeName(dst_ty) });
|
||||
}
|
||||
return val; // hasErrors() aborts before codegen
|
||||
}
|
||||
const child_ty = self.module.types.get(src_ty).optional.child;
|
||||
const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
|
||||
return self.coerceMode(unwrapped, child_ty, dst_ty, mode);
|
||||
|
||||
@@ -1127,6 +1127,12 @@ pub fn createComptimeFunctionWithPrelude(self: *Lowering, prefix: []const u8, pr
|
||||
const name = std.fmt.bufPrint(&buf, "{s}_{d}", .{ prefix, self.comptime_counter }) catch prefix;
|
||||
self.comptime_counter += 1;
|
||||
|
||||
// Flow narrowing (issue 0179) is per-function: this wrapper body has its
|
||||
// own `Ref` space (overlapping the caller's), so isolate it from the
|
||||
// caller's `narrowed`/`narrowed_refs` to avoid a false-positive unwrap gate.
|
||||
var narrow_guard = Lowering.NarrowGuard.enter(self);
|
||||
defer narrow_guard.restore();
|
||||
|
||||
// Save current builder + lowering state. The wrapper fn we're
|
||||
// about to build runs the comptime expression in isolation —
|
||||
// it must NOT inherit the enclosing call's `inline_return_target`
|
||||
|
||||
@@ -15,6 +15,93 @@ const Scope = lower.Scope;
|
||||
const ComptimeValue = Lowering.ComptimeValue;
|
||||
const isTypeCategoryMatch = Lowering.isTypeCategoryMatch;
|
||||
|
||||
// ── Flow-sensitive narrowing (issue 0179) ───────────────────────
|
||||
//
|
||||
// `?T` only converts to a concrete `T` when the value is PROVEN present —
|
||||
// otherwise the implicit unwrap silently yields the zero payload of a null
|
||||
// optional (the bug). These helpers recognize the `!= null` / `== null`
|
||||
// guard shapes and record which local names a branch / guard proves present;
|
||||
// `lowerIdentifier` tags the loaded `Ref` of a narrowed name into
|
||||
// `narrowed_refs`, and `coerceMode`'s `.optional_unwrap` arm only unwraps a
|
||||
// tagged (proven-present) value.
|
||||
|
||||
/// The local-variable name if `node` is a bare identifier currently bound to
|
||||
/// an OPTIONAL local/param in scope (the only thing flow-narrowing applies
|
||||
/// to). Null for field paths, indexes, non-optionals, etc. — those keep the
|
||||
/// explicit `!`/`??`/binding requirement.
|
||||
pub fn narrowableLocal(self: *Lowering, node: *const Node) ?[]const u8 {
|
||||
if (node.data != .identifier) return null;
|
||||
const name = node.data.identifier.name;
|
||||
const scope = self.scope orelse return null;
|
||||
const b = scope.lookup(name) orelse return null;
|
||||
if (b.ty.isBuiltin()) return null;
|
||||
if (self.module.types.get(b.ty) != .optional) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
/// If `bop` compares an optional local against the `null` literal (either
|
||||
/// operand order), the narrowable local's name; else null.
|
||||
pub fn nullCmpName(self: *Lowering, bop: ast.BinaryOp) ?[]const u8 {
|
||||
const lhs_null = bop.lhs.data == .null_literal;
|
||||
const rhs_null = bop.rhs.data == .null_literal;
|
||||
if (lhs_null == rhs_null) return null; // need exactly one `null` side
|
||||
return self.narrowableLocal(if (lhs_null) bop.rhs else bop.lhs);
|
||||
}
|
||||
|
||||
/// Names proven present when `cond` is TRUE: `x != null`, and the `and` of
|
||||
/// such tests (`a != null and b != null`).
|
||||
pub fn collectPresentIfTrue(self: *Lowering, cond: *const Node, out: *std.ArrayList([]const u8)) void {
|
||||
if (cond.data != .binary_op) return;
|
||||
const bop = cond.data.binary_op;
|
||||
switch (bop.op) {
|
||||
.neq => if (self.nullCmpName(bop)) |n| out.append(self.alloc, n) catch {},
|
||||
.and_op => {
|
||||
self.collectPresentIfTrue(bop.lhs, out);
|
||||
self.collectPresentIfTrue(bop.rhs, out);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Names proven present when `cond` is FALSE: `x == null` (false ⇒ present),
|
||||
/// and the `or` of such tests (`a == null or b == null` — false ⇒ both
|
||||
/// present). This is the guard-narrowing case (`if a == null or b == null
|
||||
/// { return }` proves both present afterwards).
|
||||
pub fn collectPresentIfFalse(self: *Lowering, cond: *const Node, out: *std.ArrayList([]const u8)) void {
|
||||
if (cond.data != .binary_op) return;
|
||||
const bop = cond.data.binary_op;
|
||||
switch (bop.op) {
|
||||
.eq => if (self.nullCmpName(bop)) |n| out.append(self.alloc, n) catch {},
|
||||
.or_op => {
|
||||
self.collectPresentIfFalse(bop.lhs, out);
|
||||
self.collectPresentIfFalse(bop.rhs, out);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the currently-narrowed names so a region (block / branch) can
|
||||
/// restore them on exit. Returns a list the caller must `deinit`.
|
||||
pub fn narrowSnapshot(self: *Lowering) std.ArrayList([]const u8) {
|
||||
var list = std.ArrayList([]const u8).empty;
|
||||
var it = self.narrowed.keyIterator();
|
||||
while (it.next()) |k| list.append(self.alloc, k.*) catch {};
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Restore the narrowed-name set to a prior snapshot (drops anything added
|
||||
/// since, re-adds anything killed since).
|
||||
pub fn narrowRestore(self: *Lowering, saved: *std.ArrayList([]const u8)) void {
|
||||
self.narrowed.clearRetainingCapacity();
|
||||
for (saved.items) |n| self.narrowed.put(n, {}) catch {};
|
||||
saved.deinit(self.alloc);
|
||||
}
|
||||
|
||||
/// Mark every name in `names` as narrowed (proven present) in the current set.
|
||||
pub fn applyNarrowing(self: *Lowering, names: []const []const u8) void {
|
||||
for (names) |n| self.narrowed.put(n, {}) catch {};
|
||||
}
|
||||
|
||||
pub fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref {
|
||||
// inline if: evaluate condition at compile time, only lower taken branch
|
||||
if (ie.is_comptime) {
|
||||
@@ -147,11 +234,27 @@ pub fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref {
|
||||
scope.put(bind_name, .{ .ref = slot, .ty = inner_ty, .is_alloca = true });
|
||||
}
|
||||
}
|
||||
// Flow narrowing (issue 0179): which local names this condition proves
|
||||
// present in each arm. A binding `if v := opt` already unwraps `v`, so it
|
||||
// contributes nothing here.
|
||||
var present_true = std.ArrayList([]const u8).empty;
|
||||
defer present_true.deinit(self.alloc);
|
||||
var present_false = std.ArrayList([]const u8).empty;
|
||||
defer present_false.deinit(self.alloc);
|
||||
if (ie.binding_name == null) {
|
||||
self.collectPresentIfTrue(ie.condition, &present_true);
|
||||
self.collectPresentIfFalse(ie.condition, &present_false);
|
||||
}
|
||||
|
||||
// Set target_type so null/undef in branches get the right type
|
||||
const saved_target = self.target_type;
|
||||
if (is_value and result_type != .void) self.target_type = result_type;
|
||||
var then_diverged = false;
|
||||
var then_snap = self.narrowSnapshot();
|
||||
self.applyNarrowing(present_true.items);
|
||||
if (is_value) {
|
||||
var v = self.lowerExpr(ie.then_branch);
|
||||
then_diverged = self.currentBlockHasTerminator();
|
||||
if (!self.currentBlockHasTerminator()) {
|
||||
const v_ty = self.builder.getRefType(v);
|
||||
if (v_ty != result_type and v_ty != .void and result_type != .void) {
|
||||
@@ -161,14 +264,18 @@ pub fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref {
|
||||
}
|
||||
} else {
|
||||
self.lowerBlock(ie.then_branch);
|
||||
then_diverged = self.currentBlockHasTerminator();
|
||||
if (!self.currentBlockHasTerminator()) {
|
||||
self.builder.br(merge_bb, &.{});
|
||||
}
|
||||
}
|
||||
self.narrowRestore(&then_snap);
|
||||
|
||||
// Else branch
|
||||
if (has_else) {
|
||||
self.builder.switchToBlock(else_bb.?);
|
||||
var else_snap = self.narrowSnapshot();
|
||||
self.applyNarrowing(present_false.items);
|
||||
if (is_value) {
|
||||
var v = self.lowerExpr(ie.else_branch.?);
|
||||
if (!self.currentBlockHasTerminator()) {
|
||||
@@ -184,9 +291,15 @@ pub fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref {
|
||||
self.builder.br(merge_bb, &.{});
|
||||
}
|
||||
}
|
||||
self.narrowRestore(&else_snap);
|
||||
}
|
||||
self.target_type = saved_target;
|
||||
|
||||
// Guard form: `if <x == null ...> { <diverges> }` with no else proves the
|
||||
// tested names present for the remainder of the enclosing block. The
|
||||
// enclosing `lowerBlock` snapshot drops this narrowing at block end.
|
||||
if (!has_else and then_diverged) self.applyNarrowing(present_false.items);
|
||||
|
||||
// Continue at merge
|
||||
self.builder.switchToBlock(merge_bb);
|
||||
if (is_value) {
|
||||
|
||||
@@ -2036,6 +2036,15 @@ pub fn lowerDerefExpr(self: *Lowering, de: *const ast.DerefExpr) Ref {
|
||||
return ptr;
|
||||
}
|
||||
|
||||
/// Reject using an un-narrowed optional directly as a binary-op operand
|
||||
/// (issue 0185). Mirrors the `coerceMode` `?T → concrete` rejection (0179):
|
||||
/// the optional does not implicitly unwrap; steer the user to an explicit form.
|
||||
pub fn diagOptionalOperand(self: *Lowering, opt_ty: TypeId, span: ast.Span) void {
|
||||
if (self.diagnostics) |d| {
|
||||
d.addFmt(.err, span, "cannot use a value of type '{s}' as an operand: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? <default>', or guard with '!= null'", .{self.formatTypeName(opt_ty)});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lowerForceUnwrap(self: *Lowering, fu: *const ast.ForceUnwrap) Ref {
|
||||
const val = self.lowerExpr(fu.operand);
|
||||
const inner_ty = self.resolveOptionalInner(self.inferExprType(fu.operand));
|
||||
@@ -2268,9 +2277,17 @@ pub fn lowerExpr(self: *Lowering, node: *const Node) Ref {
|
||||
// `inline for xs (x)` element capture — lower the
|
||||
// synthesized `xs[<i>]` it aliases.
|
||||
if (binding.pack_elem) |elem| break :blk self.lowerExpr(elem);
|
||||
// Flow narrowing (issue 0179): a name proven present by a
|
||||
// `!= null` guard tags its loaded value so the implicit
|
||||
// `?T → concrete` unwrap in `coerceMode` is permitted (an
|
||||
// un-narrowed unwrap is rejected, not silently zeroed).
|
||||
const is_narrowed = self.narrowed.count() > 0 and self.narrowed.contains(id.name);
|
||||
if (binding.is_alloca) {
|
||||
break :blk self.builder.load(binding.ref, binding.ty);
|
||||
const loaded = self.builder.load(binding.ref, binding.ty);
|
||||
if (is_narrowed) self.narrowed_refs.put(loaded, {}) catch {};
|
||||
break :blk loaded;
|
||||
}
|
||||
if (is_narrowed) self.narrowed_refs.put(binding.ref, {}) catch {};
|
||||
break :blk binding.ref;
|
||||
}
|
||||
}
|
||||
@@ -3207,10 +3224,19 @@ pub fn lowerBinaryOp(self: *Lowering, bop: *const ast.BinaryOp) Ref {
|
||||
const rhs_inferred = rhs_ref_pointee orelse self.inferExprType(bop.rhs);
|
||||
var ty = arithResultType(lhs_ty, rhs_inferred);
|
||||
|
||||
// Auto-unwrap optional operands for arithmetic/comparison
|
||||
// Auto-unwrap optional operands for arithmetic/comparison — ONLY when the
|
||||
// operand is PROVEN present by flow narrowing (issue 0185, the operand-side
|
||||
// sibling of 0179). An un-narrowed `?T` operand used to unwrap
|
||||
// UNCONDITIONALLY, so a null operand silently became its zero payload
|
||||
// (`null + 10` → `10`, no diagnostic). `lowerIdentifier` tags a
|
||||
// guard-narrowed local's loaded `Ref` into `narrowed_refs`; an un-narrowed
|
||||
// optional operand is rejected loudly (then still unwrapped so the IR stays
|
||||
// well-formed — `hasErrors()` aborts before codegen). Presence tests
|
||||
// (`x == null` / `x != null`) returned early above, so they're unaffected.
|
||||
if (!ty.isBuiltin()) {
|
||||
const info = self.module.types.get(ty);
|
||||
if (info == .optional) {
|
||||
if (!self.narrowed_refs.contains(lhs)) self.diagOptionalOperand(ty, bop.lhs.span);
|
||||
ty = info.optional.child;
|
||||
lhs = self.builder.emit(.{ .optional_unwrap = .{ .operand = lhs } }, ty);
|
||||
}
|
||||
@@ -3219,6 +3245,7 @@ pub fn lowerBinaryOp(self: *Lowering, bop: *const ast.BinaryOp) Ref {
|
||||
if (!rhs_ty.isBuiltin()) {
|
||||
const rhs_info = self.module.types.get(rhs_ty);
|
||||
if (rhs_info == .optional) {
|
||||
if (!self.narrowed_refs.contains(rhs)) self.diagOptionalOperand(rhs_ty, bop.rhs.span);
|
||||
rhs = self.builder.emit(.{ .optional_unwrap = .{ .operand = rhs } }, rhs_info.optional.child);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1054,6 +1054,15 @@ pub fn synthesizeJniMainStubs(self: *Lowering) void {
|
||||
}
|
||||
|
||||
pub fn synthesizeJniMainStub(self: *Lowering, fcd: *const ast.RuntimeClassDecl, md: ast.RuntimeMethodDecl) void {
|
||||
// Flow narrowing (issue 0179) is per-function: each native-method stub body
|
||||
// gets its own `Ref` space (reset by `beginFunction` below) that OVERLAPS
|
||||
// both the enclosing pass and a sibling method's stub. Without isolation the
|
||||
// previous method's `narrowed_refs` indices falsely match this body's `Ref`s
|
||||
// and permit an unsound unwrap of a non-present optional. Clear on entry,
|
||||
// restore on exit — same contract as the closure / monomorphization paths.
|
||||
var narrow_guard = Lowering.NarrowGuard.enter(self);
|
||||
defer narrow_guard.restore();
|
||||
|
||||
const mangled = jni_descriptor.jniMangleNativeName(self.alloc, fcd.runtime_path, md.name) catch return;
|
||||
const name_id = self.module.types.internString(mangled);
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ pub fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name
|
||||
const owned_name = self.alloc.dupe(u8, mangled_name) catch return;
|
||||
self.lowered_functions.put(owned_name, {}) catch {};
|
||||
|
||||
// Flow narrowing (issue 0179) is per-function: this monomorphized body has
|
||||
// its own `Ref` space (overlapping the caller's), so isolate it from the
|
||||
// caller's `narrowed`/`narrowed_refs` to avoid a false-positive unwrap gate.
|
||||
var narrow_guard = Lowering.NarrowGuard.enter(self);
|
||||
defer narrow_guard.restore();
|
||||
|
||||
// Save builder state
|
||||
const saved_func = self.builder.func;
|
||||
const saved_block = self.builder.current_block;
|
||||
|
||||
@@ -795,6 +795,12 @@ pub fn monomorphizePackFn(
|
||||
const owned_name = self.alloc.dupe(u8, mangled_name) catch return;
|
||||
self.lowered_functions.put(owned_name, {}) catch {};
|
||||
|
||||
// Flow narrowing (issue 0179) is per-function: this monomorphized pack body
|
||||
// has its own `Ref` space (overlapping the caller's), so isolate it from the
|
||||
// caller's `narrowed`/`narrowed_refs` to avoid a false-positive unwrap gate.
|
||||
var narrow_guard = Lowering.NarrowGuard.enter(self);
|
||||
defer narrow_guard.restore();
|
||||
|
||||
// Find the pack param's name and position in fd.params, plus its
|
||||
// constraint protocol (`..xs: Box` ⇒ "Box"; comptime `..$args` has none).
|
||||
var pack_name: []const u8 = "";
|
||||
|
||||
@@ -22,10 +22,14 @@ pub fn lowerBlock(self: *Lowering, node: *const Node) void {
|
||||
const saved_scope = self.scope;
|
||||
self.scope = &block_scope;
|
||||
const saved_defer_len = self.defer_stack.items.len;
|
||||
// Flow narrowing (issue 0179) is block-scoped: a guard inside this
|
||||
// block narrows the rest of THIS block, no further.
|
||||
var narrow_snap = self.narrowSnapshot();
|
||||
defer {
|
||||
self.emitBlockDefers(saved_defer_len);
|
||||
self.scope = saved_scope;
|
||||
block_scope.deinit();
|
||||
self.narrowRestore(&narrow_snap);
|
||||
}
|
||||
for (blk.stmts) |stmt| {
|
||||
if (self.block_terminated) break;
|
||||
@@ -76,10 +80,12 @@ pub fn lowerBlockValue(self: *Lowering, node: *const Node) ?Ref {
|
||||
const saved_scope = self.scope;
|
||||
self.scope = &block_scope;
|
||||
const saved_defer_len = self.defer_stack.items.len;
|
||||
var narrow_snap = self.narrowSnapshot();
|
||||
defer {
|
||||
self.emitBlockDefers(saved_defer_len);
|
||||
self.scope = saved_scope;
|
||||
block_scope.deinit();
|
||||
self.narrowRestore(&narrow_snap);
|
||||
}
|
||||
// A block whose last statement is `;`-terminated (or not an
|
||||
// expression) discards its value: lower every statement as a
|
||||
@@ -801,6 +807,13 @@ fn tryLowerPropertyAssignment(self: *Lowering, asgn: *const ast.Assignment) bool
|
||||
}
|
||||
|
||||
pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
|
||||
// Reassignment kills flow narrowing (issue 0179 / specs.md §Flow-Sensitive
|
||||
// Narrowing): a fresh value may be null, so the name is no longer proven
|
||||
// present. Drop it from the narrowed set before lowering the store.
|
||||
if (asgn.target.data == .identifier) {
|
||||
_ = self.narrowed.remove(asgn.target.data.identifier.name);
|
||||
}
|
||||
|
||||
// Writes through a constant are rejected at compile time (issue 0116):
|
||||
// the target chain's root naming a const global (array/struct consts,
|
||||
// #run consts) or a module value const cannot be stored to — for a
|
||||
|
||||
Reference in New Issue
Block a user