diff --git a/examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx b/examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx new file mode 100644 index 00000000..8d370f4a --- /dev/null +++ b/examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx @@ -0,0 +1,35 @@ +// Regression (issue 0189): a non-type expression used in TYPE position must be +// rejected with a clear diagnostic — never silently resolved to a fabricated +// zero-field empty struct `{}` that ships to codegen as a real type. +// +// Two fabrication paths are covered: +// +// 1. A dotted `type_expr` / field-access (`g.a`, `g` a runtime VALUE, `a` a +// field) in type position — both the bare annotation `x : g.a = ---;` and +// the `Tuple(i32, g.a)` element form hit the same `resolveTypeWithBindings` +// dotted-name guard. A dotted name whose prefix is not a namespace alias is +// a value field access, not a qualified `pkg.Type` path → "expected a type, +// found a value". +// +// 2. A named `!E` (error-set type) whose `E` is not a declared error set — +// an undeclared name or a value name after `!` silently fabricated a `{}` +// stub via `resolveErrorType` -> `resolveNominalLeaf`. The +// `error_type_expr` arm of `checkTypeNodeForUnknown` now validates it → +// "unknown error set" (undeclared / value) or "expected an error set" +// (a declared non-error-set type). +// +// A bare `!` (the void failable channel) and a DECLARED `!E` in return position +// stay valid — exercised in examples/errors and not flagged here. +#import "modules/std.sx"; + +S :: struct { a: i32; } +g : S = .{ a = 1 }; + +main :: () -> i32 { + x : g.a = ---; // field-access value in type position + y : Tuple(i32, g.a) = ---; // same, as a tuple element + z : !Nonexistent = ---; // `!` of an undeclared name + w : Tuple(i32, !Nonexistent) = ---; // nested in a tuple + v : Closure(!Nonexistent) -> i32 = ---; // nested in a closure param + 0 +} diff --git a/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.exit b/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.exit @@ -0,0 +1 @@ +1 diff --git a/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.stderr b/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.stderr new file mode 100644 index 00000000..26bd63cc --- /dev/null +++ b/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.stderr @@ -0,0 +1,29 @@ +error: unknown error set 'Nonexistent' + --> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:31:9 + | +31 | z : !Nonexistent = ---; // `!` of an undeclared name + | ^^^^^^^^^^^^ + +error: unknown error set 'Nonexistent' + --> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:32:20 + | +32 | w : Tuple(i32, !Nonexistent) = ---; // nested in a tuple + | ^^^^^^^^^^^^ + +error: unknown error set 'Nonexistent' + --> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:33:17 + | +33 | v : Closure(!Nonexistent) -> i32 = ---; // nested in a closure param + | ^^^^^^^^^^^^ + +error: expected a type, found a value 'g.a' in type position + --> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:29:9 + | +29 | x : g.a = ---; // field-access value in type position + | ^^^ + +error: expected a type, found a value 'g.a' in type position + --> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:30:20 + | +30 | y : Tuple(i32, g.a) = ---; // same, as a tuple element + | ^^^ diff --git a/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.stdout b/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/diagnostics/expected/1195-diagnostics-non-type-in-type-position.stdout @@ -0,0 +1 @@ + diff --git a/issues/0189-field-access-in-type-position-fabricates-empty-struct.md b/issues/0189-field-access-in-type-position-fabricates-empty-struct.md index bcd43d89..a37ca33e 100644 --- a/issues/0189-field-access-in-type-position-fabricates-empty-struct.md +++ b/issues/0189-field-access-in-type-position-fabricates-empty-struct.md @@ -1,6 +1,27 @@ # 0189 — non-type expression in type position silently fabricates an empty struct -**Status:** OPEN +**Status:** RESOLVED + +> **RESOLVED.** Root cause: two distinct type-resolution paths silently +> fabricated a zero-field `{}` struct for a non-type AST node used in type +> position — (1) a dotted `type_expr` / field-access (`g.a`, `g` a runtime +> value) whose prefix is not a namespace alias, and (2) an `error_type_expr` +> (`!Name`) whose `Name` is not a declared error set (an undeclared name or a +> value). Both reached codegen as a real empty struct with no diagnostic. +> +> Fix: (1) the dotted-name guard in `resolveTypeWithBindings` +> (`src/ir/lower.zig` ~1071–1093) rejects a value field-access in type position +> ("expected a type, found a value '' in type position"); (2) a new +> `.error_type_expr` arm in `checkTypeNodeForUnknown` +> (`src/ir/semantic_diagnostics.zig`) validates a named `!E` against a +> collected set of declared error-set names — "unknown error set ''" for +> an undeclared/value name, "expected an error set after '!', found type +> ''" for a declared non-error-set type. A bare `!` (void channel) and a +> declared `!E` in return position stay valid. +> +> Regression test: `examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx` +> (covers both the `g.a`/`Tuple(i32, g.a)` field-access path and the +> `!Nonexistent` / nested-tuple / nested-closure error-set path). ## Symptom diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 9930cb7e..b3f23d81 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1068,6 +1068,29 @@ pub const Lowering = struct { self.setCurrentSourceFile(saved); return ty; } + // A dotted `type_expr` whose prefix is NOT a namespace alias + // is the only remaining qualified form — and sx has no + // `Type.NestedType` access, so this is a VALUE field access + // (`g.a` where `g` is a value) sitting in a type position. + // Without this guard `resolveNominalLeaf("g.a")` would + // fabricate a zero-field empty-struct stub (`{}`) and ship it + // to codegen as a real type (issue 0189) — a silent-default + // miscompile. Reject loudly and poison with `.unresolved`. + // A genuinely registered dotted type (none today, but a + // forward-declared stub could exist) is still honored before + // we reject, so we never reject a name that resolves to a + // real type. + if (!self.aliasDeclaredAnywhere(te.name[0..dot])) { + const sid = self.module.types.internString(te.name); + if (self.module.types.findByName(sid)) |tid| { + const info = self.module.types.get(tid); + const is_empty_stub = info == .@"struct" and info.@"struct".fields.len == 0; + if (!is_empty_stub) return tid; + } + if (self.diagnostics) |d| + d.addFmt(.err, node.span, "expected a type, found a value '{s}' in type position", .{te.name}); + return .unresolved; + } } return self.resolveNominalLeaf(te.name, te.is_raw, node.span); }, diff --git a/src/ir/semantic_diagnostics.zig b/src/ir/semantic_diagnostics.zig index 164c8319..1ba7335a 100644 --- a/src/ir/semantic_diagnostics.zig +++ b/src/ir/semantic_diagnostics.zig @@ -45,6 +45,13 @@ pub const UnknownTypeChecker = struct { types: *TypeTable, index: *ProgramIndex, main_file: ?[]const u8, + /// Declared error-set names (`E :: error { ... }`) gathered across every + /// compiled module + nested scope. Populated in `run`; consulted by the + /// `.error_type_expr` arm of `checkTypeNodeForUnknown` to tell a valid + /// `!E` (E a declared set) apart from an undeclared name or a value name + /// used after `!` in type position (both of which silently fabricate a + /// zero-field `{}` stub — issue 0189). `null` only before `run` populates it. + error_sets: ?*const std.StringHashMap(void) = null, pub fn run(self: UnknownTypeChecker, decls: []const *const Node) void { // Reserved-type-name binding diagnostic: rejects any @@ -65,6 +72,14 @@ pub const UnknownTypeChecker = struct { var declared = std.StringHashMap(void).init(self.alloc); defer declared.deinit(); self.collectDeclaredTypeNames(decls, &declared); + // Declared error-set names — every module + nested scope. Used by the + // `.error_type_expr` arm to validate `!E` (issue 0189). Collected + // unfiltered (imported sets count: `g :: () -> i64 !LibErr` is valid). + var error_sets = std.StringHashMap(void).init(self.alloc); + defer error_sets.deinit(); + for (decls) |decl| self.collectErrorSetNames(decl, &error_sets); + var checker = self; + checker.error_sets = &error_sets; const saved_file = self.diagnostics.current_source_file; defer self.diagnostics.current_source_file = saved_file; for (decls) |decl| { @@ -77,11 +92,11 @@ pub const UnknownTypeChecker = struct { // previous phase left behind (issue 0122). if (decl.source_file) |sf| self.diagnostics.current_source_file = sf; switch (decl.data) { - .fn_decl => self.checkFnSignatureTypes(&decl.data.fn_decl, &declared), - .struct_decl => |sd| self.checkStructFieldTypes(&sd, &declared), + .fn_decl => checker.checkFnSignatureTypes(&decl.data.fn_decl, &declared), + .struct_decl => |sd| checker.checkStructFieldTypes(&sd, &declared), .const_decl => |cd| switch (cd.value.data) { - .fn_decl => self.checkFnSignatureTypes(&cd.value.data.fn_decl, &declared), - .struct_decl => |sd| self.checkStructFieldTypes(&sd, &declared), + .fn_decl => checker.checkFnSignatureTypes(&cd.value.data.fn_decl, &declared), + .struct_decl => |sd| checker.checkStructFieldTypes(&sd, &declared), else => {}, }, else => {}, @@ -89,6 +104,34 @@ pub const UnknownTypeChecker = struct { } } + /// Gather declared error-set names (`E :: error { ... }`) into `out`, from a + /// top-level decl and every nested scope (fn / closure bodies, local type + /// decls). A top-level error set parses as a bare `.error_set_decl`; a + /// local one is a `.const_decl` whose value is an `.error_set_decl`. Walking + /// every module (not just the main file) keeps an imported `!LibErr` valid. + fn collectErrorSetNames(self: UnknownTypeChecker, node: *const Node, out: *std.StringHashMap(void)) void { + switch (node.data) { + .error_set_decl => |esd| out.put(esd.name, {}) catch {}, + .const_decl => |cd| { + if (cd.value.data == .error_set_decl) + out.put(cd.value.data.error_set_decl.name, {}) catch {}; + self.collectErrorSetNames(cd.value, out); + }, + .fn_decl => |fd| self.collectErrorSetNames(fd.body, out), + .block => |b| for (b.stmts) |s| self.collectErrorSetNames(s, out), + .if_expr => |ie| { + self.collectErrorSetNames(ie.then_branch, out); + if (ie.else_branch) |e| self.collectErrorSetNames(e, out); + }, + .while_expr => |we| self.collectErrorSetNames(we.body, out), + .for_expr => |fe| self.collectErrorSetNames(fe.body, out), + .match_expr => |me| for (me.arms) |arm| self.collectErrorSetNames(arm.body, out), + .push_stmt => |ps| self.collectErrorSetNames(ps.body, out), + .lambda => |lm| self.collectErrorSetNames(lm.body, out), + else => {}, + } + } + /// Reserved-type-name binding walk. Visits every node /// reachable from `node` and rejects each *binding name* — `var` / `:=` / /// typed-local declarations, destructure names, function / lambda / method @@ -814,10 +857,49 @@ pub const UnknownTypeChecker = struct { self.checkTypeNodeForUnknown(a, declared, in_scope, type_vals); } }, + // `!E` (named failable channel) in type position. A bare `!` + // (name == null) is the inferred/void channel and is always valid. + // A named `!E` is valid ONLY when `E` is a declared error set; + // otherwise the lowering path silently fabricates a zero-field + // `{}` stub (issue 0189), so reject it here with a precise + // diagnostic — "unknown error set" for an undeclared name, "expected + // an error set" for a name that resolves to a non-error-set type or + // a value. + .error_type_expr => |ete| if (ete.name) |name| + self.reportIfNotErrorSet(name, node.span), else => {}, } } + /// Validate the `E` in an `!E` type. `E` must be a declared error set. + /// Distinguishes three failure shapes so the user gets an actionable + /// message: an undeclared name (`unknown error set`), and a declared name + /// that is NOT an error set — a value or a non-error-set type (`expected an + /// error set`). Mirrors the silent-fabrication guard for `g.a` in + /// `resolveTypeWithBindings` (issue 0189): never let a non-error-set name + /// after `!` reach the lowering stub. + fn reportIfNotErrorSet(self: UnknownTypeChecker, name: []const u8, span: ?ast.Span) void { + // Inline-spelled / qualified spellings (`mod.E`) carry non-identifier + // characters — trust them, matching `reportIfUnknownType`. + if (!isIdentLike(name)) return; + const sets = self.error_sets orelse return; + if (sets.contains(name)) return; + // A name that names a real (non-error-set) TYPE — a struct/enum/union, + // a builtin, or a fabricated stub — is a type-in-error-position misuse. + if (isBuiltinTypeName(name)) { + self.diagnostics.addFmt(.err, span, "expected an error set after '!', found type '{s}'", .{name}); + return; + } + const sid = self.types.internString(name); + if (self.types.findByName(sid)) |_| { + self.diagnostics.addFmt(.err, span, "expected an error set after '!', found type '{s}'", .{name}); + return; + } + // Otherwise the name is undeclared (or names a value): no error-set + // author anywhere. Either way it is not a usable error set. + self.diagnostics.addFmt(.err, span, "unknown error set '{s}'", .{name}); + } + fn reportIfUnknownType( self: UnknownTypeChecker, name: []const u8,