From f49a49cd07f5cf31f931c9555d42ccafec751a38 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 19:00:39 +0300 Subject: [PATCH 1/4] fix(diagnostics): reject reserved/builtin type names used as identifiers (issue 0076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0125-types-type-named-var-rejected.sx | 13 ++ examples/0125-types-type-named-vars.sx | 19 --- .../0135-types-self-streaming-nonreserved.sx | 30 +++++ .../0904-optionals-any-to-string-optional.sx | 8 +- ...ostics-reserved-type-name-as-identifier.sx | 16 +++ .../0125-types-type-named-var-rejected.exit | 1 + .../0125-types-type-named-var-rejected.stderr | 5 + .../0125-types-type-named-var-rejected.stdout | 0 .../0125-types-type-named-vars.stderr | 1 - .../0125-types-type-named-vars.stdout | 3 - ...135-types-self-streaming-nonreserved.exit} | 0 ...35-types-self-streaming-nonreserved.stderr | 0 ...35-types-self-streaming-nonreserved.stdout | 2 + ...tics-reserved-type-name-as-identifier.exit | 1 + ...cs-reserved-type-name-as-identifier.stderr | 23 ++++ ...cs-reserved-type-name-as-identifier.stdout | 0 ...076-stack-struct-addrof-passed-by-value.md | 125 ++++++++++++++++++ src/ir/semantic_diagnostics.zig | 46 ++++++- 18 files changed, 262 insertions(+), 31 deletions(-) create mode 100644 examples/0125-types-type-named-var-rejected.sx delete mode 100644 examples/0125-types-type-named-vars.sx create mode 100644 examples/0135-types-self-streaming-nonreserved.sx create mode 100644 examples/1119-diagnostics-reserved-type-name-as-identifier.sx create mode 100644 examples/expected/0125-types-type-named-var-rejected.exit create mode 100644 examples/expected/0125-types-type-named-var-rejected.stderr create mode 100644 examples/expected/0125-types-type-named-var-rejected.stdout delete mode 100644 examples/expected/0125-types-type-named-vars.stderr delete mode 100644 examples/expected/0125-types-type-named-vars.stdout rename examples/expected/{0125-types-type-named-vars.exit => 0135-types-self-streaming-nonreserved.exit} (100%) create mode 100644 examples/expected/0135-types-self-streaming-nonreserved.stderr create mode 100644 examples/expected/0135-types-self-streaming-nonreserved.stdout create mode 100644 examples/expected/1119-diagnostics-reserved-type-name-as-identifier.exit create mode 100644 examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stderr create mode 100644 examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stdout create mode 100644 issues/0076-stack-struct-addrof-passed-by-value.md diff --git a/examples/0125-types-type-named-var-rejected.sx b/examples/0125-types-type-named-var-rejected.sx new file mode 100644 index 0000000..47a6711 --- /dev/null +++ b/examples/0125-types-type-named-var-rejected.sx @@ -0,0 +1,13 @@ +// A local declared with a reserved/builtin type-name spelling (`s2` is the +// arbitrary-width `sN` integer type) is rejected at the declaration site. +// Previously such a name parsed as a `.type_expr`, so address-of sites +// mis-lowered it (load-by-value to a `ptr` param → LLVM verifier abort, or a +// silent `*self`-mutation-losing copy). Regression (issue 0076). Expected: +// error at the declaration; exit 1. +#import "modules/std.sx"; + +main :: () -> s32 { + s2 := 42; + print("s2: {}\n", s2); + return 0; +} diff --git a/examples/0125-types-type-named-vars.sx b/examples/0125-types-type-named-vars.sx deleted file mode 100644 index 39efe1c..0000000 --- a/examples/0125-types-type-named-vars.sx +++ /dev/null @@ -1,19 +0,0 @@ -#import "modules/std.sx"; -#import "modules/math/math.sx"; -#import "modules/compiler.sx"; -#import "modules/test.sx"; -pkg :: #import "modules/testpkg"; - -main :: () { - - // ======================================================== - // 21. TYPE-NAMED VARIABLES (s2, u8, etc.) - // ======================================================== - print("=== 21. Type-Named Vars ===\n"); - { - s2 := 42; - print("s2: {}\n", s2); - s2 = s2 + 1; - print("s2+1: {}\n", s2); - } -} diff --git a/examples/0135-types-self-streaming-nonreserved.sx b/examples/0135-types-self-streaming-nonreserved.sx new file mode 100644 index 0000000..3337b08 --- /dev/null +++ b/examples/0135-types-self-streaming-nonreserved.sx @@ -0,0 +1,30 @@ +// A `*self`-mutating streaming pattern with NON-reserved binding names +// (`hasher`, `ctx`) compiles and accumulates state correctly through BOTH +// call styles — explicit address-of `update(@h, ...)` and autoref +// `h.update(...)` — across multiple mutating calls. Proves the +// `.identifier`-only address-of paths in lowering are correct as-is, with no +// type-shaped-name special-case (companion to the issue-0076 rejection of +// type-named identifiers). +#import "modules/std.sx"; + +Hasher :: struct { total: s64 = 0; count: s64 = 0; } + +update :: (self: *Hasher, n: s64) { + self.total += n; + self.count += 1; +} + +main :: () -> s32 { + hasher := Hasher.{ total = 0, count = 0 }; + update(@hasher, 10); // explicit address-of receiver + hasher.update(20); // autoref receiver + update(@hasher, 30); + hasher.update(40); + print("hasher total={} count={}\n", hasher.total, hasher.count); + + ctx := Hasher.{ total = 100, count = 0 }; + ctx.update(5); + update(@ctx, 7); + print("ctx total={} count={}\n", ctx.total, ctx.count); + return 0; +} diff --git a/examples/0904-optionals-any-to-string-optional.sx b/examples/0904-optionals-any-to-string-optional.sx index 3fb45c4..d534b18 100644 --- a/examples/0904-optionals-any-to-string-optional.sx +++ b/examples/0904-optionals-any-to-string-optional.sx @@ -23,9 +23,9 @@ S :: struct { } main :: () { - s1 := S.{ a = 42, b = "hi", c = true }; - print("{}\n", s1); - s2 := S.{ a = null, b = null, c = null }; - print("{}\n", s2); + filled := S.{ a = 42, b = "hi", c = true }; + print("{}\n", filled); + empty := S.{ a = null, b = null, c = null }; + print("{}\n", empty); 0; } diff --git a/examples/1119-diagnostics-reserved-type-name-as-identifier.sx b/examples/1119-diagnostics-reserved-type-name-as-identifier.sx new file mode 100644 index 0000000..0477ef6 --- /dev/null +++ b/examples/1119-diagnostics-reserved-type-name-as-identifier.sx @@ -0,0 +1,16 @@ +// A value binding (parameter or local `var`) spelled as a reserved/builtin +// type name is rejected at the declaration site, across every declaration +// form: a parameter name (`u8`), a typed local (`s64`, `bool`), and a `:=` +// local (`string`). Such a spelling parses as a `.type_expr` rather than an +// `.identifier`, so the address-of family in lowering mis-lowers it (issue +// 0076). Expected: one error per offending name; exit 1. +#import "modules/std.sx"; + +takes_u8 :: (u8: s32) -> s32 { return u8; } + +main :: () -> s32 { + s64 : s32 = 3; + bool : bool = true; + string := "x"; + return 0; +} diff --git a/examples/expected/0125-types-type-named-var-rejected.exit b/examples/expected/0125-types-type-named-var-rejected.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0125-types-type-named-var-rejected.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0125-types-type-named-var-rejected.stderr b/examples/expected/0125-types-type-named-var-rejected.stderr new file mode 100644 index 0000000..d2798b1 --- /dev/null +++ b/examples/expected/0125-types-type-named-var-rejected.stderr @@ -0,0 +1,5 @@ +error: 's2' is a reserved type name and cannot be used as an identifier + --> /Users/agra/projects/sx/examples/0125-types-type-named-var-rejected.sx:10:5 + | +10 | s2 := 42; + | ^^^^^^^^^ diff --git a/examples/expected/0125-types-type-named-var-rejected.stdout b/examples/expected/0125-types-type-named-var-rejected.stdout new file mode 100644 index 0000000..e69de29 diff --git a/examples/expected/0125-types-type-named-vars.stderr b/examples/expected/0125-types-type-named-vars.stderr deleted file mode 100644 index 8b13789..0000000 --- a/examples/expected/0125-types-type-named-vars.stderr +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/expected/0125-types-type-named-vars.stdout b/examples/expected/0125-types-type-named-vars.stdout deleted file mode 100644 index 3210d67..0000000 --- a/examples/expected/0125-types-type-named-vars.stdout +++ /dev/null @@ -1,3 +0,0 @@ -=== 21. Type-Named Vars === -s2: 42 -s2+1: 43 diff --git a/examples/expected/0125-types-type-named-vars.exit b/examples/expected/0135-types-self-streaming-nonreserved.exit similarity index 100% rename from examples/expected/0125-types-type-named-vars.exit rename to examples/expected/0135-types-self-streaming-nonreserved.exit diff --git a/examples/expected/0135-types-self-streaming-nonreserved.stderr b/examples/expected/0135-types-self-streaming-nonreserved.stderr new file mode 100644 index 0000000..e69de29 diff --git a/examples/expected/0135-types-self-streaming-nonreserved.stdout b/examples/expected/0135-types-self-streaming-nonreserved.stdout new file mode 100644 index 0000000..55ce35c --- /dev/null +++ b/examples/expected/0135-types-self-streaming-nonreserved.stdout @@ -0,0 +1,2 @@ +hasher total=100 count=4 +ctx total=112 count=2 diff --git a/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.exit b/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stderr b/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stderr new file mode 100644 index 0000000..4b5e737 --- /dev/null +++ b/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stderr @@ -0,0 +1,23 @@ +error: 'u8' is a reserved type name and cannot be used as an identifier + --> /Users/agra/projects/sx/examples/1119-diagnostics-reserved-type-name-as-identifier.sx:9:14 + | + 9 | takes_u8 :: (u8: s32) -> s32 { return u8; } + | ^^ + +error: 's64' is a reserved type name and cannot be used as an identifier + --> /Users/agra/projects/sx/examples/1119-diagnostics-reserved-type-name-as-identifier.sx:12:5 + | +12 | s64 : s32 = 3; + | ^^^^^^^^^^^^^^ + +error: 'bool' is a reserved type name and cannot be used as an identifier + --> /Users/agra/projects/sx/examples/1119-diagnostics-reserved-type-name-as-identifier.sx:13:5 + | +13 | bool : bool = true; + | ^^^^^^^^^^^^^^^^^^^ + +error: 'string' is a reserved type name and cannot be used as an identifier + --> /Users/agra/projects/sx/examples/1119-diagnostics-reserved-type-name-as-identifier.sx:14:5 + | +14 | string := "x"; + | ^^^^^^^^^^^^^^ diff --git a/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stdout b/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stdout new file mode 100644 index 0000000..e69de29 diff --git a/issues/0076-stack-struct-addrof-passed-by-value.md b/issues/0076-stack-struct-addrof-passed-by-value.md new file mode 100644 index 0000000..1579131 --- /dev/null +++ b/issues/0076-stack-struct-addrof-passed-by-value.md @@ -0,0 +1,125 @@ +# 0076 — builtin/reserved type name wrongly accepted as an identifier + +> **Status: RESOLVED.** +> +> **Root cause:** the language accepted a value binding (local/global `var` or a +> parameter) spelled as a reserved/builtin type name. The parser turns such a +> spelling into a `.type_expr` rather than an `.identifier` (`parser.zig`, via +> `Type.fromName`), so the address-of family in `src/ir/lower.zig` never saw a +> scoped local and fell through to value lowering — loading the whole aggregate +> and passing it by value to a `ptr` parameter (LLVM verifier abort, or a silent +> `*self`-mutation-losing copy). +> +> **Fix:** a declaration-site diagnostic in the existing semantic pass +> `src/ir/semantic_diagnostics.zig` (`UnknownTypeChecker`). New +> `checkBindingName` rejects any parameter name or `var` binding name (local or +> global, `:=` / typed-local 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 is added; +> the `.identifier`-only address-of paths are correct once type-shaped names can +> never be bound. The rejected `bareVarName` approach was never landed. +> +> **Regression tests:** +> - `examples/0125-types-type-named-var-rejected.sx` — `:=` form (`s2`) rejected. +> - `examples/1119-diagnostics-reserved-type-name-as-identifier.sx` — parameter +> (`u8`), typed-local (`s64`, `bool`), and `:=` (`string`) forms rejected. +> - `examples/0135-types-self-streaming-nonreserved.sx` — positive: `*self` +> streaming with non-reserved names (`hasher`, `ctx`) accumulates correctly via +> both `update(@h, …)` and `h.update(…)`. +> +> Pre-existing example `examples/0904-...` declared locals `s1`/`s2` (incidental +> names); renamed to `filled`/`empty`. Scope: main-file decls only, matching the +> pass's existing trusted-imports convention. + +## Symptom (how it first surfaced) + +A local variable whose name is lexically a type — e.g. `s2` (the `sN` +arbitrary-width signed-int syntax: `Type.fromName("s2")` → `s(2)`), or `u8`, +`s64`, etc. — is accepted as a variable. Because such a name parses as a +`.type_expr` (not `.identifier`), the address-of family of lowering sites +(`@s2`, the autoref `s2.update(...)` receiver, a bare `f(s2)` at a `*T` param, +global function-pointer args) does NOT recognize it as a scoped local and falls +through to value lowering — loading the whole aggregate and passing it **by +value** to a `ptr` parameter: + +``` +LLVM verification failed: Call parameter type does not match function signature! + call void @update(ptr @__sx_default_context, + { [8 x i64], [64 x i8], i64, i64 } %load, ...) +``` + +For some struct shapes it compiles but silently passes a **copy** (callee +`*self` mutations lost). A non-type-shaped name (`hasher`, `ctx`) never triggers +any of this — the `.identifier` paths already work correctly. + +## Root cause + +The language is **accepting reserved/builtin type names as identifiers** in the +first place. `sN`/`uN` (arbitrary-width ints) and the named builtins +(`bool`, `string`, `void`, `f32`, `f64`, `s8`/`s16`/`s32`/`s64`, +`u8`/`u16`/`u32`/`u64`, …) are reserved type names; declaring a variable with +such a name is meaningless and produces the mis-lowering above. Patching each +address-of site to tolerate the name (the rejected `bareVarName` approach) is +whack-a-mole — there is always another site, and it entrenches a name that +should never have been allowed. + +## Proper fix (the required direction) + +Emit a **diagnostic error** when an identifier is declared with a name that +collides with a **builtin/reserved type name** — including the arbitrary-width +`[su][0-9]+` (`sN`/`uN`) family AND the named builtins (`bool`, `string`, +`void`, `f32`, `f64`, the fixed-width int types, etc.). Scope ruling (Agra): +**all builtin/reserved type names** are rejected as identifiers. (User-defined +struct/type-name shadowing, if intentionally supported elsewhere, is out of +scope for this issue — this is specifically about builtin/reserved type names.) + +Diagnostic at the declaration site, e.g.: +`error: 'u8' is a reserved type name and cannot be used as an identifier` +with the declaration's span. + +Suspected area: name binding / declaration handling — where a `:=` / typed +local / parameter name is introduced. Reject the name there, before it ever +reaches lowering. Do NOT add lowering special-cases for type-shaped names; the +`.identifier`-only checks at the address-of sites are then correct as-is (no +type-shaped name can reach them). + +## Reproduction + +```sx +#import "modules/std.sx"; +Sha256 :: struct { h:[8]u64; block:[64]u8; block_len:s64=0; total_len:u64=0; } +init :: () -> Sha256 { s:Sha256=---; s.block_len=0; s.total_len=0; s } +update :: (self:*Sha256, data:string) { self.total_len += data.len; } +main :: () -> s32 { s2 := init(); update(@s2, "."); print("total_len={}\n", s2.total_len); return 0; } +``` + +`./zig-out/bin/sx run ` today → LLVM verifier abort. +**Expected after fix:** a clean compile-time diagnostic that `s2` is a reserved +type name and cannot be an identifier (exit non-zero, readable error — NOT an +LLVM abort, NOT a silent copy). The same program with a non-reserved name +(`hasher := init(); update(@hasher, ".")`) must compile and print `total_len=1`. + +## Verification + +1. Pinned diagnostics test(s) asserting the error for representative reserved + names used as identifiers: `s2`, `u8`, `s64`, `bool`, `string` (declaration + forms: `:=`, typed local, and a parameter name). Capture the diagnostic text + in `expected/`. +2. A positive test: the same `*self` streaming pattern with NON-reserved names + (`hasher`, `ctx`) compiles and accumulates state correctly via both + `update(@h, ...)` and `h.update(...)` — proving the `.identifier` paths are + correct and no lowering special-case is needed. +3. `zig build && zig build test && bash tests/run_examples.sh` all green. If any + existing example/test declares a variable with a reserved type name, it is now + illegal — fix the test's variable name (do NOT weaken the diagnostic). Report + how many such sites existed. + +## Provenance + +Discovered by the `distribution` flow (P1.2 pure-sx SHA-256), whose minimal repro +happened to name a local `s2`. Real SHA-256 code with names like `hasher`/`ctx` +is unaffected on the current compiler — so the P1.2 "blocker" was a +naming artifact, and this issue is really a missing-diagnostic correctness bug. diff --git a/src/ir/semantic_diagnostics.zig b/src/ir/semantic_diagnostics.zig index d5a8d6a..0015a11 100644 --- a/src/ir/semantic_diagnostics.zig +++ b/src/ir/semantic_diagnostics.zig @@ -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 1–64 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, … From df6e830bec7124120ff1769258af636715518911 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 19:32:49 +0300 Subject: [PATCH 2/4] fix(diagnostics): reject reserved type-name bindings in every module (issue 0077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue-0076 reserved-type-name binding diagnostic only ran over main-file decls, so an imported module (or the stdlib) could still declare `s2 := ...` and reach lowering, where the address-of family loads the whole aggregate and passes it by value to a `ptr` param — LLVM verifier abort. Extend coverage to every compiled module: a dedicated `checkBindingNames` walk (in semantic_diagnostics.zig) visits every var/`:=`/typed-local binding name and function/lambda/struct-method parameter at any depth, with NO main-file filter, descending the `namespace_decl` that a `mod :: #import` wraps so imported-module decls are reached. It tracks each module's source_file (save/restore per node) so the diagnostic renders against the imported module's text. Rejection still defers to the parser's `Type.fromName` classifier; the unknown-type check (0064) stays main-file-only. No lowering special-case; `.identifier`-only address-of paths are unchanged. Stdlib audit: the only reserved-name bindings under library/ were two `u1` locals in ui/renderer.sx (UV coords) — renamed to u_min/u_max/v_min/v_max. Regression test: examples/1120-diagnostics-imported-reserved-type-name.sx (+ companion mod.sx) — an imported `s2 := ...` now emits the clean diagnostic at the import's declaration site (exit 1), not an LLVM abort. Resolves issues 0076 (coverage extension) and 0077. --- ...diagnostics-imported-reserved-type-name.sx | 16 +++ .../mod.sx | 16 +++ ...agnostics-imported-reserved-type-name.exit | 1 + ...nostics-imported-reserved-type-name.stderr | 5 + ...nostics-imported-reserved-type-name.stdout | 1 + ...076-stack-struct-addrof-passed-by-value.md | 14 +- ...077-imported-reserved-type-name-binding.md | 103 +++++++++++++ library/modules/ui/renderer.sx | 40 +++--- src/ir/semantic_diagnostics.zig | 135 ++++++++++++++++-- 9 files changed, 301 insertions(+), 30 deletions(-) create mode 100644 examples/1120-diagnostics-imported-reserved-type-name.sx create mode 100644 examples/1120-diagnostics-imported-reserved-type-name/mod.sx create mode 100644 examples/expected/1120-diagnostics-imported-reserved-type-name.exit create mode 100644 examples/expected/1120-diagnostics-imported-reserved-type-name.stderr create mode 100644 examples/expected/1120-diagnostics-imported-reserved-type-name.stdout create mode 100644 issues/0077-imported-reserved-type-name-binding.md diff --git a/examples/1120-diagnostics-imported-reserved-type-name.sx b/examples/1120-diagnostics-imported-reserved-type-name.sx new file mode 100644 index 0000000..e8b5fe9 --- /dev/null +++ b/examples/1120-diagnostics-imported-reserved-type-name.sx @@ -0,0 +1,16 @@ +// A value binding spelled as a reserved type name (`s2`, the `sN` arbitrary- +// width int syntax) is rejected at its declaration site even when it lives in +// an IMPORTED module — the reserved-name binding diagnostic covers every +// compiled module, not just the main file. Without universal coverage the +// binding reaches lowering and aborts LLVM verification (a loaded aggregate +// passed by value to a `*Box` param). +// +// Regression (issue 0077): the imported-module facet of issue 0076. Expected: +// one clean diagnostic pointing at the imported module's `s2 := ...`, exit 1 — +// NOT an LLVM verifier abort. +#import "modules/std.sx"; +mod :: #import "1120-diagnostics-imported-reserved-type-name/mod.sx"; + +main :: () -> s32 { + return mod.run_imported_reserved_name(); +} diff --git a/examples/1120-diagnostics-imported-reserved-type-name/mod.sx b/examples/1120-diagnostics-imported-reserved-type-name/mod.sx new file mode 100644 index 0000000..0c9d670 --- /dev/null +++ b/examples/1120-diagnostics-imported-reserved-type-name/mod.sx @@ -0,0 +1,16 @@ +#import "modules/std.sx"; + +Box :: struct { total: s64 = 0; count: s64 = 0; } + +update :: (self: *Box, n: s64) { + self.total += n; + self.count += 1; +} + +run_imported_reserved_name :: () -> s32 { + s2 := Box.{ total = 0, count = 0 }; + update(@s2, 5); + s2.update(7); + print("imported s2 total={} count={}\n", s2.total, s2.count); + return 0; +} diff --git a/examples/expected/1120-diagnostics-imported-reserved-type-name.exit b/examples/expected/1120-diagnostics-imported-reserved-type-name.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1120-diagnostics-imported-reserved-type-name.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1120-diagnostics-imported-reserved-type-name.stderr b/examples/expected/1120-diagnostics-imported-reserved-type-name.stderr new file mode 100644 index 0000000..f5469a0 --- /dev/null +++ b/examples/expected/1120-diagnostics-imported-reserved-type-name.stderr @@ -0,0 +1,5 @@ +error: 's2' is a reserved type name and cannot be used as an identifier + --> examples/1120-diagnostics-imported-reserved-type-name/mod.sx:11:5 + | +11 | s2 := Box.{ total = 0, count = 0 }; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/examples/expected/1120-diagnostics-imported-reserved-type-name.stdout b/examples/expected/1120-diagnostics-imported-reserved-type-name.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1120-diagnostics-imported-reserved-type-name.stdout @@ -0,0 +1 @@ + diff --git a/issues/0076-stack-struct-addrof-passed-by-value.md b/issues/0076-stack-struct-addrof-passed-by-value.md index 1579131..06f247d 100644 --- a/issues/0076-stack-struct-addrof-passed-by-value.md +++ b/issues/0076-stack-struct-addrof-passed-by-value.md @@ -31,8 +31,18 @@ > both `update(@h, …)` and `h.update(…)`. > > Pre-existing example `examples/0904-...` declared locals `s1`/`s2` (incidental -> names); renamed to `filled`/`empty`. Scope: main-file decls only, matching the -> pass's existing trusted-imports convention. +> names); renamed to `filled`/`empty`. +> +> **Coverage extension (issue 0077).** The first landing scoped the binding +> check to main-file decls (matching the unknown-type check's trusted-imports +> convention); an imported module could still declare `s2 := …` and hit the +> original LLVM verifier abort. The reserved-name binding diagnostic now runs +> over EVERY compiled module — imported user modules (descending the +> `namespace_decl` an `mod :: #import` wraps) AND the stdlib `library/` — and +> the two `u1` locals in `library/modules/ui/renderer.sx` were renamed +> accordingly. The unknown-type check (issue 0064) stays main-file-only. See +> issue 0077 for the imported-module facet and its pinned regression test +> `examples/1120-diagnostics-imported-reserved-type-name.sx`. ## Symptom (how it first surfaced) diff --git a/issues/0077-imported-reserved-type-name-binding.md b/issues/0077-imported-reserved-type-name-binding.md new file mode 100644 index 0000000..e5733d7 --- /dev/null +++ b/issues/0077-imported-reserved-type-name-binding.md @@ -0,0 +1,103 @@ +# 0077 — reserved type-name binding diagnostic skips imported modules + +> **Status: RESOLVED.** +> +> **Root cause:** the reserved-name binding diagnostic (issue 0076) only ran +> over main-file decls (`UnknownTypeChecker.run`'s `main_file` filter). An +> imported module's `s2 := …` was never checked and reached lowering, where the +> address-of family loaded the whole aggregate and passed it by value to a +> `*Box` param — LLVM verifier abort. +> +> **Fix:** the binding check (`checkBindingNames` in +> `src/ir/semantic_diagnostics.zig`) now walks EVERY compiled module — no +> main-file filter — visiting every `var`/`:=`/typed-local binding name and +> function/lambda/struct-method parameter at any depth, and descending the +> `namespace_decl` that a `mod :: #import` wraps so imported-module decls are +> reached. The walk tracks each module's `source_file` (via the diagnostic +> list's `current_source_file`, saved/restored per node) so the diagnostic +> renders against the imported module's text. Rejection still defers to the +> parser's `name_class.Type.fromName` classifier (no drift). The unknown-type +> check (issue 0064) stays main-file-only. No lowering special-case; the +> `.identifier`-only address-of paths are unchanged. +> +> **Stdlib audit:** the only reserved-name bindings under `library/` were two +> `u1` locals in `library/modules/ui/renderer.sx` (lines 203, 382); the UV-coord +> locals were renamed `u_min`/`u_max`/`v_min`/`v_max`. No reserved parameter +> names or other reserved bindings exist in `library/` or `examples/`. +> +> **Regression test:** `examples/1120-diagnostics-imported-reserved-type-name.sx` +> (+ companion `1120-diagnostics-imported-reserved-type-name/mod.sx`) — an +> imported module declaring `s2 := …` now emits the clean diagnostic at the +> import's declaration site (exit 1), not an LLVM abort. + +## Symptom + +An imported module can still declare a parameter or `var` binding whose name is a +reserved/builtin type name. Observed: the imported-module repro below reaches +lowering and fails LLVM verification by passing a loaded struct value to a +`*Box` parameter. Expected: the same declaration-site diagnostic used for +main-file issue 0076 should reject the imported module's `s2` binding before +lowering. + +## Reproduction + +Create these two files under the repo root, then run +`./zig-out/bin/sx run .sx-tmp/issue0077_main.sx`. + +`.sx-tmp/issue0077_mod.sx`: + +```sx +#import "modules/std.sx"; + +Box :: struct { total: s64 = 0; count: s64 = 0; } + +update :: (self: *Box, n: s64) { + self.total += n; + self.count += 1; +} + +run_imported_reserved_name :: () -> s32 { + s2 := Box.{ total = 0, count = 0 }; + update(@s2, 5); + s2.update(7); + print("imported s2 total={} count={}\n", s2.total, s2.count); + return 0; +} +``` + +`.sx-tmp/issue0077_main.sx`: + +```sx +#import "modules/std.sx"; +mod :: #import ".sx-tmp/issue0077_mod.sx"; + +main :: () -> s32 { + return mod.run_imported_reserved_name(); +} +``` + +Current output on `flow/sx-foundation/F0.1`: + +```text +LLVM verification failed: Call parameter type does not match function signature! + %load = load { i64, i64 }, ptr %alloca, align 8, !dbg !461 + ptr call void @update(ptr %0, { i64, i64 } %load, i64 5), !dbg !462 +``` + +## Investigation prompt + +Investigate and fix issue 0077 in the sx compiler. The suspected area is +`src/ir/semantic_diagnostics.zig`, especially +`UnknownTypeChecker.run` and its `main_file` filter. Attempt 2 for issue 0076 +added `checkBindingName`, but it only runs over main-file declarations; imported +modules are still trusted and can hit the original LLVM verifier failure. The +fix likely needs to apply reserved-type-name binding diagnostics to all user +source modules that are lowered, while preserving the existing trusted-stdlib +or library convention only where intentionally required. Also audit existing +reserved-name bindings in `library/` (for example `u1 := ...` in +`library/modules/ui/renderer.sx`) and rename any source that will become +newly illegal under the corrected rule. + +Verification: run the two-file repro above and expect a clean diagnostic at the +imported module's `s2 := ...` declaration, not LLVM verification failure. Then +run `zig build`, `zig build test`, and `bash tests/run_examples.sh`. diff --git a/library/modules/ui/renderer.sx b/library/modules/ui/renderer.sx index 9d3556c..1bc6043 100755 --- a/library/modules/ui/renderer.sx +++ b/library/modules/ui/renderer.sx @@ -198,17 +198,17 @@ UIRenderer :: struct { w := frame.size.width; h := frame.size.height; - u0 := uv_min.x; - v0 := uv_min.y; - u1 := uv_max.x; - v1 := uv_max.y; + u_min := uv_min.x; + v_min := uv_min.y; + u_max := uv_max.x; + v_max := uv_max.y; - self.write_vertex(x0, y0, u0, v0, r, g, b, a, radius, border_w, w, h); - self.write_vertex(x1, y0, u1, v0, r, g, b, a, radius, border_w, w, h); - self.write_vertex(x0, y1, u0, v1, r, g, b, a, radius, border_w, w, h); - self.write_vertex(x1, y0, u1, v0, r, g, b, a, radius, border_w, w, h); - self.write_vertex(x1, y1, u1, v1, r, g, b, a, radius, border_w, w, h); - self.write_vertex(x0, y1, u0, v1, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x0, y0, u_min, v_min, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y0, u_max, v_min, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x0, y1, u_min, v_max, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y0, u_max, v_min, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y1, u_max, v_max, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x0, y1, u_min, v_max, r, g, b, a, radius, border_w, w, h); } write_vertex :: (self: *UIRenderer, x: f32, y: f32, u: f32, v: f32, r: f32, g: f32, b: f32, a: f32, cr: f32, bw: f32, rw: f32, rh: f32) { @@ -377,10 +377,10 @@ UIRenderer :: struct { gx1 := gx0 + cached.width * inv_dpi; gy1 := gy0 + cached.height * inv_dpi; - u0 := cached.uv_x; - v0 := cached.uv_y; - u1 := cached.uv_x + cached.uv_w; - v1 := cached.uv_y + cached.uv_h; + u_min := cached.uv_x; + v_min := cached.uv_y; + u_max := cached.uv_x + cached.uv_w; + v_max := cached.uv_y + cached.uv_h; if self.vertex_count + 6 > MAX_UI_VERTICES { @@ -389,12 +389,12 @@ UIRenderer :: struct { // corner_radius = -1.0 signals "text mode" to the fragment shader neg1 : f32 = 0.0 - 1.0; - self.write_vertex(gx0, gy0, u0, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); - self.write_vertex(gx1, gy0, u1, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); - self.write_vertex(gx0, gy1, u0, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); - self.write_vertex(gx1, gy0, u1, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); - self.write_vertex(gx1, gy1, u1, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); - self.write_vertex(gx0, gy1, u0, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx0, gy0, u_min, v_min, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy0, u_max, v_min, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx0, gy1, u_min, v_max, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy0, u_max, v_min, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy1, u_max, v_max, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx0, gy1, u_min, v_max, r, g, b, a, neg1, 0.0, 0.0, 0.0); } } i += 1; diff --git a/src/ir/semantic_diagnostics.zig b/src/ir/semantic_diagnostics.zig index 0015a11..9578c08 100644 --- a/src/ir/semantic_diagnostics.zig +++ b/src/ir/semantic_diagnostics.zig @@ -11,15 +11,20 @@ const TypeTable = types.TypeTable; const ProgramIndex = program_index_mod.ProgramIndex; const TypeResolver = type_resolver.TypeResolver; -/// Declaration-name / type-position diagnostic pass. Two checks, both over the -/// main file's decls, before lowering: +/// Declaration-name / type-position diagnostic pass. Two checks, 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`. +/// Main-file decls only — imported / library modules are trusted, matching +/// `checkErrorFlow`. +/// 2. Reserved-type-name binding (issues 0076, 0077): a value binding +/// (local/global `var`, a typed-local, or a parameter) spelled as a +/// reserved/builtin type name. See `isReservedTypeName`. Runs over EVERY +/// compiled module (no main-file filter): such a binding mis-lowers the same +/// way wherever declared, so an imported module or the stdlib is no +/// exception. /// /// 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 @@ -42,6 +47,21 @@ pub const UnknownTypeChecker = struct { main_file: ?[]const u8, pub fn run(self: UnknownTypeChecker, decls: []const *const Node) void { + // Reserved-type-name binding diagnostic (issues 0076, 0077): rejects any + // parameter name or `var` / `:=` / typed-local binding name spelled as a + // reserved/builtin type name. Runs over EVERY compiled module — imported + // user modules and the stdlib `library/` included — because such a + // binding mis-lowers identically wherever it is declared: a loaded + // aggregate passed by value to a `ptr` param → LLVM verifier abort. No + // main-file filter (unlike the unknown-type check below) and no declared- + // type / scope context — rejection is purely on spelling. The walk + // tracks each module's source file (via the diagnostic list's + // `current_source_file`, saved/restored per node) so an imported-module + // diagnostic renders against that module's text (issue 0077). + for (decls) |decl| self.checkBindingNames(decl); + + // Unknown-type diagnostic (issue 0064): main-file decls only; imported + // and library modules are trusted, matching `checkErrorFlow`. var declared = std.StringHashMap(void).init(self.alloc); defer declared.deinit(); self.collectDeclaredTypeNames(decls, &declared); @@ -54,7 +74,6 @@ 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), @@ -65,6 +84,108 @@ pub const UnknownTypeChecker = struct { } } + /// Reserved-type-name binding walk (issues 0076, 0077). Visits every binding + /// site reachable from `node` — `var` / `:=` / typed-local declarations and + /// function / lambda / struct-method parameters, at any nesting depth — and + /// rejects each name that collides with a reserved/builtin type name. Walks + /// into expressions too, so a lambda nested in a call arg / struct literal is + /// reached. Deliberately filter-free (every module is walked) and context- + /// free (spelling is the sole criterion), distinct from the main-file-scoped + /// unknown-type walk. A node carrying its own `source_file` (every module's + /// top-level decls do) becomes the emit file for its whole subtree, restored + /// on exit so a sibling in another module isn't rendered against it. + fn checkBindingNames(self: UnknownTypeChecker, node: *const Node) void { + const saved_file = self.diagnostics.current_source_file; + defer self.diagnostics.current_source_file = saved_file; + if (node.source_file) |sf| self.diagnostics.current_source_file = sf; + switch (node.data) { + .var_decl => |vd| { + self.checkBindingName(vd.name, node.span); + if (vd.value) |v| self.checkBindingNames(v); + }, + .fn_decl => |fd| { + for (fd.params) |p| self.checkBindingName(p.name, p.name_span); + self.checkBindingNames(fd.body); + }, + .lambda => |lm| { + for (lm.params) |p| self.checkBindingName(p.name, p.name_span); + self.checkBindingNames(lm.body); + }, + .const_decl => |cd| self.checkBindingNames(cd.value), + // A namespaced import (`mod :: #import "..."`) is wrapped here, its + // module decls held inline. Descend so an imported module's + // reserved-name binding is rejected too (issue 0077). + .namespace_decl => |nd| for (nd.decls) |d| self.checkBindingNames(d), + .struct_decl => |sd| for (sd.methods) |m| self.checkBindingNames(m), + .block => |b| for (b.stmts) |s| self.checkBindingNames(s), + .if_expr => |ie| { + self.checkBindingNames(ie.condition); + self.checkBindingNames(ie.then_branch); + if (ie.else_branch) |e| self.checkBindingNames(e); + }, + .while_expr => |we| { + self.checkBindingNames(we.condition); + self.checkBindingNames(we.body); + }, + .for_expr => |fe| { + self.checkBindingNames(fe.iterable); + if (fe.range_end) |re| self.checkBindingNames(re); + self.checkBindingNames(fe.body); + }, + .match_expr => |me| { + self.checkBindingNames(me.subject); + for (me.arms) |arm| self.checkBindingNames(arm.body); + }, + .push_stmt => |ps| { + self.checkBindingNames(ps.context_expr); + self.checkBindingNames(ps.body); + }, + .defer_stmt => |ds| self.checkBindingNames(ds.expr), + .onfail_stmt => |os| self.checkBindingNames(os.body), + .return_stmt => |r| if (r.value) |v| self.checkBindingNames(v), + .raise_stmt => |rs| self.checkBindingNames(rs.tag), + .assignment => |a| { + self.checkBindingNames(a.value); + self.checkBindingNames(a.target); + }, + .multi_assign => |ma| for (ma.values) |v| self.checkBindingNames(v), + .destructure_decl => |dd| self.checkBindingNames(dd.value), + .call => |c| { + self.checkBindingNames(c.callee); + for (c.args) |a| self.checkBindingNames(a); + }, + .binary_op => |b| { + self.checkBindingNames(b.lhs); + self.checkBindingNames(b.rhs); + }, + .unary_op => |u| self.checkBindingNames(u.operand), + .field_access => |fa| self.checkBindingNames(fa.object), + .index_expr => |ix| { + self.checkBindingNames(ix.object); + self.checkBindingNames(ix.index); + }, + .struct_literal => |sl| { + for (sl.field_inits) |fi| self.checkBindingNames(fi.value); + if (sl.init_block) |ib| self.checkBindingNames(ib); + }, + .array_literal => |al| for (al.elements) |e| self.checkBindingNames(e), + .force_unwrap => |fu| self.checkBindingNames(fu.operand), + .null_coalesce => |nc| { + self.checkBindingNames(nc.lhs); + self.checkBindingNames(nc.rhs); + }, + .deref_expr => |de| self.checkBindingNames(de.operand), + .try_expr => |te| self.checkBindingNames(te.operand), + .catch_expr => |ce| { + self.checkBindingNames(ce.operand); + self.checkBindingNames(ce.body); + }, + .comptime_expr => |ce| self.checkBindingNames(ce.expr), + .spread_expr => |se| self.checkBindingNames(se.operand), + 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- @@ -233,7 +354,6 @@ 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); @@ -285,7 +405,6 @@ 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); }, From fcc76b93916273e962fe916120538b1e4996f7b8 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 20:09:46 +0300 Subject: [PATCH 3/4] fix(diagnostics): make reserved-type-name binding check exhaustive (issue 0076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reserved/builtin-type-name binding diagnostic was a hand-walked subset of binding-bearing AST nodes with a silent `else => {}`, so each review found another syntactic binding form that bypassed it and hit the original LLVM verifier abort: destructure names (`s2, x := …`), `impl` method params/locals, and `if` / `while` / `for` / match-arm / `catch` / `onfail` captures. Rewrite `checkBindingNames` (src/ir/semantic_diagnostics.zig) as an EXHAUSTIVE `switch` over every `Node.Data` tag with NO `else` arm — a future binding-bearing node type now fails to compile until it is handled here, so coverage is enforced by the compiler instead of a hand-maintained list. The check stays in the pre-lowering semantic pass rather than moving to the `Scope.put` scope-registration choke point: lowering is lazy, so an uncalled function's bindings never reach `Scope.put`, yet they must still be rejected at their declaration (e.g. the never-called `takes_u8` in 1119). No lowering special-case; `lower.zig` unchanged. Regression tests (fail-before: LLVM abort or silent accept → pass-after: clean diagnostic, exit 1): - 1121 control-flow: destructure, if/while bindings, for capture+index, match-arm capture - 1122 impl-block method: reserved param AND reserved local - 1123 catch + onfail tag bindings - 1124 destructure name reserved in an imported module Existing 0125 / 1119 / 0135 / 1120 tests kept; full suite 368 passed. --- ...-diagnostics-reserved-name-control-flow.sx | 30 +++ ...2-diagnostics-reserved-name-impl-method.sx | 30 +++ ...-diagnostics-reserved-name-catch-onfail.sx | 28 +++ ...agnostics-imported-reserved-destructure.sx | 15 ++ .../mod.sx | 8 + ...iagnostics-reserved-name-control-flow.exit | 1 + ...gnostics-reserved-name-control-flow.stderr | 41 ++++ ...gnostics-reserved-name-control-flow.stdout | 1 + ...diagnostics-reserved-name-impl-method.exit | 1 + ...agnostics-reserved-name-impl-method.stderr | 11 + ...agnostics-reserved-name-impl-method.stdout | 1 + ...iagnostics-reserved-name-catch-onfail.exit | 1 + ...gnostics-reserved-name-catch-onfail.stderr | 11 + ...gnostics-reserved-name-catch-onfail.stdout | 1 + ...nostics-imported-reserved-destructure.exit | 1 + ...stics-imported-reserved-destructure.stderr | 5 + ...stics-imported-reserved-destructure.stdout | 1 + ...076-stack-struct-addrof-passed-by-value.md | 29 ++- src/ir/semantic_diagnostics.zig | 191 +++++++++++++++--- 19 files changed, 375 insertions(+), 32 deletions(-) create mode 100644 examples/1121-diagnostics-reserved-name-control-flow.sx create mode 100644 examples/1122-diagnostics-reserved-name-impl-method.sx create mode 100644 examples/1123-diagnostics-reserved-name-catch-onfail.sx create mode 100644 examples/1124-diagnostics-imported-reserved-destructure.sx create mode 100644 examples/1124-diagnostics-imported-reserved-destructure/mod.sx create mode 100644 examples/expected/1121-diagnostics-reserved-name-control-flow.exit create mode 100644 examples/expected/1121-diagnostics-reserved-name-control-flow.stderr create mode 100644 examples/expected/1121-diagnostics-reserved-name-control-flow.stdout create mode 100644 examples/expected/1122-diagnostics-reserved-name-impl-method.exit create mode 100644 examples/expected/1122-diagnostics-reserved-name-impl-method.stderr create mode 100644 examples/expected/1122-diagnostics-reserved-name-impl-method.stdout create mode 100644 examples/expected/1123-diagnostics-reserved-name-catch-onfail.exit create mode 100644 examples/expected/1123-diagnostics-reserved-name-catch-onfail.stderr create mode 100644 examples/expected/1123-diagnostics-reserved-name-catch-onfail.stdout create mode 100644 examples/expected/1124-diagnostics-imported-reserved-destructure.exit create mode 100644 examples/expected/1124-diagnostics-imported-reserved-destructure.stderr create mode 100644 examples/expected/1124-diagnostics-imported-reserved-destructure.stdout diff --git a/examples/1121-diagnostics-reserved-name-control-flow.sx b/examples/1121-diagnostics-reserved-name-control-flow.sx new file mode 100644 index 0000000..0ccd8f1 --- /dev/null +++ b/examples/1121-diagnostics-reserved-name-control-flow.sx @@ -0,0 +1,30 @@ +// Reserved/builtin type names are rejected as binding NAMES across every +// control-flow and destructuring form, not just plain `var`/param decls: a +// destructure name (`s2`), an `if`/`while` optional binding (`u8`/`s16`), a +// `for` capture and index name (`bool`/`s32`), and a match-arm capture +// (`string`). Each spelling parses as a `.type_expr`, so the address-of family +// in lowering mis-lowers it (a loaded aggregate passed by value to a `ptr` +// param → LLVM verifier abort). The declaration-site diagnostic comes from one +// EXHAUSTIVE binding-name walk, so no syntactic binding form can slip through. +// +// Regression (issue 0076, attempt-4 coverage). Expected: one error per +// offending name; exit 1 — NOT an LLVM verifier abort. +#import "modules/std.sx"; + +pair :: () -> (s64, s64) { (1, 2) } +maybe :: () -> ?s64 { return null; } + +main :: () -> s32 { + s2, rest := pair(); // destructure name + if u8 := maybe() { } // if optional binding + while s16 := maybe() { break; } // while optional binding + xs := [3]s64.{ 10, 20, 30 }; + for xs: (bool) { } // for capture name + for xs: (v, s32) { } // for index name + opt: ?s64 = 5; + r := if opt == { // match-arm capture + case .some: (string) { 0 } + case .none: { 0 } + }; + return 0; +} diff --git a/examples/1122-diagnostics-reserved-name-impl-method.sx b/examples/1122-diagnostics-reserved-name-impl-method.sx new file mode 100644 index 0000000..289d8cd --- /dev/null +++ b/examples/1122-diagnostics-reserved-name-impl-method.sx @@ -0,0 +1,30 @@ +// A reserved/builtin type name is rejected as a binding name inside an `impl` +// block's method too — both as a parameter (`u8`) and as a local (`s2`). The +// impl method is reached through the exhaustive binding-name walk's +// `impl_block` arm (→ each method's `fn_decl`), so an `impl` method is no more +// exempt than a free function. Without the diagnostic the reserved local's +// `@s2` mis-lowers (a loaded aggregate passed by value to a `*Box` param → +// LLVM verifier abort). +// +// Regression (issue 0076, attempt-4 coverage). Expected: one error for the +// param and one for the local; exit 1. +#import "modules/std.sx"; + +Box :: struct { total: s64 = 0; count: s64 = 0; } +update :: (self: *Box, n: s64) { self.total += n; self.count += 1; } + +Doer :: protocol { go :: (self: *Self, n: s64); } + +impl Doer for Box { + go :: (self: *Box, u8: s64) { + s2 := Box.{ total = 1 }; + update(@s2, u8); + self.total += s2.total; + } +} + +main :: () -> s32 { + b := Box.{}; + b.go(7); + return 0; +} diff --git a/examples/1123-diagnostics-reserved-name-catch-onfail.sx b/examples/1123-diagnostics-reserved-name-catch-onfail.sx new file mode 100644 index 0000000..83a84e2 --- /dev/null +++ b/examples/1123-diagnostics-reserved-name-catch-onfail.sx @@ -0,0 +1,28 @@ +// A reserved/builtin type name is rejected as the error-tag binding of a +// `catch` (`u8`) and of an `onfail` (`s64`). Both are reached through the +// exhaustive binding-name walk's `catch_expr` / `onfail_stmt` arms. The tag is +// a scalar, so before the diagnostic these spellings were silently accepted +// (they never reached the address-of mis-lowering) — the binding must still be +// rejected at its declaration. +// +// Regression (issue 0076, attempt-4 coverage). Expected: one error for each +// binding; exit 1. +#import "modules/std.sx"; + +E :: error { Bad } + +must :: (n: s32) -> !E { + if n < 0 { raise error.Bad; } + return; +} + +classify :: (n: s32) -> !E { + onfail s64 { } // onfail tag binding + must(n) catch u8 { return; }; // catch tag binding + return; +} + +main :: () -> s32 { + classify(-1) catch { }; + return 0; +} diff --git a/examples/1124-diagnostics-imported-reserved-destructure.sx b/examples/1124-diagnostics-imported-reserved-destructure.sx new file mode 100644 index 0000000..7b60588 --- /dev/null +++ b/examples/1124-diagnostics-imported-reserved-destructure.sx @@ -0,0 +1,15 @@ +// A reserved type name used as a DESTRUCTURE binding name (`s2`) is rejected +// even when it lives in an IMPORTED module — the exhaustive binding-name walk +// descends the `namespace_decl` an `mod :: #import` wraps and renders the +// diagnostic against that module's source (issue 0077's universal-coverage +// rule applied to the destructure form). Without it the binding reaches +// lowering and aborts LLVM verification. +// +// Regression (issues 0076 + 0077, attempt-4 coverage). Expected: one clean +// diagnostic pointing at the imported module's `s2, rest := ...`, exit 1. +#import "modules/std.sx"; +mod :: #import "1124-diagnostics-imported-reserved-destructure/mod.sx"; + +main :: () -> s32 { + return mod.run(); +} diff --git a/examples/1124-diagnostics-imported-reserved-destructure/mod.sx b/examples/1124-diagnostics-imported-reserved-destructure/mod.sx new file mode 100644 index 0000000..4ae0979 --- /dev/null +++ b/examples/1124-diagnostics-imported-reserved-destructure/mod.sx @@ -0,0 +1,8 @@ +#import "modules/std.sx"; + +pair :: () -> (s64, s64) { (1, 2) } + +run :: () -> s32 { + s2, rest := pair(); // destructure name in an IMPORTED module + return 0; +} diff --git a/examples/expected/1121-diagnostics-reserved-name-control-flow.exit b/examples/expected/1121-diagnostics-reserved-name-control-flow.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1121-diagnostics-reserved-name-control-flow.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1121-diagnostics-reserved-name-control-flow.stderr b/examples/expected/1121-diagnostics-reserved-name-control-flow.stderr new file mode 100644 index 0000000..1ecdc5c --- /dev/null +++ b/examples/expected/1121-diagnostics-reserved-name-control-flow.stderr @@ -0,0 +1,41 @@ +error: 's2' is a reserved type name and cannot be used as an identifier + --> examples/1121-diagnostics-reserved-name-control-flow.sx:18:5 + | +18 | s2, rest := pair(); // destructure name + | ^^^^^^^^^^^^^^^^^^^ + +error: 'u8' is a reserved type name and cannot be used as an identifier + --> examples/1121-diagnostics-reserved-name-control-flow.sx:19:5 + | +19 | if u8 := maybe() { } // if optional binding + | ^^^^^^^^^^^^^^^^^^^^ + +error: 's16' is a reserved type name and cannot be used as an identifier + --> examples/1121-diagnostics-reserved-name-control-flow.sx:20:5 + | +20 | while s16 := maybe() { break; } // while optional binding + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: 'bool' is a reserved type name and cannot be used as an identifier + --> examples/1121-diagnostics-reserved-name-control-flow.sx:22:5 + | +22 | for xs: (bool) { } // for capture name + | ^^^^^^^^^^^^^^^^^^ + +error: 's32' is a reserved type name and cannot be used as an identifier + --> examples/1121-diagnostics-reserved-name-control-flow.sx:23:5 + | +23 | for xs: (v, s32) { } // for index name + | ^^^^^^^^^^^^^^^^^^^^ + +error: 'string' is a reserved type name and cannot be used as an identifier + --> examples/1121-diagnostics-reserved-name-control-flow.sx:25:10 + | +25 | r := if opt == { // match-arm capture + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +26 | case .some: (string) { 0 } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +27 | case .none: { 0 } + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +28 | }; + | ^^^^^ diff --git a/examples/expected/1121-diagnostics-reserved-name-control-flow.stdout b/examples/expected/1121-diagnostics-reserved-name-control-flow.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1121-diagnostics-reserved-name-control-flow.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1122-diagnostics-reserved-name-impl-method.exit b/examples/expected/1122-diagnostics-reserved-name-impl-method.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1122-diagnostics-reserved-name-impl-method.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1122-diagnostics-reserved-name-impl-method.stderr b/examples/expected/1122-diagnostics-reserved-name-impl-method.stderr new file mode 100644 index 0000000..cc1cf72 --- /dev/null +++ b/examples/expected/1122-diagnostics-reserved-name-impl-method.stderr @@ -0,0 +1,11 @@ +error: 'u8' is a reserved type name and cannot be used as an identifier + --> examples/1122-diagnostics-reserved-name-impl-method.sx:19:24 + | +19 | go :: (self: *Box, u8: s64) { + | ^^ + +error: 's2' is a reserved type name and cannot be used as an identifier + --> examples/1122-diagnostics-reserved-name-impl-method.sx:20:9 + | +20 | s2 := Box.{ total = 1 }; + | ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/examples/expected/1122-diagnostics-reserved-name-impl-method.stdout b/examples/expected/1122-diagnostics-reserved-name-impl-method.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1122-diagnostics-reserved-name-impl-method.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1123-diagnostics-reserved-name-catch-onfail.exit b/examples/expected/1123-diagnostics-reserved-name-catch-onfail.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1123-diagnostics-reserved-name-catch-onfail.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stderr b/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stderr new file mode 100644 index 0000000..e6865b8 --- /dev/null +++ b/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stderr @@ -0,0 +1,11 @@ +error: 's64' is a reserved type name and cannot be used as an identifier + --> examples/1123-diagnostics-reserved-name-catch-onfail.sx:20:5 + | +20 | onfail s64 { } // onfail tag binding + | ^^^^^^^^^^^^^^ + +error: 'u8' is a reserved type name and cannot be used as an identifier + --> examples/1123-diagnostics-reserved-name-catch-onfail.sx:21:5 + | +21 | must(n) catch u8 { return; }; // catch tag binding + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stdout b/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1124-diagnostics-imported-reserved-destructure.exit b/examples/expected/1124-diagnostics-imported-reserved-destructure.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1124-diagnostics-imported-reserved-destructure.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1124-diagnostics-imported-reserved-destructure.stderr b/examples/expected/1124-diagnostics-imported-reserved-destructure.stderr new file mode 100644 index 0000000..2de26c2 --- /dev/null +++ b/examples/expected/1124-diagnostics-imported-reserved-destructure.stderr @@ -0,0 +1,5 @@ +error: 's2' is a reserved type name and cannot be used as an identifier + --> examples/1124-diagnostics-imported-reserved-destructure/mod.sx:6:5 + | + 6 | s2, rest := pair(); // destructure name in an IMPORTED module + | ^^^^^^^^^^^^^^^^^^^ diff --git a/examples/expected/1124-diagnostics-imported-reserved-destructure.stdout b/examples/expected/1124-diagnostics-imported-reserved-destructure.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1124-diagnostics-imported-reserved-destructure.stdout @@ -0,0 +1 @@ + diff --git a/issues/0076-stack-struct-addrof-passed-by-value.md b/issues/0076-stack-struct-addrof-passed-by-value.md index 06f247d..8ed80a1 100644 --- a/issues/0076-stack-struct-addrof-passed-by-value.md +++ b/issues/0076-stack-struct-addrof-passed-by-value.md @@ -11,10 +11,9 @@ > `*self`-mutation-losing copy). > > **Fix:** a declaration-site diagnostic in the existing semantic pass -> `src/ir/semantic_diagnostics.zig` (`UnknownTypeChecker`). New -> `checkBindingName` rejects any parameter name or `var` binding name (local or -> global, `:=` / typed-local forms) whose spelling collides with a reserved type -> name; `isReservedTypeName` defers to the parser's own classifier +> `src/ir/semantic_diagnostics.zig` (`UnknownTypeChecker`). `checkBindingName` +> rejects any binding name 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 @@ -22,10 +21,32 @@ > the `.identifier`-only address-of paths are correct once type-shaped names can > never be bound. The rejected `bareVarName` approach was never landed. > +> **Coverage is structural (attempt 4).** Earlier landings hand-walked a subset +> of binding-bearing nodes with a silent `else => {}`, so each review found a new +> leaking syntactic form (destructure names, `impl` method params/locals, `if` / +> `while` / `for` / match-arm / `catch` / `onfail` captures) that bypassed the +> check and hit the original LLVM verifier abort. `checkBindingNames` is now an +> **exhaustive `switch` over every `Node.Data` tag with NO `else` arm**: a future +> binding-bearing node type fails to compile until it is handled here, so +> coverage is enforced by the compiler rather than by a hand-maintained list. The +> check stays in the pre-lowering semantic pass (NOT moved to the `Scope.put` +> scope-registration choke point) because lowering is lazy — an UNCALLED +> function's bindings never reach `Scope.put`, yet they must still be rejected at +> their declaration (e.g. `examples/1119`'s never-called `takes_u8`). +> > **Regression tests:** > - `examples/0125-types-type-named-var-rejected.sx` — `:=` form (`s2`) rejected. > - `examples/1119-diagnostics-reserved-type-name-as-identifier.sx` — parameter > (`u8`), typed-local (`s64`, `bool`), and `:=` (`string`) forms rejected. +> - `examples/1121-diagnostics-reserved-name-control-flow.sx` — destructure name, +> `if` / `while` optional bindings, `for` capture + index names, match-arm +> capture. +> - `examples/1122-diagnostics-reserved-name-impl-method.sx` — `impl`-block method +> reserved param AND reserved local. +> - `examples/1123-diagnostics-reserved-name-catch-onfail.sx` — `catch` and +> `onfail` error-tag bindings. +> - `examples/1124-diagnostics-imported-reserved-destructure.sx` — destructure +> name reserved in an IMPORTED module (renders against that module's source). > - `examples/0135-types-self-streaming-nonreserved.sx` — positive: `*self` > streaming with non-reserved names (`hasher`, `ctx`) accumulates correctly via > both `update(@h, …)` and `h.update(…)`. diff --git a/src/ir/semantic_diagnostics.zig b/src/ir/semantic_diagnostics.zig index 9578c08..659bb26 100644 --- a/src/ir/semantic_diagnostics.zig +++ b/src/ir/semantic_diagnostics.zig @@ -84,91 +84,176 @@ pub const UnknownTypeChecker = struct { } } - /// Reserved-type-name binding walk (issues 0076, 0077). Visits every binding - /// site reachable from `node` — `var` / `:=` / typed-local declarations and - /// function / lambda / struct-method parameters, at any nesting depth — and - /// rejects each name that collides with a reserved/builtin type name. Walks - /// into expressions too, so a lambda nested in a call arg / struct literal is - /// reached. Deliberately filter-free (every module is walked) and context- - /// free (spelling is the sole criterion), distinct from the main-file-scoped - /// unknown-type walk. A node carrying its own `source_file` (every module's - /// top-level decls do) becomes the emit file for its whole subtree, restored - /// on exit so a sibling in another module isn't rendered against it. + /// Reserved-type-name binding walk (issues 0076, 0077). Visits every node + /// reachable from `node` and rejects each *binding name* — `var` / `:=` / + /// typed-local declarations, destructure names, function / lambda / method + /// parameters, `if` / `while` optional bindings, `for` capture + index + /// names, match-arm captures, and `catch` / `onfail` tag bindings — whose + /// spelling collides with a reserved/builtin type name. Such a spelling + /// parses as a `.type_expr`, so the address-of family in `lower.zig` never + /// sees the scoped local and mis-lowers it (a loaded aggregate passed + /// by value to a `ptr` param → LLVM verifier abort, or a silent + /// mutation-losing copy). Rejecting the name here, before lowering, keeps + /// the `.identifier`-only address-of paths correct with no lowering + /// special-case. + /// + /// The `switch` is EXHAUSTIVE — every `Node.Data` tag is listed and there + /// is NO `else` arm. A future binding-bearing node type therefore fails to + /// compile here until it is handled, so coverage is enforced by the + /// compiler rather than by remembering to extend a hand-maintained list. + /// (The check can't live at the scope-registration choke point in + /// `lower.zig`: lowering is lazy, so an UNCALLED function's bindings never + /// reach `Scope.put` — yet they must still be rejected at their + /// declaration.) Deliberately filter-free (every compiled module is walked) + /// and context-free (spelling is the sole criterion), distinct from the + /// main-file-scoped unknown-type walk. A node carrying its own + /// `source_file` (every module's top-level decls do) becomes the emit file + /// for its whole subtree, restored on exit so a sibling in another module + /// isn't rendered against it (issue 0077). fn checkBindingNames(self: UnknownTypeChecker, node: *const Node) void { const saved_file = self.diagnostics.current_source_file; defer self.diagnostics.current_source_file = saved_file; if (node.source_file) |sf| self.diagnostics.current_source_file = sf; switch (node.data) { + // ── Binding-introducing nodes: check the name(s), then recurse. ── .var_decl => |vd| { self.checkBindingName(vd.name, node.span); if (vd.value) |v| self.checkBindingNames(v); }, + .destructure_decl => |dd| { + for (dd.names) |n| self.checkBindingName(n, node.span); + self.checkBindingNames(dd.value); + }, .fn_decl => |fd| { - for (fd.params) |p| self.checkBindingName(p.name, p.name_span); + self.checkParamNames(fd.params); self.checkBindingNames(fd.body); }, .lambda => |lm| { - for (lm.params) |p| self.checkBindingName(p.name, p.name_span); + self.checkParamNames(lm.params); self.checkBindingNames(lm.body); }, - .const_decl => |cd| self.checkBindingNames(cd.value), - // A namespaced import (`mod :: #import "..."`) is wrapped here, its - // module decls held inline. Descend so an imported module's - // reserved-name binding is rejected too (issue 0077). - .namespace_decl => |nd| for (nd.decls) |d| self.checkBindingNames(d), - .struct_decl => |sd| for (sd.methods) |m| self.checkBindingNames(m), - .block => |b| for (b.stmts) |s| self.checkBindingNames(s), + .param => |p| { + self.checkBindingName(p.name, p.name_span); + if (p.default_expr) |de| self.checkBindingNames(de); + }, .if_expr => |ie| { + if (ie.binding_name) |bn| self.checkBindingName(bn, node.span); self.checkBindingNames(ie.condition); self.checkBindingNames(ie.then_branch); if (ie.else_branch) |e| self.checkBindingNames(e); }, .while_expr => |we| { + if (we.binding_name) |bn| self.checkBindingName(bn, node.span); self.checkBindingNames(we.condition); self.checkBindingNames(we.body); }, .for_expr => |fe| { + if (fe.capture_name.len != 0) self.checkBindingName(fe.capture_name, node.span); + if (fe.index_name) |idx| self.checkBindingName(idx, node.span); self.checkBindingNames(fe.iterable); if (fe.range_end) |re| self.checkBindingNames(re); self.checkBindingNames(fe.body); }, .match_expr => |me| { self.checkBindingNames(me.subject); - for (me.arms) |arm| self.checkBindingNames(arm.body); + for (me.arms) |arm| { + if (arm.capture) |cap| self.checkBindingName(cap, node.span); + if (arm.pattern) |p| self.checkBindingNames(p); + self.checkBindingNames(arm.body); + } }, + .match_arm => |arm| { + if (arm.capture) |cap| self.checkBindingName(cap, node.span); + if (arm.pattern) |p| self.checkBindingNames(p); + self.checkBindingNames(arm.body); + }, + .catch_expr => |ce| { + if (ce.binding) |b| self.checkBindingName(b, node.span); + self.checkBindingNames(ce.operand); + self.checkBindingNames(ce.body); + }, + .onfail_stmt => |os| { + if (os.binding) |b| self.checkBindingName(b, node.span); + self.checkBindingNames(os.body); + }, + // impl / protocol-default / foreign-class method bodies: each + // method introduces its own params + locals. A `#jni_main` / + // `#objc_class` bodied method is lowered (M1.2), so its reserved + // param/local names mis-lower the same as any other. + .impl_block => |ib| for (ib.methods) |m| self.checkBindingNames(m), + .protocol_decl => |pd| for (pd.methods) |m| { + if (m.default_body) |body| { + for (m.param_names) |pn| self.checkBindingName(pn, node.span); + self.checkBindingNames(body); + } + }, + .foreign_class_decl => |fcd| for (fcd.members) |member| switch (member) { + .method => |m| if (m.body) |body| { + for (m.param_names) |pn| self.checkBindingName(pn, node.span); + self.checkBindingNames(body); + }, + .field, .extends, .implements => {}, + }, + // ── Container / control-flow / expression nodes: recurse children + // so a binding nested anywhere below is still reached. ── + // A namespaced import (`mod :: #import "..."`) is wrapped here, its + // module decls held inline; descend so an imported module's + // reserved-name binding is rejected too (issue 0077). + .namespace_decl => |nd| for (nd.decls) |d| self.checkBindingNames(d), + .const_decl => |cd| self.checkBindingNames(cd.value), + .struct_decl => |sd| { + for (sd.methods) |m| self.checkBindingNames(m); + for (sd.constants) |c| self.checkBindingNames(c); + for (sd.field_defaults) |fdef| if (fdef) |d| self.checkBindingNames(d); + }, + .root => |r| for (r.decls) |d| self.checkBindingNames(d), + .block => |b| for (b.stmts) |s| self.checkBindingNames(s), .push_stmt => |ps| { self.checkBindingNames(ps.context_expr); self.checkBindingNames(ps.body); }, + .jni_env_block => |jb| { + self.checkBindingNames(jb.env); + self.checkBindingNames(jb.body); + }, .defer_stmt => |ds| self.checkBindingNames(ds.expr), - .onfail_stmt => |os| self.checkBindingNames(os.body), .return_stmt => |r| if (r.value) |v| self.checkBindingNames(v), .raise_stmt => |rs| self.checkBindingNames(rs.tag), .assignment => |a| { self.checkBindingNames(a.value); self.checkBindingNames(a.target); }, - .multi_assign => |ma| for (ma.values) |v| self.checkBindingNames(v), - .destructure_decl => |dd| self.checkBindingNames(dd.value), + .multi_assign => |ma| { + for (ma.targets) |t| self.checkBindingNames(t); + for (ma.values) |v| self.checkBindingNames(v); + }, .call => |c| { self.checkBindingNames(c.callee); for (c.args) |a| self.checkBindingNames(a); }, + .ffi_intrinsic_call => |fic| for (fic.args) |a| self.checkBindingNames(a), .binary_op => |b| { self.checkBindingNames(b.lhs); self.checkBindingNames(b.rhs); }, + .chained_comparison => |cc| for (cc.operands) |o| self.checkBindingNames(o), .unary_op => |u| self.checkBindingNames(u.operand), .field_access => |fa| self.checkBindingNames(fa.object), .index_expr => |ix| { self.checkBindingNames(ix.object); self.checkBindingNames(ix.index); }, + .slice_expr => |sx| { + self.checkBindingNames(sx.object); + if (sx.start) |s| self.checkBindingNames(s); + if (sx.end) |e| self.checkBindingNames(e); + }, .struct_literal => |sl| { for (sl.field_inits) |fi| self.checkBindingNames(fi.value); if (sl.init_block) |ib| self.checkBindingNames(ib); }, .array_literal => |al| for (al.elements) |e| self.checkBindingNames(e), + .tuple_literal => |tl| for (tl.elements) |e| self.checkBindingNames(e.value), .force_unwrap => |fu| self.checkBindingNames(fu.operand), .null_coalesce => |nc| { self.checkBindingNames(nc.lhs); @@ -176,13 +261,63 @@ pub const UnknownTypeChecker = struct { }, .deref_expr => |de| self.checkBindingNames(de.operand), .try_expr => |te| self.checkBindingNames(te.operand), - .catch_expr => |ce| { - self.checkBindingNames(ce.operand); - self.checkBindingNames(ce.body); - }, .comptime_expr => |ce| self.checkBindingNames(ce.expr), + .insert_expr => |ins| self.checkBindingNames(ins.expr), .spread_expr => |se| self.checkBindingNames(se.operand), - else => {}, + // ── Leaves & pure type-expression nodes: no binding sites below. ── + // Type-expression subtrees carry only type names (no value + // bindings); enum / union / error-set declarations carry only field + // types + comptime constants. Listing each tag explicitly (rather + // than an `else`) is what forces a future binding-bearing node to be + // reconsidered here. + .int_literal, + .float_literal, + .bool_literal, + .string_literal, + .identifier, + .enum_literal, + .type_expr, + .enum_decl, + .union_decl, + .error_set_decl, + .import_decl, + .array_type_expr, + .slice_type_expr, + .parameterized_type_expr, + .pointer_type_expr, + .many_pointer_type_expr, + .optional_type_expr, + .error_type_expr, + .caller_location, + .pack_index_type_expr, + .comptime_pack_ref, + .null_literal, + .break_expr, + .continue_expr, + .undef_literal, + .inferred_type, + .builtin_expr, + .compiler_expr, + .foreign_expr, + .library_decl, + .framework_decl, + .function_type_expr, + .closure_type_expr, + .tuple_type_expr, + .ufcs_alias, + .c_import_decl, + => {}, + } + } + + /// Check each parameter's binding name (`fn` / lambda params are stored as + /// `Param` values, not child nodes, so they're walked here rather than via + /// the node `switch`). A param default expression can itself nest bindings + /// (a lambda default), so recurse into it. + fn checkParamNames(self: UnknownTypeChecker, params: []const ast.Param) void { + for (params) |p| { + self.checkBindingName(p.name, p.name_span); + if (p.default_expr) |de| self.checkBindingNames(de); } } From 6433eb6155ca64ae8c1aea9077c84548505bda3b Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 22:06:56 +0300 Subject: [PATCH 4/4] fix(diagnostics): point reserved-type-name binding errors at the binding (issue 0076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reserved-type-name binding diagnostic fired correctly but underlined the enclosing statement / if / while / for / match / protocol / #objc_class block because every binding-name check reused the parent `node.span`. Thread each binding name's own span through the AST and parser, and pass it to `checkBindingNames`: - ast: add name spans to VarDecl, DestructureDecl, If/WhileExpr, ForExpr (capture + index), MatchArm, Catch/OnFailStmt, Protocol/ForeignMethodDecl. - parser: populate each span at the binding site from the name token's loc; destructure reuses each target identifier's own span. - semantic_diagnostics: every checkBindingName call now passes the binding's own span — no site falls back to node.span. fn/lambda params already used Param.name_span. Carets now land on the offending identifier itself. New regression examples/1125 asserts the protocol default-body and sx-defined #objc_class method param spans; 0125/1119-1124 expected updated to the precise carets. --- ...-diagnostics-reserved-name-method-param.sx | 30 +++++++++++ .../0125-types-type-named-var-rejected.stderr | 2 +- ...cs-reserved-type-name-as-identifier.stderr | 6 +-- ...nostics-imported-reserved-type-name.stderr | 2 +- ...gnostics-reserved-name-control-flow.stderr | 28 ++++------ ...agnostics-reserved-name-impl-method.stderr | 2 +- ...gnostics-reserved-name-catch-onfail.stderr | 8 +-- ...stics-imported-reserved-destructure.stderr | 2 +- ...iagnostics-reserved-name-method-param.exit | 1 + ...gnostics-reserved-name-method-param.stderr | 11 ++++ ...gnostics-reserved-name-method-param.stdout | 1 + ...076-stack-struct-addrof-passed-by-value.md | 14 +++++ src/ast.zig | 11 ++++ src/ir/semantic_diagnostics.zig | 24 ++++----- src/parser.zig | 53 +++++++++++++++---- 15 files changed, 144 insertions(+), 51 deletions(-) create mode 100644 examples/1125-diagnostics-reserved-name-method-param.sx create mode 100644 examples/expected/1125-diagnostics-reserved-name-method-param.exit create mode 100644 examples/expected/1125-diagnostics-reserved-name-method-param.stderr create mode 100644 examples/expected/1125-diagnostics-reserved-name-method-param.stdout diff --git a/examples/1125-diagnostics-reserved-name-method-param.sx b/examples/1125-diagnostics-reserved-name-method-param.sx new file mode 100644 index 0000000..12d383a --- /dev/null +++ b/examples/1125-diagnostics-reserved-name-method-param.sx @@ -0,0 +1,30 @@ +// A reserved/builtin type name used as a PARAMETER name is rejected inside the +// two method-with-body forms that carry their params as bare name lists rather +// than `Param` nodes: a protocol default-body method (`u8`) and a sx-defined +// foreign-class (`#objc_class`) method (`s16`). The declaration-site diagnostic +// underlines the OFFENDING PARAMETER itself, not the enclosing `protocol` / +// `#objc_class` block — each method's `param_name_spans` is threaded from the +// parser so the caret lands on the parameter token. +// +// Regression (issue 0076, attempt-5 span precision). Expected: one error per +// offending parameter, each caret on the parameter name; exit 1. +#import "modules/std.sx"; +#import "modules/compiler.sx"; + +Greeter :: protocol { + greet :: (self: *Self, u8: s64) -> s64 { + return u8; + } +} + +SxFoo :: #objc_class("SxFoo") { + counter: s32; + + bump :: (self: *Self, s16: s32) { + self.counter += s16; + } +} + +main :: () -> s32 { + return 0; +} diff --git a/examples/expected/0125-types-type-named-var-rejected.stderr b/examples/expected/0125-types-type-named-var-rejected.stderr index d2798b1..6b02235 100644 --- a/examples/expected/0125-types-type-named-var-rejected.stderr +++ b/examples/expected/0125-types-type-named-var-rejected.stderr @@ -2,4 +2,4 @@ error: 's2' is a reserved type name and cannot be used as an identifier --> /Users/agra/projects/sx/examples/0125-types-type-named-var-rejected.sx:10:5 | 10 | s2 := 42; - | ^^^^^^^^^ + | ^^ diff --git a/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stderr b/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stderr index 4b5e737..b15964c 100644 --- a/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stderr +++ b/examples/expected/1119-diagnostics-reserved-type-name-as-identifier.stderr @@ -8,16 +8,16 @@ error: 's64' is a reserved type name and cannot be used as an identifier --> /Users/agra/projects/sx/examples/1119-diagnostics-reserved-type-name-as-identifier.sx:12:5 | 12 | s64 : s32 = 3; - | ^^^^^^^^^^^^^^ + | ^^^ error: 'bool' is a reserved type name and cannot be used as an identifier --> /Users/agra/projects/sx/examples/1119-diagnostics-reserved-type-name-as-identifier.sx:13:5 | 13 | bool : bool = true; - | ^^^^^^^^^^^^^^^^^^^ + | ^^^^ error: 'string' is a reserved type name and cannot be used as an identifier --> /Users/agra/projects/sx/examples/1119-diagnostics-reserved-type-name-as-identifier.sx:14:5 | 14 | string := "x"; - | ^^^^^^^^^^^^^^ + | ^^^^^^ diff --git a/examples/expected/1120-diagnostics-imported-reserved-type-name.stderr b/examples/expected/1120-diagnostics-imported-reserved-type-name.stderr index f5469a0..9b7397d 100644 --- a/examples/expected/1120-diagnostics-imported-reserved-type-name.stderr +++ b/examples/expected/1120-diagnostics-imported-reserved-type-name.stderr @@ -2,4 +2,4 @@ error: 's2' is a reserved type name and cannot be used as an identifier --> examples/1120-diagnostics-imported-reserved-type-name/mod.sx:11:5 | 11 | s2 := Box.{ total = 0, count = 0 }; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^ diff --git a/examples/expected/1121-diagnostics-reserved-name-control-flow.stderr b/examples/expected/1121-diagnostics-reserved-name-control-flow.stderr index 1ecdc5c..4032ee2 100644 --- a/examples/expected/1121-diagnostics-reserved-name-control-flow.stderr +++ b/examples/expected/1121-diagnostics-reserved-name-control-flow.stderr @@ -2,40 +2,34 @@ error: 's2' is a reserved type name and cannot be used as an identifier --> examples/1121-diagnostics-reserved-name-control-flow.sx:18:5 | 18 | s2, rest := pair(); // destructure name - | ^^^^^^^^^^^^^^^^^^^ + | ^^ error: 'u8' is a reserved type name and cannot be used as an identifier - --> examples/1121-diagnostics-reserved-name-control-flow.sx:19:5 + --> examples/1121-diagnostics-reserved-name-control-flow.sx:19:8 | 19 | if u8 := maybe() { } // if optional binding - | ^^^^^^^^^^^^^^^^^^^^ + | ^^ error: 's16' is a reserved type name and cannot be used as an identifier - --> examples/1121-diagnostics-reserved-name-control-flow.sx:20:5 + --> examples/1121-diagnostics-reserved-name-control-flow.sx:20:11 | 20 | while s16 := maybe() { break; } // while optional binding - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^^ error: 'bool' is a reserved type name and cannot be used as an identifier - --> examples/1121-diagnostics-reserved-name-control-flow.sx:22:5 + --> examples/1121-diagnostics-reserved-name-control-flow.sx:22:14 | 22 | for xs: (bool) { } // for capture name - | ^^^^^^^^^^^^^^^^^^ + | ^^^^ error: 's32' is a reserved type name and cannot be used as an identifier - --> examples/1121-diagnostics-reserved-name-control-flow.sx:23:5 + --> examples/1121-diagnostics-reserved-name-control-flow.sx:23:17 | 23 | for xs: (v, s32) { } // for index name - | ^^^^^^^^^^^^^^^^^^^^ + | ^^^ error: 'string' is a reserved type name and cannot be used as an identifier - --> examples/1121-diagnostics-reserved-name-control-flow.sx:25:10 + --> examples/1121-diagnostics-reserved-name-control-flow.sx:26:22 | -25 | r := if opt == { // match-arm capture - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 26 | case .some: (string) { 0 } - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -27 | case .none: { 0 } - | ^^^^^^^^^^^^^^^^^^^^^^^^^ -28 | }; - | ^^^^^ + | ^^^^^^ diff --git a/examples/expected/1122-diagnostics-reserved-name-impl-method.stderr b/examples/expected/1122-diagnostics-reserved-name-impl-method.stderr index cc1cf72..393565c 100644 --- a/examples/expected/1122-diagnostics-reserved-name-impl-method.stderr +++ b/examples/expected/1122-diagnostics-reserved-name-impl-method.stderr @@ -8,4 +8,4 @@ error: 's2' is a reserved type name and cannot be used as an identifier --> examples/1122-diagnostics-reserved-name-impl-method.sx:20:9 | 20 | s2 := Box.{ total = 1 }; - | ^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^ diff --git a/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stderr b/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stderr index e6865b8..f63404e 100644 --- a/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stderr +++ b/examples/expected/1123-diagnostics-reserved-name-catch-onfail.stderr @@ -1,11 +1,11 @@ error: 's64' is a reserved type name and cannot be used as an identifier - --> examples/1123-diagnostics-reserved-name-catch-onfail.sx:20:5 + --> examples/1123-diagnostics-reserved-name-catch-onfail.sx:20:12 | 20 | onfail s64 { } // onfail tag binding - | ^^^^^^^^^^^^^^ + | ^^^ error: 'u8' is a reserved type name and cannot be used as an identifier - --> examples/1123-diagnostics-reserved-name-catch-onfail.sx:21:5 + --> examples/1123-diagnostics-reserved-name-catch-onfail.sx:21:19 | 21 | must(n) catch u8 { return; }; // catch tag binding - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^ diff --git a/examples/expected/1124-diagnostics-imported-reserved-destructure.stderr b/examples/expected/1124-diagnostics-imported-reserved-destructure.stderr index 2de26c2..668ff2c 100644 --- a/examples/expected/1124-diagnostics-imported-reserved-destructure.stderr +++ b/examples/expected/1124-diagnostics-imported-reserved-destructure.stderr @@ -2,4 +2,4 @@ error: 's2' is a reserved type name and cannot be used as an identifier --> examples/1124-diagnostics-imported-reserved-destructure/mod.sx:6:5 | 6 | s2, rest := pair(); // destructure name in an IMPORTED module - | ^^^^^^^^^^^^^^^^^^^ + | ^^ diff --git a/examples/expected/1125-diagnostics-reserved-name-method-param.exit b/examples/expected/1125-diagnostics-reserved-name-method-param.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1125-diagnostics-reserved-name-method-param.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1125-diagnostics-reserved-name-method-param.stderr b/examples/expected/1125-diagnostics-reserved-name-method-param.stderr new file mode 100644 index 0000000..d3b4b67 --- /dev/null +++ b/examples/expected/1125-diagnostics-reserved-name-method-param.stderr @@ -0,0 +1,11 @@ +error: 'u8' is a reserved type name and cannot be used as an identifier + --> examples/1125-diagnostics-reserved-name-method-param.sx:15:28 + | +15 | greet :: (self: *Self, u8: s64) -> s64 { + | ^^ + +error: 's16' is a reserved type name and cannot be used as an identifier + --> examples/1125-diagnostics-reserved-name-method-param.sx:23:27 + | +23 | bump :: (self: *Self, s16: s32) { + | ^^^ diff --git a/examples/expected/1125-diagnostics-reserved-name-method-param.stdout b/examples/expected/1125-diagnostics-reserved-name-method-param.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1125-diagnostics-reserved-name-method-param.stdout @@ -0,0 +1 @@ + diff --git a/issues/0076-stack-struct-addrof-passed-by-value.md b/issues/0076-stack-struct-addrof-passed-by-value.md index 8ed80a1..fe2013e 100644 --- a/issues/0076-stack-struct-addrof-passed-by-value.md +++ b/issues/0076-stack-struct-addrof-passed-by-value.md @@ -34,6 +34,17 @@ > function's bindings never reach `Scope.put`, yet they must still be rejected at > their declaration (e.g. `examples/1119`'s never-called `takes_u8`). > +> **Span precision (attempt 5).** Every binding form now carries its own +> name span in the AST (`VarDecl.name_span`, `DestructureDecl.name_spans`, +> `IfExpr`/`WhileExpr.binding_span`, `ForExpr.capture_span`/`index_span`, +> `MatchArm.capture_span`, `CatchExpr`/`OnFailStmt.binding_span`, +> `Protocol`/`ForeignMethodDecl.param_name_spans`), populated by the parser at +> each binding site. `checkBindingNames` passes that span to the diagnostic, so +> the caret underlines the offending identifier itself instead of the enclosing +> statement / `if` / `match` / `protocol` / `#objc_class` block. No call site +> falls back to the parent `node.span`. Regular `fn`/lambda params already used +> `Param.name_span`. +> > **Regression tests:** > - `examples/0125-types-type-named-var-rejected.sx` — `:=` form (`s2`) rejected. > - `examples/1119-diagnostics-reserved-type-name-as-identifier.sx` — parameter @@ -47,6 +58,9 @@ > `onfail` error-tag bindings. > - `examples/1124-diagnostics-imported-reserved-destructure.sx` — destructure > name reserved in an IMPORTED module (renders against that module's source). +> - `examples/1125-diagnostics-reserved-name-method-param.sx` — protocol +> default-body method param AND sx-defined `#objc_class` method param, each +> caret landing on the parameter token. > - `examples/0135-types-self-streaming-nonreserved.sx` — positive: `*self` > streaming with non-reserved names (`hasher`, `ctx`) accumulates correctly via > both `update(@h, …)` and `h.update(…)`. diff --git a/src/ast.zig b/src/ast.zig index 7ce14fa..fa085e5 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -272,6 +272,7 @@ pub const IfExpr = struct { is_inline: bool, // true for `if cond then a else b` is_comptime: bool = false, // true for `inline if` — compile-time branch elimination binding_name: ?[]const u8 = null, // for `if val := expr { ... }` optional binding + binding_span: ?Span = null, // span of `binding_name` (set iff `binding_name` is) }; pub const MatchExpr = struct { @@ -285,6 +286,7 @@ pub const MatchArm = struct { body: *Node, is_break: bool, capture: ?[]const u8 = null, // payload binding name: case .variant: (name) { ... } + capture_span: ?Span = null, // span of `capture` (set iff `capture` is) }; pub const ConstDecl = struct { @@ -295,6 +297,7 @@ pub const ConstDecl = struct { pub const VarDecl = struct { name: []const u8, + name_span: Span, type_annotation: ?*Node, value: ?*Node, is_foreign: bool = false, @@ -329,6 +332,7 @@ pub const MultiAssign = struct { pub const DestructureDecl = struct { names: []const []const u8, + name_spans: []const Span, // one per entry in `names`, same order value: *Node, }; @@ -449,6 +453,7 @@ pub const TryExpr = struct { pub const CatchExpr = struct { operand: *Node, binding: ?[]const u8 = null, + binding_span: ?Span = null, // span of `binding` (set iff `binding` is) body: *Node, is_match_body: bool = false, }; @@ -458,6 +463,7 @@ pub const CatchExpr = struct { /// a bare expression (`onfail EXPR;`). pub const OnFailStmt = struct { binding: ?[]const u8 = null, + binding_span: ?Span = null, // span of `binding` (set iff `binding` is) body: *Node, }; @@ -551,13 +557,16 @@ pub const WhileExpr = struct { condition: *Node, body: *Node, binding_name: ?[]const u8 = null, // for `while val := expr { ... }` optional binding + binding_span: ?Span = null, // span of `binding_name` (set iff `binding_name` is) }; pub const ForExpr = struct { iterable: *Node, body: *Node, capture_name: []const u8, + capture_span: ?Span = null, // span of `capture_name` (null when omitted, e.g. `for 0..N { }`) index_name: ?[]const u8 = null, + index_span: ?Span = null, // span of `index_name` (set iff `index_name` is) /// Range form `for start..end (i) { }`: `iterable` is the start, `range_end` /// the (exclusive) end. Null for the iterate-a-collection form /// (`for coll : (x) { }`). For the range form `capture_name` is the cursor @@ -645,6 +654,7 @@ pub const ProtocolMethodDecl = struct { name: []const u8, params: []const *Node, // type_expr nodes for parameter types (excluding implicit self) param_names: []const []const u8, // parameter names (excluding implicit self) + param_name_spans: []const Span = &.{}, // one per `param_names` entry; empty for synthesized methods return_type: ?*Node, // null = void return default_body: ?*Node, // null = required method, non-null = default implementation }; @@ -670,6 +680,7 @@ pub const ForeignMethodDecl = struct { name: []const u8, params: []const *Node, // type_expr nodes — first is `*Self` for instance methods param_names: []const []const u8, + param_name_spans: []const Span = &.{}, // one per `param_names` entry; empty for synthesized methods return_type: ?*Node, // null = void is_static: bool = false, // true for `static name :: ...` jni_descriptor_override: ?[]const u8 = null, // `#jni_method_descriptor("(Sig)Ret")` — JNI runtime only diff --git a/src/ir/semantic_diagnostics.zig b/src/ir/semantic_diagnostics.zig index 659bb26..33e297d 100644 --- a/src/ir/semantic_diagnostics.zig +++ b/src/ir/semantic_diagnostics.zig @@ -117,11 +117,11 @@ pub const UnknownTypeChecker = struct { switch (node.data) { // ── Binding-introducing nodes: check the name(s), then recurse. ── .var_decl => |vd| { - self.checkBindingName(vd.name, node.span); + self.checkBindingName(vd.name, vd.name_span); if (vd.value) |v| self.checkBindingNames(v); }, .destructure_decl => |dd| { - for (dd.names) |n| self.checkBindingName(n, node.span); + for (dd.names, dd.name_spans) |n, sp| self.checkBindingName(n, sp); self.checkBindingNames(dd.value); }, .fn_decl => |fd| { @@ -137,19 +137,19 @@ pub const UnknownTypeChecker = struct { if (p.default_expr) |de| self.checkBindingNames(de); }, .if_expr => |ie| { - if (ie.binding_name) |bn| self.checkBindingName(bn, node.span); + if (ie.binding_name) |bn| self.checkBindingName(bn, ie.binding_span); self.checkBindingNames(ie.condition); self.checkBindingNames(ie.then_branch); if (ie.else_branch) |e| self.checkBindingNames(e); }, .while_expr => |we| { - if (we.binding_name) |bn| self.checkBindingName(bn, node.span); + if (we.binding_name) |bn| self.checkBindingName(bn, we.binding_span); self.checkBindingNames(we.condition); self.checkBindingNames(we.body); }, .for_expr => |fe| { - if (fe.capture_name.len != 0) self.checkBindingName(fe.capture_name, node.span); - if (fe.index_name) |idx| self.checkBindingName(idx, node.span); + if (fe.capture_name.len != 0) self.checkBindingName(fe.capture_name, fe.capture_span); + if (fe.index_name) |idx| self.checkBindingName(idx, fe.index_span); self.checkBindingNames(fe.iterable); if (fe.range_end) |re| self.checkBindingNames(re); self.checkBindingNames(fe.body); @@ -157,23 +157,23 @@ pub const UnknownTypeChecker = struct { .match_expr => |me| { self.checkBindingNames(me.subject); for (me.arms) |arm| { - if (arm.capture) |cap| self.checkBindingName(cap, node.span); + if (arm.capture) |cap| self.checkBindingName(cap, arm.capture_span); if (arm.pattern) |p| self.checkBindingNames(p); self.checkBindingNames(arm.body); } }, .match_arm => |arm| { - if (arm.capture) |cap| self.checkBindingName(cap, node.span); + if (arm.capture) |cap| self.checkBindingName(cap, arm.capture_span); if (arm.pattern) |p| self.checkBindingNames(p); self.checkBindingNames(arm.body); }, .catch_expr => |ce| { - if (ce.binding) |b| self.checkBindingName(b, node.span); + if (ce.binding) |b| self.checkBindingName(b, ce.binding_span); self.checkBindingNames(ce.operand); self.checkBindingNames(ce.body); }, .onfail_stmt => |os| { - if (os.binding) |b| self.checkBindingName(b, node.span); + if (os.binding) |b| self.checkBindingName(b, os.binding_span); self.checkBindingNames(os.body); }, // impl / protocol-default / foreign-class method bodies: each @@ -183,13 +183,13 @@ pub const UnknownTypeChecker = struct { .impl_block => |ib| for (ib.methods) |m| self.checkBindingNames(m), .protocol_decl => |pd| for (pd.methods) |m| { if (m.default_body) |body| { - for (m.param_names) |pn| self.checkBindingName(pn, node.span); + for (m.param_names, m.param_name_spans) |pn, sp| self.checkBindingName(pn, sp); self.checkBindingNames(body); } }, .foreign_class_decl => |fcd| for (fcd.members) |member| switch (member) { .method => |m| if (m.body) |body| { - for (m.param_names) |pn| self.checkBindingName(pn, node.span); + for (m.param_names, m.param_name_spans) |pn, sp| self.checkBindingName(pn, sp); self.checkBindingNames(body); }, .field, .extends, .implements => {}, diff --git a/src/parser.zig b/src/parser.zig index 13980c1..7113e13 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -145,6 +145,7 @@ pub const Parser = struct { return self.fail("expected identifier at top level"); } const name = self.tokenSlice(self.current); + const name_span = ast.Span{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); // IDENT :: ... @@ -157,7 +158,7 @@ pub const Parser = struct { // IDENT : type = value; (typed variable) if (self.current.tag == .colon) { self.advance(); - return self.parseTypedBinding(name, start); + return self.parseTypedBinding(name, name_span, start); } // IDENT := value; (variable) @@ -165,7 +166,7 @@ pub const Parser = struct { self.advance(); const value = try self.parseExpr(); try self.expectSemicolonAfter(value); - return try self.createNode(start, .{ .var_decl = .{ .name = name, .type_annotation = null, .value = value } }); + return try self.createNode(start, .{ .var_decl = .{ .name = name, .name_span = name_span, .type_annotation = null, .value = value } }); } return self.fail("expected '::', ':=', or ':' after identifier"); @@ -382,7 +383,7 @@ pub const Parser = struct { } }); } - fn parseTypedBinding(self: *Parser, name: []const u8, start_pos: u32) anyerror!*Node { + fn parseTypedBinding(self: *Parser, name: []const u8, name_span: ast.Span, start_pos: u32) anyerror!*Node { // After `name :` // Parse type const type_node = try self.parseTypeExpr(); @@ -400,13 +401,13 @@ pub const Parser = struct { self.advance(); const value = try self.parseExpr(); try self.expectSemicolonAfter(value); - return try self.createNode(start_pos, .{ .var_decl = .{ .name = name, .type_annotation = type_node, .value = value } }); + return try self.createNode(start_pos, .{ .var_decl = .{ .name = name, .name_span = name_span, .type_annotation = type_node, .value = value } }); } if (self.current.tag == .semicolon) { // name : type; (default-initialized variable) self.advance(); - return try self.createNode(start_pos, .{ .var_decl = .{ .name = name, .type_annotation = type_node, .value = null } }); + return try self.createNode(start_pos, .{ .var_decl = .{ .name = name, .name_span = name_span, .type_annotation = type_node, .value = null } }); } if (self.current.tag == .hash_foreign) { @@ -426,6 +427,7 @@ pub const Parser = struct { try self.expect(.semicolon); return try self.createNode(start_pos, .{ .var_decl = .{ .name = name, + .name_span = name_span, .type_annotation = type_node, .value = null, .is_foreign = true, @@ -1167,6 +1169,7 @@ pub const Parser = struct { var param_types = std.ArrayList(*Node).empty; var param_names = std.ArrayList([]const u8).empty; + var param_name_spans = std.ArrayList(ast.Span).empty; while (self.current.tag != .r_paren and self.current.tag != .eof) { if (param_types.items.len > 0) { @@ -1178,6 +1181,7 @@ pub const Parser = struct { return self.fail("expected parameter name in protocol method"); } const pname = self.tokenSlice(self.current); + try param_name_spans.append(self.allocator, .{ .start = self.current.loc.start, .end = self.current.loc.end }); self.advance(); try self.expect(.colon); const ptype = try self.parseTypeExpr(); @@ -1205,6 +1209,7 @@ pub const Parser = struct { .name = method_name, .params = try param_types.toOwnedSlice(self.allocator), .param_names = try param_names.toOwnedSlice(self.allocator), + .param_name_spans = try param_name_spans.toOwnedSlice(self.allocator), .return_type = return_type, .default_body = default_body, }); @@ -1418,6 +1423,7 @@ pub const Parser = struct { .name = member_name, .params = &.{}, .param_names = &.{}, + .param_name_spans = &.{}, .return_type = ret_type, .is_static = true, .jni_descriptor_override = null, @@ -1431,6 +1437,7 @@ pub const Parser = struct { var param_types = std.ArrayList(*Node).empty; var param_names = std.ArrayList([]const u8).empty; + var param_name_spans = std.ArrayList(ast.Span).empty; while (self.current.tag != .r_paren and self.current.tag != .eof) { if (param_types.items.len > 0) { try self.expect(.comma); @@ -1440,6 +1447,7 @@ pub const Parser = struct { return self.fail("expected parameter name in '#jni_class' method"); } const pname = self.tokenSlice(self.current); + try param_name_spans.append(self.allocator, .{ .start = self.current.loc.start, .end = self.current.loc.end }); self.advance(); try self.expect(.colon); const ptype = try self.parseTypeExpr(); @@ -1521,6 +1529,7 @@ pub const Parser = struct { .name = member_name, .params = try param_types.toOwnedSlice(self.allocator), .param_names = try param_names.toOwnedSlice(self.allocator), + .param_name_spans = try param_name_spans.toOwnedSlice(self.allocator), .return_type = return_type, .is_static = is_static, .jni_descriptor_override = desc_override, @@ -1999,6 +2008,7 @@ pub const Parser = struct { const saved_prev_end = self.prev_end; const start = self.current.loc.start; const name = self.tokenSlice(self.current); + const name_span = ast.Span{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); if (self.current.tag == .colon_colon) { @@ -2009,11 +2019,11 @@ pub const Parser = struct { self.advance(); const value = try self.parseExpr(); try self.expectSemicolonAfter(value); - return try self.createNode(start, .{ .var_decl = .{ .name = name, .type_annotation = null, .value = value } }); + return try self.createNode(start, .{ .var_decl = .{ .name = name, .name_span = name_span, .type_annotation = null, .value = value } }); } if (self.current.tag == .colon) { self.advance(); - return self.parseTypedBinding(name, start); + return self.parseTypedBinding(name, name_span, start); } // Multi-target assignment: ident, expr, ... = expr, expr, ...; @@ -2094,8 +2104,10 @@ pub const Parser = struct { const start = self.current.loc.start; self.advance(); var binding: ?[]const u8 = null; + var binding_span: ?ast.Span = null; if (self.current.tag == .identifier and self.peekNext() == .l_brace) { binding = self.tokenSlice(self.current); + binding_span = .{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); } const saved_onfail = self.in_onfail_body; @@ -2108,7 +2120,7 @@ pub const Parser = struct { try self.expect(.semicolon); break :blk e; }; - return try self.createNode(start, .{ .onfail_stmt = .{ .binding = binding, .body = body } }); + return try self.createNode(start, .{ .onfail_stmt = .{ .binding = binding, .binding_span = binding_span, .body = body } }); } // Break statement: break; @@ -2539,8 +2551,10 @@ pub const Parser = struct { // catch e EXPR — binding + bare-expression body self.advance(); // consume 'catch' var binding: ?[]const u8 = null; + var binding_span: ?ast.Span = null; if (self.current.tag == .identifier) { binding = self.tokenSlice(self.current); + binding_span = .{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); } var is_match_body = false; @@ -2559,6 +2573,7 @@ pub const Parser = struct { expr = try self.createNode(expr.span.start, .{ .catch_expr = .{ .operand = expr, .binding = binding, + .binding_span = binding_span, .body = body, .is_match_body = is_match_body, } }); @@ -2906,6 +2921,7 @@ pub const Parser = struct { // Detect: identifier followed by := if (self.current.tag == .identifier and self.peekNext() == .colon_equal) { const binding_name = self.tokenSlice(self.current); + const binding_span = ast.Span{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); // skip identifier self.advance(); // skip := const source_expr = try self.parseExpr(); @@ -2925,6 +2941,7 @@ pub const Parser = struct { .else_branch = else_branch, .is_inline = false, .binding_name = binding_name, + .binding_span = binding_span, } }); } @@ -3026,6 +3043,7 @@ pub const Parser = struct { // Optional binding: while val := expr { ... } if (self.current.tag == .identifier and self.peekNext() == .colon_equal) { const binding_name = self.tokenSlice(self.current); + const binding_span = ast.Span{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); // skip identifier self.advance(); // skip := const source_expr = try self.parseExpr(); @@ -3034,6 +3052,7 @@ pub const Parser = struct { .condition = source_expr, .body = body, .binding_name = binding_name, + .binding_span = binding_span, } }); } @@ -3087,7 +3106,9 @@ pub const Parser = struct { } var capture_name: []const u8 = ""; + var capture_span: ?ast.Span = null; var index_name: ?[]const u8 = null; + var index_span: ?ast.Span = null; var capture_by_ref = false; if (range_end != null) { @@ -3099,6 +3120,7 @@ pub const Parser = struct { try self.expect(.l_paren); if (self.current.tag != .identifier) return self.fail("expected cursor variable name"); capture_name = self.tokenSlice(self.current); + capture_span = .{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); try self.expect(.r_paren); } @@ -3113,11 +3135,13 @@ pub const Parser = struct { } if (self.current.tag != .identifier) return self.fail("expected capture variable name"); capture_name = self.tokenSlice(self.current); + capture_span = .{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); if (self.current.tag == .comma) { self.advance(); if (self.current.tag != .identifier) return self.fail("expected index variable name"); index_name = self.tokenSlice(self.current); + index_span = .{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); } try self.expect(.r_paren); @@ -3129,7 +3153,9 @@ pub const Parser = struct { .iterable = iterable, .body = body, .capture_name = capture_name, + .capture_span = capture_span, .index_name = index_name, + .index_span = index_span, .range_end = range_end, .capture_by_ref = capture_by_ref, } }); @@ -3154,9 +3180,11 @@ pub const Parser = struct { // a capture is exactly `( )`; anything else is the // arm body (an expression) and is left for the body parse below. var capture: ?[]const u8 = null; + var capture_span: ?ast.Span = null; if (self.current.tag == .l_paren and self.isLoneIdentParen()) { self.advance(); // '(' capture = self.tokenSlice(self.current); + capture_span = .{ .start = self.current.loc.start, .end = self.current.loc.end }; self.advance(); // ident try self.expect(.r_paren); } @@ -3165,7 +3193,7 @@ pub const Parser = struct { self.advance(); try self.expect(.semicolon); const body = try self.createNode(arm_start, .{ .block = .{ .stmts = &.{} } }); - try arms.append(self.allocator, .{ .pattern = pattern, .body = body, .is_break = true, .capture = capture }); + try arms.append(self.allocator, .{ .pattern = pattern, .body = body, .is_break = true, .capture = capture, .capture_span = capture_span }); } else if (self.current.tag == .fat_arrow) { // Short form: (ident) => expr; self.advance(); @@ -3175,7 +3203,7 @@ pub const Parser = struct { // `;` is an arm terminator, not a value-discard — match arms are // exempt from the block trailing-`;` rule). const body = try self.createNode(arm_start, .{ .block = .{ .stmts = try self.allocator.dupe(*Node, &.{expr}), .produces_value = true } }); - try arms.append(self.allocator, .{ .pattern = pattern, .body = body, .is_break = false, .capture = capture }); + try arms.append(self.allocator, .{ .pattern = pattern, .body = body, .is_break = false, .capture = capture, .capture_span = capture_span }); } else { const stmts_start = self.current.loc.start; var stmts = std.ArrayList(*Node).empty; @@ -3186,7 +3214,7 @@ pub const Parser = struct { // yields its last statement's value — which, for a braced-block // arm body, still respects that inner block's own flag. const body = try self.createNode(stmts_start, .{ .block = .{ .stmts = try stmts.toOwnedSlice(self.allocator), .produces_value = true } }); - try arms.append(self.allocator, .{ .pattern = pattern, .body = body, .is_break = false, .capture = capture }); + try arms.append(self.allocator, .{ .pattern = pattern, .body = body, .is_break = false, .capture = capture, .capture_span = capture_span }); } } // Optional else arm (default) @@ -3539,16 +3567,19 @@ pub const Parser = struct { self.advance(); // All targets must be plain identifiers var names = std.ArrayList([]const u8).empty; + var name_spans = std.ArrayList(ast.Span).empty; for (targets.items) |target| { if (target.data != .identifier) { return self.fail("destructuring targets must be identifiers"); } try names.append(self.allocator, target.data.identifier.name); + try name_spans.append(self.allocator, target.span); } const value = try self.parseExpr(); try self.expectSemicolonAfter(value); return try self.createNode(start, .{ .destructure_decl = .{ .names = try names.toOwnedSlice(self.allocator), + .name_spans = try name_spans.toOwnedSlice(self.allocator), .value = value, } }); }