fix(types): reject unknown type names instead of silent empty struct (issue 0064)
An identifier used in a type position that resolved to nothing fell through
to `type_bridge.resolveTypeName`'s empty-struct-stub fallback, silently
interning a 0-field struct named after the identifier. A value parameter
mistakenly used as a type (`(T: Type, ...) -> T`, missing the `$`) or a
typo'd type name therefore compiled and ran, rendering as `T{}`.
New post-scan diagnostic pass `checkUnknownTypeNames` (lower.zig Pass 1f)
walks every main-file function signature and non-generic struct field type
and rejects any leaf name that is not a primitive, an in-scope generic param
(`$T` / `type_params`), a declared type, or a real (non-stub) registered
type. The load-bearing empty-struct stub is left intact — forward references
and foreign-class opaque types still depend on it during the scan — and the
pass runs before body lowering, so `hasErrors()` halts the build before any
stub reaches codegen.
A value param used as a type gets a tailored hint to write `$T: Type`; a
genuine unknown gets "unknown type 'X'". Imported concrete types are
recognized via the type table, and inline compound spellings (`[:0]u8`),
arbitrary-width ints (`u1`/`u2`), and `$`-introduced generics (`-> $R`) are
exempted to avoid false positives.
Regressions: examples/1111 (tailored hint) + 1112 (typo'd field type).
This commit is contained in:
10
examples/1111-diagnostics-nondollar-type-param-rejected.sx
Normal file
10
examples/1111-diagnostics-nondollar-type-param-rejected.sx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// A value parameter declared `T: Type` (WITHOUT the `$` generic sigil) used in
|
||||||
|
// a type position is rejected with a hint to write `$T: Type`. Previously the
|
||||||
|
// type resolver silently fabricated a 0-field struct named `T`, so the call
|
||||||
|
// compiled and rendered as `T{}` at runtime with no diagnostic.
|
||||||
|
// Regression (issue 0064). Expected: one error per `T` use site; exit 1.
|
||||||
|
idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); }
|
||||||
|
|
||||||
|
main :: () -> s32 {
|
||||||
|
return idwrap(s32, closure(() -> s32 { return 7; }));
|
||||||
|
}
|
||||||
13
examples/1112-diagnostics-unknown-type-name-rejected.sx
Normal file
13
examples/1112-diagnostics-unknown-type-name-rejected.sx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// An identifier used in a type position that names no declared type, builtin,
|
||||||
|
// or in-scope generic parameter is rejected. Previously the type resolver's
|
||||||
|
// empty-struct-stub fallback silently interned a 0-field struct under the typo,
|
||||||
|
// so the program compiled and ran. Regression (issue 0064, broader fix).
|
||||||
|
// Expected: a clean "unknown type" error at the field; exit 1.
|
||||||
|
Point :: struct {
|
||||||
|
x: s32;
|
||||||
|
y: Coordnate; // typo for a non-existent type
|
||||||
|
}
|
||||||
|
|
||||||
|
main :: () -> s32 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
error: 'T' is a value parameter, not a type; introduce a generic type parameter with `$T: Type`
|
||||||
|
--> /Users/agra/projects/sx/examples/1111-diagnostics-nondollar-type-param-rejected.sx:6:37
|
||||||
|
|
|
||||||
|
6 | idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); }
|
||||||
|
| ^
|
||||||
|
|
||||||
|
error: 'T' is a value parameter, not a type; introduce a generic type parameter with `$T: Type`
|
||||||
|
--> /Users/agra/projects/sx/examples/1111-diagnostics-nondollar-type-param-rejected.sx:6:43
|
||||||
|
|
|
||||||
|
6 | idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); }
|
||||||
|
| ^
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: unknown type 'Coordnate'
|
||||||
|
--> /Users/agra/projects/sx/examples/1112-diagnostics-unknown-type-name-rejected.sx:8:8
|
||||||
|
|
|
||||||
|
8 | y: Coordnate; // typo for a non-existent type
|
||||||
|
| ^^^^^^^^^
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
# 0064 — non-`$` `T: Type` function param used as a type silently yields `{}`
|
# 0064 — non-`$` `T: Type` function param used as a type silently yields `{}`
|
||||||
|
|
||||||
|
> **✅ RESOLVED (2026-06-02).** Root cause as diagnosed: an identifier in a type
|
||||||
|
> position that resolved to nothing fell through to `type_bridge.resolveTypeName`'s
|
||||||
|
> empty-struct stub, silently interning a 0-field struct under the name. **Fix
|
||||||
|
> (option 2, surfaced as a diagnostic):** a new post-scan pass
|
||||||
|
> `checkUnknownTypeNames` ([src/ir/lower.zig], Pass 1f) walks every main-file
|
||||||
|
> function signature and non-generic struct field type and rejects any leaf name
|
||||||
|
> that is not a primitive, an in-scope generic param (`$T` / `type_params`), a
|
||||||
|
> declared type, or a real (non-stub) registered type. The load-bearing
|
||||||
|
> empty-struct stub is left intact (forward references + foreign-class opaque
|
||||||
|
> types still rely on it during the scan); the pass runs after scanning and before
|
||||||
|
> body lowering, so `core.zig`'s `hasErrors()` halts the build before any stub
|
||||||
|
> reaches codegen. A value param used as a type gets the tailored hint
|
||||||
|
> *"'T' is a value parameter, not a type; introduce a generic type parameter with
|
||||||
|
> `$T: Type`"*; a genuine unknown name gets *"unknown type 'X'"*. Imported concrete
|
||||||
|
> types are recognized via the type table (`findByName`), so cross-module
|
||||||
|
> references aren't false-flagged; inline compound spellings (`[:0]u8`), arbitrary-
|
||||||
|
> width ints (`u1`/`u2`), and `$`-introduced generics (`-> $R`) are all exempted.
|
||||||
|
> Regression tests: `examples/1111-diagnostics-nondollar-type-param-rejected.sx`
|
||||||
|
> (tailored hint, exit 1) and `examples/1112-diagnostics-unknown-type-name-rejected.sx`
|
||||||
|
> (typo'd field type, exit 1). Suite: 347 passed, 0 failed.
|
||||||
|
|
||||||
## Symptom
|
## Symptom
|
||||||
|
|
||||||
A function parameter declared `T: Type` (or lowercase `T: type`) — i.e. WITHOUT
|
A function parameter declared `T: Type` (or lowercase `T: type`) — i.e. WITHOUT
|
||||||
|
|||||||
185
src/ir/lower.zig
185
src/ir/lower.zig
@@ -367,6 +367,12 @@ pub const Lowering = struct {
|
|||||||
// before body lowering — purely a diagnostic pass; `core.zig` halts on
|
// before body lowering — purely a diagnostic pass; `core.zig` halts on
|
||||||
// any error before codegen.
|
// any error before codegen.
|
||||||
self.checkErrorFlow(decls);
|
self.checkErrorFlow(decls);
|
||||||
|
// Pass 1f: reject identifiers used in a type position that name no
|
||||||
|
// declared type / primitive / in-scope generic param (issue 0064).
|
||||||
|
// Runs after scanning (so every real type name is registered) and
|
||||||
|
// before body lowering, so the diagnostic halts via `core.zig`
|
||||||
|
// `hasErrors()` before the empty-struct stub can reach codegen.
|
||||||
|
self.checkUnknownTypeNames(decls);
|
||||||
// Pass 2: lower main (and comptime side-effects)
|
// Pass 2: lower main (and comptime side-effects)
|
||||||
self.lowerMainAndComptime(decls);
|
self.lowerMainAndComptime(decls);
|
||||||
// Pass 3: lower deferred functions (any_to_string etc.) now that all types are registered
|
// Pass 3: lower deferred functions (any_to_string etc.) now that all types are registered
|
||||||
@@ -518,6 +524,185 @@ pub const Lowering = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Diagnostic pass (issue 0064): reject an identifier used in a type
|
||||||
|
/// position that names no declared type, primitive, or in-scope generic
|
||||||
|
/// type parameter. Without it, `type_bridge.resolveTypeName`'s
|
||||||
|
/// empty-struct-stub fallback silently fabricates a 0-field struct named
|
||||||
|
/// after the unknown identifier — so a value param mistakenly used as a
|
||||||
|
/// type (`(T: Type, …) -> T`, missing the `$`) or a typo'd type name
|
||||||
|
/// compiles and runs, rendering as `T{}`. Main-file decls only; imported /
|
||||||
|
/// library modules are trusted, matching `checkErrorFlow`.
|
||||||
|
fn checkUnknownTypeNames(self: *Lowering, decls: []const *const Node) void {
|
||||||
|
if (self.diagnostics == null) return;
|
||||||
|
var declared = std.StringHashMap(void).init(self.alloc);
|
||||||
|
defer declared.deinit();
|
||||||
|
self.collectDeclaredTypeNames(decls, &declared);
|
||||||
|
for (decls) |decl| {
|
||||||
|
if (self.main_file) |mf| {
|
||||||
|
if (decl.source_file) |sf| {
|
||||||
|
if (!std.mem.eql(u8, sf, mf)) continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (decl.data) {
|
||||||
|
.fn_decl => self.checkFnSignatureTypes(&decl.data.fn_decl, &declared),
|
||||||
|
.struct_decl => |sd| self.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),
|
||||||
|
else => {},
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect every top-level name that can legitimately appear in a type
|
||||||
|
/// position: const-decl names (covers `T :: struct/enum/union/error/alias`
|
||||||
|
/// and value consts), plus the scan-populated foreign-class / generic-
|
||||||
|
/// template / protocol / alias maps. Built across ALL files so a main-file
|
||||||
|
/// reference to an imported type isn't flagged.
|
||||||
|
fn collectDeclaredTypeNames(self: *Lowering, decls: []const *const Node, out: *std.StringHashMap(void)) void {
|
||||||
|
for (decls) |decl| {
|
||||||
|
switch (decl.data) {
|
||||||
|
.const_decl => |cd| out.put(cd.name, {}) catch {},
|
||||||
|
.struct_decl => |sd| out.put(sd.name, {}) catch {},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var it_fc = self.foreign_class_map.keyIterator();
|
||||||
|
while (it_fc.next()) |k| out.put(k.*, {}) catch {};
|
||||||
|
var it_tmpl = self.struct_template_map.keyIterator();
|
||||||
|
while (it_tmpl.next()) |k| out.put(k.*, {}) catch {};
|
||||||
|
var it_pd = self.protocol_decl_map.keyIterator();
|
||||||
|
while (it_pd.next()) |k| out.put(k.*, {}) catch {};
|
||||||
|
var it_pa = self.protocol_ast_map.keyIterator();
|
||||||
|
while (it_pa.next()) |k| out.put(k.*, {}) catch {};
|
||||||
|
var it_al = self.type_alias_map.keyIterator();
|
||||||
|
while (it_al.next()) |k| out.put(k.*, {}) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkStructFieldTypes(self: *Lowering, sd: *const ast.StructDecl, declared: *std.StringHashMap(void)) void {
|
||||||
|
// Generic struct fields reference the struct's own type params ($T) —
|
||||||
|
// resolved at instantiation, not here.
|
||||||
|
if (sd.type_params.len != 0) return;
|
||||||
|
for (sd.field_types) |ft| self.checkTypeNodeForUnknown(ft, declared, &.{}, &.{});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkFnSignatureTypes(self: *Lowering, fd: *const ast.FnDecl, declared: *std.StringHashMap(void)) void {
|
||||||
|
// Value params declared `: Type` (no `$`) — using one in a type
|
||||||
|
// position is the issue-0064 misuse; surface a tailored hint.
|
||||||
|
var type_vals = std.ArrayList([]const u8).empty;
|
||||||
|
defer type_vals.deinit(self.alloc);
|
||||||
|
for (fd.params) |p| {
|
||||||
|
if (p.type_expr.data == .type_expr) {
|
||||||
|
const cn = p.type_expr.data.type_expr.name;
|
||||||
|
if (std.mem.eql(u8, cn, "Type") or std.mem.eql(u8, cn, "type")) {
|
||||||
|
type_vals.append(self.alloc, p.name) catch {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (fd.params) |p| self.checkTypeNodeForUnknown(p.type_expr, declared, fd.type_params, type_vals.items);
|
||||||
|
if (fd.return_type) |rt| self.checkTypeNodeForUnknown(rt, declared, fd.type_params, type_vals.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recurse a type-annotation node to its leaf names, reporting any unknown.
|
||||||
|
fn checkTypeNodeForUnknown(
|
||||||
|
self: *Lowering,
|
||||||
|
node: *const Node,
|
||||||
|
declared: *std.StringHashMap(void),
|
||||||
|
in_scope: []const ast.StructTypeParam,
|
||||||
|
type_vals: []const []const u8,
|
||||||
|
) void {
|
||||||
|
switch (node.data) {
|
||||||
|
// A `$`-prefixed name (`-> $R`) introduces/references a generic type
|
||||||
|
// param inline — always valid in a type position.
|
||||||
|
.type_expr => |te| if (!te.is_generic) self.reportIfUnknownType(te.name, node.span, declared, in_scope, type_vals),
|
||||||
|
.identifier => |id| self.reportIfUnknownType(id.name, node.span, declared, in_scope, type_vals),
|
||||||
|
.pointer_type_expr => |pt| self.checkTypeNodeForUnknown(pt.pointee_type, declared, in_scope, type_vals),
|
||||||
|
.many_pointer_type_expr => |mp| self.checkTypeNodeForUnknown(mp.element_type, declared, in_scope, type_vals),
|
||||||
|
.slice_type_expr => |st| self.checkTypeNodeForUnknown(st.element_type, declared, in_scope, type_vals),
|
||||||
|
.optional_type_expr => |ot| self.checkTypeNodeForUnknown(ot.inner_type, declared, in_scope, type_vals),
|
||||||
|
.array_type_expr => |at| self.checkTypeNodeForUnknown(at.element_type, declared, in_scope, type_vals),
|
||||||
|
.tuple_type_expr => |tt| for (tt.field_types) |ft| self.checkTypeNodeForUnknown(ft, declared, in_scope, type_vals),
|
||||||
|
.function_type_expr => |ft| {
|
||||||
|
for (ft.param_types) |pt| self.checkTypeNodeForUnknown(pt, declared, in_scope, type_vals);
|
||||||
|
if (ft.return_type) |rt| self.checkTypeNodeForUnknown(rt, declared, in_scope, type_vals);
|
||||||
|
},
|
||||||
|
.closure_type_expr => |ct| {
|
||||||
|
// Variadic type-pack closures (`Closure(..$args) -> R`) resolve
|
||||||
|
// their projections specially — don't walk them here.
|
||||||
|
if (ct.pack_name != null) return;
|
||||||
|
for (ct.param_types) |pt| self.checkTypeNodeForUnknown(pt, declared, in_scope, type_vals);
|
||||||
|
if (ct.return_type) |rt| self.checkTypeNodeForUnknown(rt, declared, in_scope, type_vals);
|
||||||
|
},
|
||||||
|
// Builtin constructors (Vector) and generic templates resolve the
|
||||||
|
// base name specially; just check the type args.
|
||||||
|
.parameterized_type_expr => |pt| for (pt.args) |a| self.checkTypeNodeForUnknown(a, declared, in_scope, type_vals),
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reportIfUnknownType(
|
||||||
|
self: *Lowering,
|
||||||
|
name: []const u8,
|
||||||
|
span: ?ast.Span,
|
||||||
|
declared: *std.StringHashMap(void),
|
||||||
|
in_scope: []const ast.StructTypeParam,
|
||||||
|
type_vals: []const []const u8,
|
||||||
|
) void {
|
||||||
|
// Only bare identifiers are validated. Inline-spelled compound types
|
||||||
|
// (`[:0]u8`, `mod.Type`, …) carry non-identifier characters — trust them.
|
||||||
|
if (!isIdentLike(name)) return;
|
||||||
|
if (isBuiltinTypeName(name)) return;
|
||||||
|
for (in_scope) |tp| if (std.mem.eql(u8, tp.name, name)) return;
|
||||||
|
if (declared.contains(name)) return;
|
||||||
|
// Registered as a real (non-stub) type — covers imported concrete
|
||||||
|
// structs / enums / unions absent from the main-file decl list. A
|
||||||
|
// fabricated empty-struct stub (the very thing we're catching) is the
|
||||||
|
// sole 0-field-struct case, so it doesn't suppress the diagnostic.
|
||||||
|
const sid = self.module.types.internString(name);
|
||||||
|
if (self.module.types.findByName(sid)) |tid| {
|
||||||
|
const info = self.module.types.get(tid);
|
||||||
|
const empty_struct_stub = info == .@"struct" and info.@"struct".fields.len == 0;
|
||||||
|
if (!empty_struct_stub) return;
|
||||||
|
}
|
||||||
|
const diags = self.diagnostics orelse return;
|
||||||
|
for (type_vals) |tv| {
|
||||||
|
if (std.mem.eql(u8, tv, name)) {
|
||||||
|
diags.addFmt(.err, span, "'{s}' is a value parameter, not a type; introduce a generic type parameter with `${s}: Type`", .{ name, name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diags.addFmt(.err, span, "unknown type '{s}'", .{name});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isBuiltinTypeName(name: []const u8) bool {
|
||||||
|
if (type_bridge.resolveTypePrimitive(name) != null) return true;
|
||||||
|
// Arbitrary-width integers / floats: u1, s7, u128, f16, f80, …
|
||||||
|
if (name.len >= 2 and (name[0] == 'u' or name[0] == 's' or name[0] == 'f')) {
|
||||||
|
var all_digits = true;
|
||||||
|
for (name[1..]) |c| {
|
||||||
|
if (!std.ascii.isDigit(c)) {
|
||||||
|
all_digits = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (all_digits) return true;
|
||||||
|
}
|
||||||
|
const extra = [_][]const u8{ "Type", "type", "int", "float", "Self", "self", "any", "noreturn", "usize", "isize", "comptime_int", "comptime_float" };
|
||||||
|
for (extra) |e| if (std.mem.eql(u8, name, e)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isIdentLike(name: []const u8) bool {
|
||||||
|
if (name.len == 0) return false;
|
||||||
|
if (!(std.ascii.isAlphabetic(name[0]) or name[0] == '_')) return false;
|
||||||
|
for (name) |c| {
|
||||||
|
if (!(std.ascii.isAlphanumeric(c) or c == '_')) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Analyze one function (or lambda) body as its own boundary — a fresh
|
/// Analyze one function (or lambda) body as its own boundary — a fresh
|
||||||
/// binding context and an empty proven set.
|
/// binding context and an empty proven set.
|
||||||
fn analyzeFnBody(self: *Lowering, body: *const Node) void {
|
fn analyzeFnBody(self: *Lowering, body: *const Node) void {
|
||||||
|
|||||||
Reference in New Issue
Block a user