fix(diagnostics): reject reserved/builtin type names used as identifiers (issue 0076)

A value binding (local/global `var` or a parameter) spelled as a
reserved/builtin type name parses as a `.type_expr` rather than an
`.identifier` (parser.zig, via `Type.fromName`), so the address-of
family in lower.zig never saw a scoped local and mis-lowered it —
loading the aggregate and passing it by value to a `ptr` parameter
(LLVM verifier abort, or a silent `*self`-mutation-losing copy).

Add a declaration-site diagnostic in semantic_diagnostics.zig
(`UnknownTypeChecker.checkBindingName`): reject any parameter name or
`var` binding name (`:=` / typed-local / global forms) whose spelling
collides with a reserved type name. `isReservedTypeName` defers to the
parser's own classifier (`types.Type.fromName`) so the rejected set
never drifts from the set that would parse as a type — the named
builtins (bool/string/void/f32/f64/usize/isize/Any) and `[su]N` over
sx's 1-64 range. Bare value names (`s`, `self`, `index`) are untouched.
No lowering special-case; the `.identifier`-only address-of paths are
correct once type-shaped names can never be bound. The rejected
attempt-1 `bareVarName` approach was never landed.

Tests:
- 0125-types-type-named-var-rejected: `:=` form (s2) rejected
  (repurposed from the old test that asserted the now-illegal behavior).
- 1119-diagnostics-reserved-type-name-as-identifier: parameter (u8),
  typed-local (s64, bool), `:=` (string) forms rejected.
- 0135-types-self-streaming-nonreserved: positive — `*self` streaming
  with non-reserved names accumulates correctly via both call styles.
- 0904-optionals: renamed incidental locals s1/s2 -> filled/empty.
This commit is contained in:
agra
2026-06-03 19:00:39 +03:00
parent 4ab3608f77
commit f49a49cd07
18 changed files with 262 additions and 31 deletions

View File

@@ -2,6 +2,7 @@ const std = @import("std");
const ast = @import("../ast.zig");
const errors = @import("../errors.zig");
const types = @import("types.zig");
const name_class = @import("../types.zig");
const program_index_mod = @import("program_index.zig");
const type_resolver = @import("type_resolver.zig");
@@ -10,10 +11,17 @@ const TypeTable = types.TypeTable;
const ProgramIndex = program_index_mod.ProgramIndex;
const TypeResolver = type_resolver.TypeResolver;
/// Unknown-type diagnostic pass (issue 0064), extracted from `Lowering`
/// (architecture phase A2.4). Rejects an identifier used in a type position
/// that names no declared type, primitive, or in-scope generic type parameter.
/// Without it, `TypeResolver.resolveNamed`'s empty-struct-stub fallback silently
/// Declaration-name / type-position diagnostic pass. Two checks, both over the
/// main file's decls, before lowering:
///
/// 1. Unknown-type diagnostic (issue 0064), extracted from `Lowering`
/// (architecture phase A2.4): an identifier used in a type position that
/// names no declared type, primitive, or in-scope generic type parameter.
/// 2. Reserved-type-name binding (issue 0076): a value binding (local/global
/// `var` or a parameter) spelled as a reserved/builtin type name. See
/// `isReservedTypeName`.
///
/// Without (1)'s check, `TypeResolver.resolveNamed`'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;
@@ -46,6 +54,7 @@ pub const UnknownTypeChecker = struct {
switch (decl.data) {
.fn_decl => self.checkFnSignatureTypes(&decl.data.fn_decl, &declared),
.struct_decl => |sd| self.checkStructFieldTypes(&sd, &declared),
.var_decl => |vd| self.checkBindingName(vd.name, decl.span),
.const_decl => |cd| switch (cd.value.data) {
.fn_decl => self.checkFnSignatureTypes(&cd.value.data.fn_decl, &declared),
.struct_decl => |sd| self.checkStructFieldTypes(&sd, &declared),
@@ -224,6 +233,7 @@ pub const UnknownTypeChecker = struct {
}
}
}
for (params) |p| self.checkBindingName(p.name, p.name_span);
for (params) |p| self.checkTypeNodeForUnknown(p.type_expr, declared, in_scope.items, type_vals.items);
if (return_type) |rt| self.checkTypeNodeForUnknown(rt, declared, in_scope.items, type_vals.items);
self.walkBodyTypes(body, declared, in_scope, type_vals);
@@ -275,6 +285,7 @@ pub const UnknownTypeChecker = struct {
.multi_assign => |ma| for (ma.values) |v| self.walkBodyTypes(v, declared, in_scope, type_vals),
.destructure_decl => |dd| self.walkBodyTypes(dd.value, declared, in_scope, type_vals),
.var_decl => |vd| {
self.checkBindingName(vd.name, node.span);
if (vd.type_annotation) |ta| self.checkTypeNodeForUnknown(ta, declared, in_scope.items, type_vals.items);
if (vd.value) |v| self.walkBodyTypes(v, declared, in_scope, type_vals);
},
@@ -416,8 +427,35 @@ pub const UnknownTypeChecker = struct {
}
self.diagnostics.addFmt(.err, span, "unknown type '{s}'", .{name});
}
/// Reject a value binding (local/global `var` or a parameter) spelled as a
/// reserved/builtin type name (issue 0076). The parser turns such a spelling
/// into a `.type_expr` rather than an `.identifier` (`parser.zig`, via
/// `name_class.Type.fromName`), so the address-of family in `lower.zig`
/// (`@x`, the autoref `x.method(...)` receiver, a bare `f(x)` at a `*T`
/// param) never sees a scoped local and falls through to value lowering —
/// loading the whole aggregate and passing it by value to a `ptr` parameter
/// (LLVM verifier abort, or a silent mutation-losing copy). Rejecting the
/// name here, before lowering, keeps the `.identifier`-only address-of paths
/// correct without any lowering special-case.
fn checkBindingName(self: UnknownTypeChecker, name: []const u8, span: ?ast.Span) void {
if (isReservedTypeName(name))
self.diagnostics.addFmt(.err, span, "'{s}' is a reserved type name and cannot be used as an identifier", .{name});
}
};
/// A binding name collides with a reserved/builtin type name exactly when the
/// parser would classify the same spelling as a type. `name_class.Type.fromName`
/// is that classifier (`parser.zig` uses it to choose `.type_expr` over
/// `.identifier`), so deferring to it ties the rejection to the parser's set and
/// keeps the two from drifting: the named builtins (`bool`, `string`, `void`,
/// `f32`, `f64`, `usize`, `isize`, `Any`) and the `[su]N` arbitrary-width ints
/// over sx's supported 164 range. A bare value name (`s`, `buf`, `index`,
/// `self`) is not a type spelling and is left alone.
fn isReservedTypeName(name: []const u8) bool {
return name_class.Type.fromName(name) != null;
}
fn isBuiltinTypeName(name: []const u8) bool {
if (TypeResolver.resolvePrimitive(name) != null) return true;
// Arbitrary-width integers / floats: u1, s7, u128, f16, f80, …