Compare commits
3 Commits
39488133c9
...
45befed698
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45befed698 | ||
|
|
f13f4abfb1 | ||
|
|
ab3c9202ff |
54
CLAUDE.md
54
CLAUDE.md
@@ -405,7 +405,10 @@ any can be advanced independently.
|
||||
After any code change:
|
||||
```sh
|
||||
zig build # must compile
|
||||
zig build test # must pass
|
||||
zig build test # must pass — runs the Zig unit tests
|
||||
# AND the full examples/ + issues/
|
||||
# regression corpus (a failing example
|
||||
# fails the build)
|
||||
```
|
||||
|
||||
After completing a phase's final step, run the phase's end-to-end verification command listed in `current/PLAN.md`.
|
||||
@@ -415,9 +418,27 @@ After completing a phase's final step, run the phase's end-to-end verification c
|
||||
After any compiler change:
|
||||
|
||||
1. **Build**: `zig build && zig build test`
|
||||
2. **Run regression tests**: `bash tests/run_examples.sh`
|
||||
- Every test must show `ok` (currently 324)
|
||||
- Zero failures, zero timeouts
|
||||
- `zig build test` runs the unit tests **and** the example/issue corpus as
|
||||
one suite — a failing example fails the build. The corpus is driven by a
|
||||
pure-Zig test (`src/corpus_run.test.zig`) that spawns the installed `sx`
|
||||
binary per example (subprocess-isolated, with a per-run timeout), so no
|
||||
shell script is involved.
|
||||
2. **Regenerate snapshots**: `zig build test -Dupdate-goldens`
|
||||
- Flips the corpus test to write each example's expected
|
||||
`.exit`/`.stdout`/`.stderr` (+ `.ir` where one already exists) from
|
||||
freshly-normalized output instead of asserting against it. This is the
|
||||
preferred way to update snapshots — no shell script needed.
|
||||
- A test is still keyed off its `expected/<name>.exit` marker, so seed an
|
||||
empty marker first for a brand-new example (see "Adding a feature").
|
||||
3. **Standalone corpus run** (optional): `bash tests/run_examples.sh`
|
||||
- Runs the corpus independent of `zig build test` (used by
|
||||
`tools/verify-step.sh`). `--update` still regenerates snapshots and
|
||||
produces byte-identical output to `-Dupdate-goldens`.
|
||||
- Every test must show `ok` (currently 626); zero failures, zero timeouts.
|
||||
- Uses GNU `timeout`/`gtimeout` when present (Homebrew coreutils on macOS)
|
||||
and runs without a per-test wall-clock guard when neither is found.
|
||||
- The two normalizers (`normalize`/`normalize_ir` in the script and the
|
||||
mirrors in `src/corpus_run.test.zig`) must stay in lockstep.
|
||||
|
||||
### Test layout
|
||||
|
||||
@@ -445,12 +466,12 @@ dirs) under the same `XXXX-` prefix.
|
||||
|
||||
### Snapshot integrity
|
||||
|
||||
**Never run `--update` while tests are failing.** The `--update` flag blindly overwrites expected output with whatever the compiler produces — including error messages. If you update snapshots during a broken state, the test suite will "pass" against garbage output and real regressions become invisible.
|
||||
**Never regenerate snapshots while tests are failing.** `-Dupdate-goldens` (and the legacy `--update`) blindly overwrite expected output with whatever the compiler produces — including error messages. If you regenerate during a broken state, the test suite will "pass" against garbage output and real regressions become invisible.
|
||||
|
||||
Safe workflow:
|
||||
1. Fix the code until `bash tests/run_examples.sh` passes against the **existing** snapshots.
|
||||
2. Only run `--update` when you've intentionally changed output (new feature, new test, changed formatting).
|
||||
3. After `--update`, review the diff (`git diff examples/expected/ issues/expected/`) to confirm no error messages or empty output were captured.
|
||||
1. Fix the code until `zig build test` passes against the **existing** snapshots.
|
||||
2. Only run `zig build test -Dupdate-goldens` when you've intentionally changed output (new feature, new test, changed formatting).
|
||||
3. After regenerating, review the diff (`git diff examples/expected/ issues/expected/`) to confirm no error messages or empty output were captured.
|
||||
|
||||
### Adding a new language feature
|
||||
|
||||
@@ -461,19 +482,20 @@ There is no monolithic smoke file — each feature is its own focused example.
|
||||
2. Run it: `./zig-out/bin/sx run examples/XXXX-<category>-<name>.sx`
|
||||
3. Seed the marker and capture expected output:
|
||||
`: > examples/expected/XXXX-<category>-<name>.exit` then
|
||||
`bash tests/run_examples.sh --update`
|
||||
4. Verify all tests still pass: `bash tests/run_examples.sh`
|
||||
`zig build test -Dupdate-goldens`
|
||||
4. Verify all tests still pass: `zig build test`
|
||||
|
||||
### Test file roles
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `examples/XXXX-category-name.sx` | Focused feature example — one feature per file. |
|
||||
| `examples/expected/XXXX-category-name.{exit,stdout,stderr}` | Expected exit code + the two output streams. Regenerate with `--update`. |
|
||||
| `examples/expected/XXXX-category-name.{exit,stdout,stderr}` | Expected exit code + the two output streams. Regenerate with `zig build test -Dupdate-goldens`. |
|
||||
| `examples/expected/XXXX-category-name.ir` | Optional `sx ir` snapshot — present only where lowering shape is locked. |
|
||||
| `issues/NNNN-slug.md` | Open-issue / bug-report writeup (mark RESOLVED in a banner when fixed; the `.md` stays). |
|
||||
| `issues/NNNN-slug.sx` (+ `issues/NNNN-slug/`) | The issue's minimal repro, co-located with the `.md`. A repro with an `issues/expected/NNNN-slug.exit` marker runs in the suite; unpinned ones don't. |
|
||||
| `tests/run_examples.sh` | Test runner. Scans `examples/` and `issues/`; compares stdout/stderr/exit (+ optional IR) per test. |
|
||||
| `src/corpus_run.test.zig` | The corpus runner inside `zig build test` — spawns `sx` per example, diffs stdout/stderr/exit (+ optional IR); regenerates snapshots under `-Dupdate-goldens`. |
|
||||
| `tests/run_examples.sh` | Standalone shell runner (used by `tools/verify-step.sh`); same compare + `--update` as the Zig test. |
|
||||
|
||||
### Unit test file convention
|
||||
|
||||
@@ -496,8 +518,8 @@ All Zig unit tests live in separate `*.test.zig` files alongside the source they
|
||||
open bug, `issues/NNNN-slug.{md,sx}` (repro co-located with the writeup).
|
||||
2. Run it: `./zig-out/bin/sx run <path>.sx`
|
||||
3. Seed the marker (`: > <root>/expected/<name>.exit`) and capture expected:
|
||||
`bash tests/run_examples.sh --update`
|
||||
4. Verify: `bash tests/run_examples.sh`
|
||||
`zig build test -Dupdate-goldens`
|
||||
4. Verify: `zig build test`
|
||||
|
||||
### Resolving an open issue
|
||||
|
||||
@@ -505,8 +527,8 @@ When a bug filed under `issues/NNNN-slug.{md,sx}` is fixed:
|
||||
|
||||
1. Move the repro into the feature suite as a regression test:
|
||||
`git mv issues/NNNN-slug.sx examples/XXXX-<category>-<name>.sx`.
|
||||
2. Seed `examples/expected/XXXX-<category>-<name>.exit`, capture with `--update`,
|
||||
and review the diff.
|
||||
2. Seed `examples/expected/XXXX-<category>-<name>.exit`, capture with
|
||||
`zig build test -Dupdate-goldens`, and review the diff.
|
||||
3. Tighten the example's comment header to describe the feature (keep a one-line
|
||||
`Regression (issue NNNN)` note for provenance).
|
||||
4. Mark `issues/NNNN-slug.md` RESOLVED with a short banner (root cause + fix +
|
||||
|
||||
33
build.zig
33
build.zig
@@ -193,28 +193,49 @@ pub fn build(b: *std.Build) void {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
// Corpus paths for the LSP corpus-sweep test (src/lsp/corpus_sweep.test.zig).
|
||||
// Inject absolute corpus dirs at configure time so the in-process analyzer
|
||||
// sweep is CWD-independent; the test still ENUMERATES the directory
|
||||
// contents at runtime (new examples are covered with no test edit).
|
||||
// Corpus paths for the corpus tests (src/lsp/corpus_sweep.test.zig — the
|
||||
// in-process analyzer sweep — and src/corpus_run.test.zig — the end-to-end
|
||||
// example/issue runner). Inject absolute corpus dirs + the installed `sx`
|
||||
// binary path at configure time so the tests are CWD-independent; the
|
||||
// runner still ENUMERATES the directory contents at runtime, so new
|
||||
// examples are covered with no test edit.
|
||||
const corpus_opts = b.addOptions();
|
||||
corpus_opts.addOption([]const u8, "examples_dir", b.path("examples").getPath(b));
|
||||
corpus_opts.addOption([]const u8, "issues_dir", b.path("issues").getPath(b));
|
||||
corpus_opts.addOption([]const u8, "library_dir", b.path("library").getPath(b));
|
||||
// Absolute path to the installed `sx` binary the corpus runner spawns per
|
||||
// example. The runner test depends on the install step (below) so this
|
||||
// exists — and so the sibling library/ tree the binary loads is in place.
|
||||
corpus_opts.addOption([]const u8, "sx_exe", b.getInstallPath(.bin, "sx"));
|
||||
// `zig build test -Dupdate-goldens` flips src/corpus_run.test.zig from
|
||||
// verify mode to regenerate mode: it overwrites each example's expected
|
||||
// .exit/.stdout/.stderr (+ .ir where one exists) with freshly-normalized
|
||||
// output instead of asserting against it. The in-build equivalent of the
|
||||
// legacy `run_examples.sh --update`.
|
||||
const update_goldens = b.option(
|
||||
bool,
|
||||
"update-goldens",
|
||||
"Regenerate example/issue snapshots instead of verifying them (use with `zig build test`)",
|
||||
) orelse false;
|
||||
corpus_opts.addOption(bool, "update_goldens", update_goldens);
|
||||
mod.addOptions("corpus_paths", corpus_opts);
|
||||
|
||||
const mod_tests = b.addTest(.{
|
||||
.root_module = mod,
|
||||
});
|
||||
const run_mod_tests = b.addRunArtifact(mod_tests);
|
||||
// src/corpus_run.test.zig spawns the installed `sx` binary per example, so
|
||||
// the mod test binary must not run until `zig-out/bin/sx` + `zig-out/library`
|
||||
// are installed. This is what folds the full example/issue regression suite
|
||||
// into `zig build test` — no shell script, just a Zig test.
|
||||
run_mod_tests.step.dependOn(b.getInstallStep());
|
||||
|
||||
const exe_tests = b.addTest(.{
|
||||
.root_module = exe.root_module,
|
||||
});
|
||||
const run_exe_tests = b.addRunArtifact(exe_tests);
|
||||
|
||||
const test_step = b.step("test", "Run tests");
|
||||
const test_step = b.step("test", "Run unit tests + the example/issue regression suite");
|
||||
test_step.dependOn(&run_mod_tests.step);
|
||||
test_step.dependOn(&run_exe_tests.step);
|
||||
|
||||
}
|
||||
|
||||
29
examples/0781-modules-same-name-enum-payload-own-wins.sx
Normal file
29
examples/0781-modules-same-name-enum-payload-own-wins.sx
Normal file
@@ -0,0 +1,29 @@
|
||||
// Own-wins for ENUM-PAYLOAD type registration over a NAMESPACED import.
|
||||
// Regression (issue 0132, broader class).
|
||||
//
|
||||
// `#import "modules/std.sx"` carries the stdlib `event.Event` struct — it is
|
||||
// NAMESPACED (reachable only as `event.Event`), never flat-visible. This file
|
||||
// ALSO authors its OWN `Event :: struct { code }`, used as the payload of the
|
||||
// enum `Wrap`. The payload type name `Event` must resolve at REGISTRATION to
|
||||
// THIS file's own `Event` (which has `code`), not the namespaced stdlib struct.
|
||||
//
|
||||
// Fail-before: `registerEnumDecl` built the tagged-union body through the
|
||||
// stateless `type_bridge.buildEnumInfo`, whose flat `findByName` picked the
|
||||
// wrong same-name author — `got`'s payload became the stdlib `Event`, so
|
||||
// `e.code` errored "field 'code' not found on type 'Event'". Fixed by threading
|
||||
// the visibility-aware resolver (`*Lowering` as the `resolveInner` hook) through
|
||||
// `buildEnumInfo` / `buildUnionInfo`, matching what `registerStructDecl` already
|
||||
// does for struct fields.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
Event :: struct { code: i64; }
|
||||
Wrap :: enum { none; got: Event; }
|
||||
|
||||
main :: () {
|
||||
w : Wrap = .got(.{ code = 7 });
|
||||
if w == {
|
||||
case .got: (e) { print("code={}\n", e.code); }
|
||||
case .none: print("none\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Own-wins for an INLINE struct field's member type over a NAMESPACED import.
|
||||
// Regression (issue 0132's broader class — the inline-decl resolution boundary).
|
||||
//
|
||||
// `Holder.inner` is an inline `struct { e: Event }`. The member type `Event`
|
||||
// must resolve to THIS file's `Event` (which has `code`), not the namespaced
|
||||
// stdlib `event.Event` struct carried by `#import "modules/std.sx"` (reachable
|
||||
// only as `event.Event`, never bare).
|
||||
//
|
||||
// Fail-before: `Lowering.resolveTypeWithBindings` delegated inline `struct_decl`
|
||||
// field types to the FLAT `type_bridge.resolveAstType`, dropping the visibility
|
||||
// context — so `e: Event` resolved via global `findByName` to the stdlib struct
|
||||
// and `h.inner.e.code` errored "field 'code' not found on type 'Event'". Fixed
|
||||
// by routing inline enum/struct/union decls through the `inner` recursion hook
|
||||
// with `self` (visibility-aware), the same own-wins rule top-level decls use.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
Event :: struct { code: i64; }
|
||||
Holder :: struct { inner: struct { e: Event; }; }
|
||||
|
||||
main :: () {
|
||||
h : Holder = ---;
|
||||
h.inner.e = .{ code = 5 };
|
||||
print("code={}\n", h.inner.e.code);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
code=7
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
code=5
|
||||
@@ -0,0 +1,203 @@
|
||||
# 0132 — protocol method return/param type resolves to the WRONG same-name type (visibility-unaware registration)
|
||||
|
||||
> **ROOT CAUSE CORRECTED (2026-06-13).** The original write-up (kept in
|
||||
> "Original hypothesis" below) guessed this was about an inferred
|
||||
> protocol-return enum TypeId "not carrying payload struct field types".
|
||||
> That is **not** the cause. A ground-truth trace (instrumented build)
|
||||
> shows the real bug: **`registerProtocolDecl` resolves method
|
||||
> parameter/return type NAMES through a flat, visibility-UNAWARE lookup**
|
||||
> (`type_bridge.resolveAstType` → `resolveNamed` → global `findByName`),
|
||||
> so when the named type has a same-name shadow (another module also
|
||||
> declares that name), it picks the WRONG one. In the repro the user's
|
||||
> `Event` enum collides with the stdlib `library/modules/std/event.sx`
|
||||
> `Event :: struct` (pulled in by `#import "modules/std.sx"`, std.sx:101,
|
||||
> namespaced as `event`). The protocol grabs the stdlib struct; the
|
||||
> annotation path grabs the user enum. Hence inferred fails, annotated
|
||||
> works.
|
||||
|
||||
## Symptom
|
||||
|
||||
One-line: a protocol (dynamic-dispatch) method whose declared parameter
|
||||
or return type NAME also exists in another module resolves to the wrong
|
||||
type, because protocol signature registration is not visibility-aware.
|
||||
|
||||
- **Observed (repro):** `error: enum literal '.escape' has no destination
|
||||
type to resolve against` on `if e.key == .escape { ... }`, where `e` is
|
||||
the payload bound by `case .key_up: (e)` on a value whose type was
|
||||
inferred from a protocol method returning `Event`.
|
||||
- **Why that error:** the protocol method's cached `ret_type` is the
|
||||
stdlib `Event` **struct** (empty of the user's variants), not the
|
||||
user's `Event` **tagged_union**. So `ev := g_plat.one_event()` types
|
||||
`ev` as a plain struct; the `case .key_up:(e)` match finds no
|
||||
tagged-union variant, binds `e` to `.unresolved`; `e.key` on an
|
||||
`.unresolved` object silently returns a placeholder (the cascade guard
|
||||
in `lower.zig:emitFieldError` suppresses the field error on
|
||||
`.unresolved`); so `.escape` then has an `.unresolved` destination and
|
||||
emits the reported diagnostic.
|
||||
- **Expected:** bare `Event` inside the protocol resolves to the user's
|
||||
own `Event` (the visibility-correct author), exactly as an explicit
|
||||
`ev : Event = …` annotation already does. The repro then prints
|
||||
`escape!`, exit 0.
|
||||
|
||||
Surfaced building the downstream `m3te` app at `main.sx:222` —
|
||||
`for g_plat.poll_events() (*ev) { … case .key_up: (e) { if e.key == .escape … } }`,
|
||||
where `g_plat : Platform` is a `modules/platform/api.sx` protocol and
|
||||
`poll_events :: () -> []Event` returns `ui.Event`. m3te imports `std`
|
||||
(which carries the namespaced `event.Event` struct) AND has its own
|
||||
`ui.Event`, so the protocol's flat lookup picks the wrong `Event` — the
|
||||
same collision as the minimal repro.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Minimal, standalone (only depends on `modules/std.sx`). The trigger is
|
||||
the type NAME `Event` colliding with `std/event.sx`'s `Event` struct:
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
Keycode :: enum { unknown; escape; enter; }
|
||||
KeyData :: struct { key: Keycode; }
|
||||
Event :: enum { none; key_up: KeyData; } // <-- name collides with std/event.sx `Event :: struct`
|
||||
|
||||
Plat :: protocol { one_event :: () -> Event; }
|
||||
|
||||
Impl :: struct { dummy: i64; }
|
||||
impl Plat for Impl {
|
||||
one_event :: (self: *Impl) -> Event { return .key_up(.{ key = .escape }); }
|
||||
}
|
||||
|
||||
main :: () {
|
||||
impl : Impl = .{ dummy = 0 };
|
||||
g_plat : Plat = xx @impl;
|
||||
ev := g_plat.one_event(); // type INFERRED from protocol return
|
||||
if ev == {
|
||||
case .key_up: (e) {
|
||||
if e.key == .escape { print("escape!\n"); } // <-- errors here
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `./zig-out/bin/sx run issues/0132-protocol-return-enum-case-payload-field-unresolved.sx`
|
||||
|
||||
Actual:
|
||||
```
|
||||
error: enum literal '.escape' has no destination type to resolve against
|
||||
--> ...:NN:NN
|
||||
|
|
||||
| if e.key == .escape { print("escape!\n"); }
|
||||
| ^^^^^^^
|
||||
```
|
||||
|
||||
### Decisive bisection (verified)
|
||||
|
||||
| Variant | Result | Why |
|
||||
|---|---|---|
|
||||
| Repro as above (name `Event`, inferred) | **FAILS** | protocol flat-resolves `Event` → stdlib `event.Event` struct (104) |
|
||||
| Rename the user type `Event` → `Evt` everywhere | **OK** (`escape!`) | no same-name shadow → flat lookup gets the only `Evt` |
|
||||
| Keep `Event` but annotate `ev : Event = g_plat.one_event()` | **OK** | annotation uses the visibility-aware `resolveNominalLeaf` → user enum (152) |
|
||||
| Concrete fn (non-protocol) returns `Event`, same body | **OK** | concrete fn signatures already resolve via `self.resolveType` (visibility-aware) |
|
||||
| Protocol returns a plain struct / a plain enum named `Event` | varies | same root cause: flat lookup picks the colliding author |
|
||||
|
||||
Ground-truth TypeIds (from an instrumented build): the protocol method's
|
||||
`ret_type` = **104** (`tag=struct name=Event`, the stdlib placeholder);
|
||||
the annotation resolves `Event` = **152** (`tag=tagged_union name=Event`,
|
||||
the user type with the `key_up → KeyData` payload). Two distinct authors
|
||||
of the name `Event`; the flat path picks 104, the visibility-aware path
|
||||
picks 152.
|
||||
|
||||
## Fix
|
||||
|
||||
Make protocol method signature registration visibility-aware, mirroring
|
||||
what concrete functions and `registerStructDecl` already do.
|
||||
|
||||
In `src/ir/protocols.zig` `registerProtocolDecl` (~lines 289–316), pin
|
||||
the visibility context to the protocol's declaring module and resolve
|
||||
through the source-aware helpers instead of the flat resolver:
|
||||
|
||||
- param types: `self.l.resolveParamTypeInSource(pd.source_file, p)`
|
||||
(keep the `Self → *void` special-case)
|
||||
- return type: `self.l.resolveTypeInSource(pd.source_file, rt)`
|
||||
(keep the `Self → *void` special-case)
|
||||
|
||||
Both helpers already exist (`src/ir/lower.zig:670` / `:684`) and are the
|
||||
exact tool for "resolve a type in its DEFINING module's visibility
|
||||
context". `ProtocolDecl.source_file` is already stamped by
|
||||
`resolveImports` for this purpose (`src/ast.zig:817`). The
|
||||
**parameterized**-protocol path (`instantiateParamProtocol`,
|
||||
`src/ir/lower/protocol.zig:119`) ALREADY does exactly this (pins
|
||||
`current_source_file = pd.source_file` and resolves via
|
||||
`resolveTypeWithBindings`); this change brings the NON-parameterized path
|
||||
to parity.
|
||||
|
||||
No silent default is introduced: the visibility-aware path emits real
|
||||
diagnostics for genuinely not-visible / ambiguous names and poisons with
|
||||
`.unresolved` (per CLAUDE.md "Silent fallback defaults" rules).
|
||||
|
||||
## Broader latent risk (same class — track separately)
|
||||
|
||||
The same visibility-unaware flat resolution at REGISTRATION time also
|
||||
affects **enum payloads** and **union field types** (CONFIRMED failing),
|
||||
because `registerEnumDecl` / `registerUnionDecl` build their bodies via
|
||||
the stateless `type_bridge.buildEnumInfo` / `buildUnionInfo`, which
|
||||
flat-resolve type names. Repro shape (confirmed):
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
Event :: struct { code: i64; } // collides with std/event.sx Event
|
||||
Wrap :: enum { none; got: Event; } // payload type Event → flat-resolves to the WRONG Event
|
||||
main :: () {
|
||||
w : Wrap = .{}; w = .got(.{ code = 7 });
|
||||
if w == { case .got: (e) { print("{}\n", e.code); } } // error: field 'code' not found on type 'Event'
|
||||
}
|
||||
```
|
||||
|
||||
(Structs are already SAFE — `registerStructDecl` resolves fields via the
|
||||
visibility-aware `self.resolveType`, `src/ir/lower/nominal.zig:637`.)
|
||||
|
||||
Suggested broader fix: inject a resolver into `buildEnumInfo` /
|
||||
`buildUnionInfo` (an `anytype` adapter with a `resolve(node) → TypeId`
|
||||
method) so the stateless inline callers keep the flat resolver while the
|
||||
stateful `registerEnumDecl` / `registerUnionDecl` pass a
|
||||
`self.resolveType`-backed (visibility-aware) one — single source of truth
|
||||
for the body shape, two resolution strategies. Also switch the struct-
|
||||
constant annotation resolve (`src/ir/lower/nominal.zig:706`) to
|
||||
`self.resolveType`. See the session notes for the full design.
|
||||
|
||||
## Verification
|
||||
|
||||
`./zig-out/bin/sx run issues/0132-…sx` prints `escape!` exit 0; then
|
||||
`zig build && zig build test` and `bash tests/run_examples.sh` all green.
|
||||
When resolved, promote the repro to
|
||||
`examples/04xx-protocols-protocol-return-name-collision.sx` per the
|
||||
"Resolving an open issue" procedure.
|
||||
|
||||
## Notes
|
||||
|
||||
- Diagnostic site (where the symptom surfaces, NOT the root cause):
|
||||
`src/ir/lower/expr.zig:920` (`lowerEnumLiteral`, `target == .unresolved`
|
||||
branch).
|
||||
- Root cause site: `src/ir/protocols.zig:299,309`
|
||||
(`registerProtocolDecl`, flat `type_bridge.resolveAstType` for
|
||||
param/return types).
|
||||
- The minimal repro previously used a legacy `Impl_methods :: { … }`
|
||||
block; that compiles but crashes at runtime independently. The repro
|
||||
here uses the canonical `impl Plat for Impl { … }` so that, post-fix,
|
||||
it actually runs and prints `escape!`.
|
||||
- Workaround in downstream code (annotate the binding, or rename the
|
||||
type to avoid the std collision) is NOT applied in m3te per the
|
||||
IMPASSABLE RULES — the fix belongs in the compiler.
|
||||
|
||||
---
|
||||
|
||||
## Original hypothesis (SUPERSEDED — kept for provenance)
|
||||
|
||||
The first write-up framed this as: "when an enum value's type is inferred
|
||||
from a protocol method's declared return, a `case`-payload binding loses
|
||||
its struct-field types", and pointed the fix at the call-result TypeId in
|
||||
`src/ir/calls.zig` / `src/ir/conversions.zig` "not carrying the variant
|
||||
payload struct's field types". The instrumented trace disproved this: the
|
||||
inferred and annotated `Event` are two DIFFERENT registered types (a
|
||||
same-name shadow), and the divergence is purely that protocol signature
|
||||
registration uses a flat, visibility-unaware lookup. The payload-field
|
||||
machinery is fine once the correct `Event` reaches the binding.
|
||||
@@ -0,0 +1,44 @@
|
||||
// issue 0132 — protocol method return/param type resolves to the WRONG
|
||||
// same-name type (visibility-unaware registration).
|
||||
//
|
||||
// ROOT CAUSE (corrected — see the .md): `registerProtocolDecl` resolves
|
||||
// the method return type `Event` through a flat, visibility-UNAWARE lookup
|
||||
// (type_bridge.resolveAstType → findByName). The user's `Event` enum
|
||||
// collides by NAME with the stdlib `std/event.sx` `Event :: struct`
|
||||
// (pulled in by `#import "modules/std.sx"`, namespaced as `event`). The
|
||||
// flat lookup picks the stdlib struct, so `ev := g_plat.one_event()` is
|
||||
// typed as a fieldless struct; the `case .key_up:(e)` payload then binds
|
||||
// `.unresolved`, and `.escape` has no destination type.
|
||||
//
|
||||
// EXPECT (today): build FAILS —
|
||||
// error: enum literal '.escape' has no destination type to resolve against
|
||||
// EXPECT (after fix): prints `escape!`, exit 0.
|
||||
//
|
||||
// Proof it's a name collision: rename `Event` -> `Evt` everywhere and the
|
||||
// inferred form compiles and prints `escape!`. Annotating
|
||||
// `ev : Event = g_plat.one_event();` also sidesteps it (the annotation
|
||||
// path is visibility-aware). See the .md for the full bisection.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
Keycode :: enum { unknown; escape; enter; }
|
||||
KeyData :: struct { key: Keycode; }
|
||||
Event :: enum { none; key_up: KeyData; }
|
||||
|
||||
Plat :: protocol { one_event :: () -> Event; }
|
||||
|
||||
Impl :: struct { dummy: i64; }
|
||||
impl Plat for Impl {
|
||||
one_event :: (self: *Impl) -> Event { return .key_up(.{ key = .escape }); }
|
||||
}
|
||||
|
||||
main :: () {
|
||||
impl : Impl = .{ dummy = 0 };
|
||||
g_plat : Plat = xx @impl;
|
||||
ev := g_plat.one_event(); // type INFERRED from protocol return
|
||||
if ev == {
|
||||
case .key_up: (e) {
|
||||
if e.key == .escape { print("escape!\n"); } // <-- errors here
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# 0133 — assigning a struct LITERAL to a union member panics ("unresolved type reached LLVM emission")
|
||||
|
||||
## Symptom
|
||||
|
||||
One-line: `u.b = .{ ... }` where `b` is a NAMED-struct member of a plain
|
||||
`union` compiles to an `.unresolved`-typed `struct_init` and trips the
|
||||
LLVM-emission tripwire. The RHS struct literal never receives its target
|
||||
type (the union member's type), so it lowers as `.unresolved`.
|
||||
|
||||
- **Observed:** `thread … panic: unresolved type reached LLVM emission —
|
||||
a type resolution failure was not diagnosed/aborted`
|
||||
(`src/backend/llvm/types.zig:176`), reached from
|
||||
`emitStructInit` (`src/backend/llvm/ops.zig:1211`) because the
|
||||
`struct_init` instruction's `ty` is `.unresolved`.
|
||||
- **Expected:** the literal types itself as the union member's struct type
|
||||
(here `S`) and stores into the member — exactly as it already does when
|
||||
the left-hand side is a STRUCT field.
|
||||
|
||||
This is PRE-EXISTING (reproduces on `master` / before any issue-0132
|
||||
work) and ORTHOGONAL to type-name resolution: it reproduces with a
|
||||
unique, non-colliding type name. Surfaced while testing issue 0132's
|
||||
broader-latent fix (making enum/union payload registration
|
||||
visibility-aware) — that fix makes a *colliding*-name union member
|
||||
resolve to the correct type, at which point this separate codegen bug is
|
||||
what blocks the end-to-end union case.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Minimal, standalone (only `modules/std.sx`):
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
S :: struct { code: i64; }
|
||||
U :: union { a: i64; b: S; }
|
||||
|
||||
main :: () {
|
||||
u : U = ---;
|
||||
u.b = .{ code = 9 }; // <-- panics: struct literal has no target type
|
||||
print("code={}\n", u.b.code);
|
||||
}
|
||||
```
|
||||
|
||||
Run: `./zig-out/bin/sx run issues/0133-union-member-struct-literal-assign-unresolved-panic.sx`
|
||||
→ panics today; the fix should make it print `code=9`, exit 0.
|
||||
|
||||
### Bisection (what does / does not trigger it)
|
||||
|
||||
| Variant | Result |
|
||||
|---|---|
|
||||
| `u.b = .{ code = 9 }` (union member ← struct LITERAL) | **PANICS** |
|
||||
| `o.b = .{ code = 9 }` where `o : Outer = struct { a; b: S }` (STRUCT member ← literal) | **OK** |
|
||||
| `s : S = .{ code = 9 }; u.b = s` (union member ← pre-made value) | **OK** |
|
||||
| `u : U = ---` then only read (no literal assign) | **OK** |
|
||||
|
||||
So the trigger is exactly the conjunction **(LHS is a union member) AND
|
||||
(RHS is a struct literal)**. A struct-field LHS propagates the target
|
||||
type to the literal; a pre-made value needs no target type. Only the
|
||||
union-member-lvalue + literal-RHS combination drops it.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
> Assigning a struct literal to a NAMED-struct member of a plain `union`
|
||||
> panics with "unresolved type reached LLVM emission". Repro:
|
||||
> `issues/0133-union-member-struct-literal-assign-unresolved-panic.sx`
|
||||
> (expect a panic today; the fix should make it print `code=9`, exit 0).
|
||||
>
|
||||
> The `struct_init` instruction for the RHS literal `.{ code = 9 }` has
|
||||
> `ty == .unresolved` — the literal was lowered without a target type, so
|
||||
> it could not resolve to the union member's struct type `S`. The panic
|
||||
> is the codegen tripwire in `src/backend/llvm/types.zig:176`
|
||||
> (`toLLVMTypeInfo`), reached from `emitStructInit`
|
||||
> (`src/backend/llvm/ops.zig:1211`).
|
||||
>
|
||||
> Root area: assignment lowering in `src/ir/lower.zig` —
|
||||
> `lowerAssignment`'s `.field_access` target path. Issue 0094 already
|
||||
> routes the lvalue POINTER through the shared `fieldLvaluePtr` (which
|
||||
> correctly resolves union/tagged-union direct members — that's why a
|
||||
> pre-made value stores fine). The gap is the RHS TARGET TYPE: for a
|
||||
> STRUCT-field LHS the code sets `self.target_type` to the field's type
|
||||
> before lowering the RHS (so a struct literal types itself), but for a
|
||||
> UNION-member LHS that target-type propagation is missing, so the
|
||||
> literal lowers under a null/unresolved target → `struct_init.ty ==
|
||||
> .unresolved`.
|
||||
>
|
||||
> Suspected fix: before lowering the RHS expression in
|
||||
> `lowerAssignment`'s field-access path, compute the LHS member's type
|
||||
> for union / tagged-union members too (reuse the same member-type lookup
|
||||
> `fieldLvaluePtr` already performs — ideally have it RETURN the resolved
|
||||
> field type, or factor a `fieldLvalueType` helper, so the lvalue-pointer
|
||||
> path and the target-type path cannot diverge — the two-resolver defect
|
||||
> class this codebase keeps burning on) and set `self.target_type` to it
|
||||
> for the RHS lowering. Do NOT paper over with an `.unresolved`→default;
|
||||
> per CLAUDE.md, resolve the real member type or emit a diagnostic.
|
||||
>
|
||||
> Verification: the repro prints `code=9` exit 0; then `zig build &&
|
||||
> zig build test` green. Add positive coverage (a union member written
|
||||
> via struct literal, then read back) — extend
|
||||
> `examples/0166-types-union-promoted-member-lvalue.sx` or add a new
|
||||
> `examples/01xx-types-union-member-struct-literal-assign.sx`. When
|
||||
> resolved, also note in issue 0132 that the broader-latent union case is
|
||||
> now demonstrable end-to-end.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tripwire site (symptom): `src/backend/llvm/types.zig:176`
|
||||
(`toLLVMTypeInfo`, `.unresolved` arm) via `emitStructInit`
|
||||
(`src/backend/llvm/ops.zig:1211`).
|
||||
- Root area (cause): `Lowering.lowerAssignment` `.field_access` target
|
||||
path in `src/ir/lower.zig` — RHS target-type not set for union/
|
||||
tagged-union members.
|
||||
- Related but distinct: issue 0094 (RESOLVED) fixed the lvalue-POINTER
|
||||
field resolution (missing-field panic + `.i64`/field-0 defaults). This
|
||||
issue is the RHS-literal TARGET-TYPE path, which 0094 did not touch.
|
||||
@@ -0,0 +1,23 @@
|
||||
// issue 0133 — assigning a struct LITERAL to a union member panics
|
||||
// ("unresolved type reached LLVM emission").
|
||||
//
|
||||
// `u.b = .{ code = 9 }` where `b` is a named-struct member of a plain
|
||||
// `union`: the RHS struct literal never receives its target type (the
|
||||
// member's type `S`), so it lowers as `.unresolved` and trips the LLVM
|
||||
// tripwire in emitStructInit. A STRUCT-field LHS propagates the target
|
||||
// type fine; a pre-made value needs none — only the union-member-lvalue
|
||||
// + struct-literal-RHS combination drops it. PRE-EXISTING, orthogonal to
|
||||
// name resolution (reproduces with this unique, non-colliding name).
|
||||
//
|
||||
// EXPECT (today): panic. EXPECT (after fix): prints `code=9`, exit 0.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
S :: struct { code: i64; }
|
||||
U :: union { a: i64; b: S; }
|
||||
|
||||
main :: () {
|
||||
u : U = ---;
|
||||
u.b = .{ code = 9 };
|
||||
print("code={}\n", u.b.code);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
# 0134 — a same-name `error` set collapses into a namespaced import's set (error sets lack per-decl nominal identity)
|
||||
|
||||
## Symptom
|
||||
|
||||
One-line: a top-level `error { ... }` whose NAME matches an error set
|
||||
reachable through a (namespaced) import **collapses into the imported
|
||||
set** at registration — losing its own tags — because error-set
|
||||
declarations are NOT given per-decl nominal identity the way
|
||||
struct / enum / union are (E6a). So a local set's tags become
|
||||
"unknown".
|
||||
|
||||
- **Observed:** `error: error tag 'error.Boom' is not in error set
|
||||
'EventErr'` on `raise error.Boom` (and on `r == error.Boom`), where
|
||||
`EventErr :: error { Boom }` is declared locally but
|
||||
`#import "modules/std.sx"` also carries `event.EventErr`
|
||||
(tags `Init` / `Register` / `Wait`). The membership check sees the
|
||||
IMPORTED set, which has no `Boom`.
|
||||
- **Expected:** the local `EventErr { Boom }` is its OWN type; `Boom` is
|
||||
a member; the program prints `own EventErr.Boom`, exit 0 — exactly as
|
||||
a uniquely-named local error set already does.
|
||||
|
||||
This is the **declaration-side** twin of issue 0132's class. The
|
||||
**reference-side** is already visibility-aware: `error_type_expr`
|
||||
(`!EventErr`) resolves its name through `Lowering.resolveName` →
|
||||
`resolveNominalLeaf` (own-author-wins). But that fix is **dormant** for
|
||||
error sets: because the local declaration never gets its own TypeId
|
||||
(it collapses into the import's), there is only ONE `EventErr` in the
|
||||
type table for the reference to find. Fixing THIS issue is what makes
|
||||
the reference-side resolution observable.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Minimal, standalone (only `modules/std.sx`). The trigger is the name
|
||||
`EventErr` colliding with `std/event.sx`'s `EventErr` error set:
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
EventErr :: error { Boom } // collides with std/event.sx `EventErr { Init, Register, Wait }`
|
||||
|
||||
fail :: () -> !EventErr {
|
||||
raise error.Boom; // Boom IS a member of the local set
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
r := fail();
|
||||
if r == error.Boom {
|
||||
print("own EventErr.Boom\n");
|
||||
return 0;
|
||||
}
|
||||
print("wrong set\n");
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
Run: `./zig-out/bin/sx run issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx`
|
||||
|
||||
Actual (today):
|
||||
```
|
||||
error: error tag 'error.Boom' is not in error set 'EventErr'
|
||||
--> ...:NN:NN
|
||||
|
|
||||
| fail :: () -> !EventErr { raise error.Boom; }
|
||||
| ^^^^^^^^^^
|
||||
```
|
||||
(and again on `r == error.Boom`). The fix should make it print
|
||||
`own EventErr.Boom`, exit 0.
|
||||
|
||||
### Decisive bisection (verified)
|
||||
|
||||
| Variant | Result |
|
||||
|---|---|
|
||||
| Local `EventErr` (name collides with `std/event.sx`) | **FAILS** — membership checked against the imported set |
|
||||
| Rename the local set `MyErr :: error { Boom }` (no collision) | **OK** — prints `own EventErr.Boom`-equivalent |
|
||||
|
||||
So the trigger is purely the same-name collision; the local set's body
|
||||
(`{ Boom }`) is correct — it's simply never registered under its own
|
||||
identity.
|
||||
|
||||
## Root cause
|
||||
|
||||
Error sets are excluded from the per-decl nominal identity system (E6a)
|
||||
that struct / enum / union use:
|
||||
|
||||
- `Lowering.registerErrorSetDecl` (`src/ir/lower/nominal.zig`) registers
|
||||
via the FLAT `type_bridge.resolveAstType(node, …)` →
|
||||
`resolveInlineErrorSet` (`src/ir/type_bridge.zig`), whose first line is
|
||||
`if (table.findByName(name_id)) |existing| return existing;` — so the
|
||||
SECOND author of a name (here the local `EventErr`, registered after
|
||||
the imported one) just gets the first author's TypeId. No distinct
|
||||
nominal slot, no own tags.
|
||||
- Contrast `registerEnumDecl` / `registerStructDecl` / `registerUnionDecl`,
|
||||
which intern through `internNamedTypeDecl(decl_key, name_id, info,
|
||||
nominal_id)` with `nominal_id = shadowNominalId(name_id)` — each author
|
||||
gets a distinct TypeId.
|
||||
- The E6a shadow-reservation scan only enumerates struct / enum / union:
|
||||
`ShadowTypeDecl` (`src/ir/lower/nominal.zig`) is
|
||||
`union(enum) { @"struct", @"enum", @"union" }`, `topLevelTypeDecl`
|
||||
maps only those, and there is `reserveShadow{Struct,Enum,Union}Slot`
|
||||
but no error-set equivalent. So a same-name error-set shadow is never
|
||||
reserved up-front.
|
||||
- The plumbing is half-there: `nominalIdOf` / `stampNominalId` already
|
||||
handle the `.error_set` arm — registration just never sets a nominal id.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
> A top-level `error { ... }` whose name collides with a same-name error
|
||||
> set from a namespaced import collapses into the imported set, so its
|
||||
> own tags are lost ("error tag 'X' is not in error set 'Name'"). Repro:
|
||||
> `issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx`
|
||||
> (expect it to FAIL today; the fix should make it print
|
||||
> `own EventErr.Boom`, exit 0).
|
||||
>
|
||||
> Root cause: error sets are excluded from the per-decl nominal identity
|
||||
> system (E6a). `Lowering.registerErrorSetDecl`
|
||||
> (`src/ir/lower/nominal.zig`) registers through the flat
|
||||
> `type_bridge.resolveAstType` → `resolveInlineErrorSet`
|
||||
> (`src/ir/type_bridge.zig`), which short-circuits on
|
||||
> `findByName(name)` and returns the first same-name author's TypeId —
|
||||
> instead of interning under a per-decl nominal id like
|
||||
> `registerEnumDecl` does via `internNamedTypeDecl` +
|
||||
> `shadowNominalId`.
|
||||
>
|
||||
> Fix direction (mirror E6a for error sets):
|
||||
> 1. Add an `@"error_set"` variant to `ShadowTypeDecl`, an arm in
|
||||
> `topLevelTypeDecl`, and a `reserveShadowErrorSetSlot` (mirroring
|
||||
> `reserveShadowEnumSlot` — reserve a `.error_set` placeholder under
|
||||
> the computed `shadowNominalId`).
|
||||
> 2. Rewrite `registerErrorSetDecl` to build the `.error_set` `TypeInfo`
|
||||
> (intern the tag ids — factor the body out of `resolveInlineErrorSet`
|
||||
> if helpful, like `buildEnumInfo`) and intern it via
|
||||
> `internNamedTypeDecl(decl_key, name_id, info, nominal_id)` with
|
||||
> `nominal_id` from the reserved slot / `shadowNominalId`, instead of
|
||||
> the flat `resolveAstType`.
|
||||
> 3. The reference side is ALREADY visibility-aware (issue 0132's broader
|
||||
> fix): `resolveErrorType` (`src/ir/type_bridge.zig`) resolves a named
|
||||
> set through `inner.resolveName`, which for `*Lowering` is
|
||||
> `resolveNominalLeaf` (own-wins). Once the declaration has its own
|
||||
> TypeId, the named reference `!EventErr` will resolve to it
|
||||
> automatically — no further reference-side change needed.
|
||||
>
|
||||
> Per CLAUDE.md "Silent fallback defaults": don't paper over with a
|
||||
> findByName default — give error-set declarations real per-decl
|
||||
> identity so the wrong-author resolution stops at the source.
|
||||
>
|
||||
> Verification: the repro prints `own EventErr.Boom` exit 0; then
|
||||
> `zig build && zig build test` green. When resolved, promote the repro
|
||||
> to `examples/10xx-errors-same-name-error-set-own-wins.sx` (the example
|
||||
> was drafted during the 0132 broader-latent work and removed because it
|
||||
> could not pass until this lands).
|
||||
|
||||
## Notes
|
||||
|
||||
- Membership-check diagnostic site (where the symptom surfaces, not the
|
||||
root cause): `src/ir/lower/expr.zig` ("error tag '...' is not in error
|
||||
set '...'").
|
||||
- Root-cause sites: `src/ir/lower/nominal.zig` `registerErrorSetDecl`
|
||||
(flat registration, no nominal id) + the `ShadowTypeDecl` /
|
||||
`topLevelTypeDecl` / `reserveShadow*Slot` set (error sets excluded);
|
||||
`src/ir/type_bridge.zig` `resolveInlineErrorSet` (the `findByName`
|
||||
short-circuit).
|
||||
- Related: issue 0132 (same class, reference + payload/field side, fixed
|
||||
for struct/enum/union). This issue is the error-set declaration side;
|
||||
the 0132 reference-side `error_type_expr` fix stays in place and
|
||||
activates once this lands.
|
||||
@@ -0,0 +1,39 @@
|
||||
// issue 0134 — a same-name `error` set collapses into a namespaced import's
|
||||
// set (error sets lack per-decl nominal identity).
|
||||
//
|
||||
// `EventErr` is declared locally as `error { Boom }`, but
|
||||
// `#import "modules/std.sx"` also carries `event.EventErr` (an error set with
|
||||
// tags Init/Register/Wait). Because error-set DECLARATIONS are not given
|
||||
// per-decl nominal identity (unlike struct/enum/union under E6a) —
|
||||
// `registerErrorSetDecl` registers via the flat `findByName`-dedup path — the
|
||||
// local `EventErr` collapses into the imported one, losing its own `Boom` tag.
|
||||
//
|
||||
// So `raise error.Boom` / `r == error.Boom` are checked against the IMPORTED
|
||||
// set, which has no `Boom`.
|
||||
//
|
||||
// EXPECT (today): build FAILS —
|
||||
// error: error tag 'error.Boom' is not in error set 'EventErr'
|
||||
// EXPECT (after fix): prints `own EventErr.Boom`, exit 0.
|
||||
//
|
||||
// Proof it's the collision: rename `EventErr` -> `MyErr` and it compiles and
|
||||
// prints. The reference side (`!EventErr` → resolveNominalLeaf) is already
|
||||
// visibility-aware from issue 0132's broader fix, but it is dormant until the
|
||||
// local declaration gets its own TypeId. See the .md.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
EventErr :: error { Boom }
|
||||
|
||||
fail :: () -> !EventErr {
|
||||
raise error.Boom;
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
r := fail();
|
||||
if r == error.Boom {
|
||||
print("own EventErr.Boom\n");
|
||||
return 0;
|
||||
}
|
||||
print("wrong set\n");
|
||||
return 1;
|
||||
}
|
||||
367
src/corpus_run.test.zig
Normal file
367
src/corpus_run.test.zig
Normal file
@@ -0,0 +1,367 @@
|
||||
const std = @import("std");
|
||||
const corpus_paths = @import("corpus_paths");
|
||||
|
||||
// End-to-end example/issue regression runner — the pure-Zig replacement for
|
||||
// `tests/run_examples.sh`. For every `<root>/expected/<name>.exit` marker under
|
||||
// examples/ and issues/, spawn the installed `sx` binary on `<name>.sx`, capture
|
||||
// stdout/stderr/exit, normalize, and diff against the stored snapshot. Optional
|
||||
// `<name>.ir` snapshots additionally diff `sx ir` output.
|
||||
//
|
||||
// Each example runs in its OWN subprocess (via std.process.run), so a crashing
|
||||
// example reports its exit code (or 128+signal, matching a shell's `$?`) instead
|
||||
// of taking down the test binary. A per-run deadline guards against hangs.
|
||||
//
|
||||
// Paths + the `sx` binary path are injected at configure time (build.zig
|
||||
// `corpus_paths`); the FILE LIST is enumerated at test time, so new examples are
|
||||
// covered with no edit here. The child runs with cwd = repo root and is handed a
|
||||
// repo-relative path (e.g. `examples/0001-foo.sx`) — exactly the form the stored
|
||||
// snapshots are normalized to, and the cwd `tests/fixtures/` imports resolve
|
||||
// against. (The shell runner passes absolute paths and relies on a sed rule to
|
||||
// collapse them back; running relatively makes that rule a no-op, so it is not
|
||||
// reimplemented here.)
|
||||
//
|
||||
// Snapshots are regenerated in-build with `zig build test -Dupdate-goldens`
|
||||
// (see the update-mode branch below) — no shell script needed. The legacy
|
||||
// `bash tests/run_examples.sh --update` still works and produces byte-identical
|
||||
// output; the two normalizers (here and in run_examples.sh) must stay in lockstep.
|
||||
|
||||
const TIMEOUT_SECS = 10;
|
||||
const MAX_OUTPUT = 16 * 1024 * 1024;
|
||||
|
||||
/// Wrap the live C `environ` so spawned children inherit the test process's
|
||||
/// environment. `Io.Threaded`'s default `process_environ` is EMPTY, and a null
|
||||
/// `environ_map` on a spawn falls back to it — so without this the child `sx`
|
||||
/// runs with no PATH (and getenv-based examples like 1222 fail spuriously).
|
||||
/// The slice points into the process-lifetime `environ` global; no copy needed.
|
||||
fn currentEnviron() std.process.Environ {
|
||||
const raw: [*:null]const ?[*:0]const u8 = @ptrCast(std.c.environ);
|
||||
return .{ .block = .{ .slice = std.mem.span(raw) } };
|
||||
}
|
||||
|
||||
var g_test_threaded: ?std.Io.Threaded = null;
|
||||
fn test_io() std.Io {
|
||||
if (g_test_threaded == null) {
|
||||
g_test_threaded = std.Io.Threaded.init(std.heap.page_allocator, .{ .environ = currentEnviron() });
|
||||
}
|
||||
return g_test_threaded.?.io();
|
||||
}
|
||||
|
||||
fn isLowerHex(c: u8) bool {
|
||||
return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
|
||||
}
|
||||
|
||||
/// Mirror of `normalize()` in run_examples.sh: collapse `0x` + 4-or-more
|
||||
/// lowercase-hex digits to `0xADDR` so heap/fn addresses don't desync snapshots.
|
||||
/// (The path-collapse sed rule is intentionally omitted — see file header.)
|
||||
fn normalizeStd(arena: std.mem.Allocator, in: []const u8) ![]u8 {
|
||||
var out: std.ArrayList(u8) = .empty;
|
||||
var i: usize = 0;
|
||||
while (i < in.len) {
|
||||
if (in[i] == '0' and i + 1 < in.len and in[i + 1] == 'x') {
|
||||
var j = i + 2;
|
||||
while (j < in.len and isLowerHex(in[j])) j += 1;
|
||||
if (j - (i + 2) >= 4) {
|
||||
try out.appendSlice(arena, "0xADDR");
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try out.append(arena, in[i]);
|
||||
i += 1;
|
||||
}
|
||||
return out.items;
|
||||
}
|
||||
|
||||
/// `^attributes #[0-9]+ = \{` — one of normalize_ir's line-drop patterns.
|
||||
fn isAttributesLine(line: []const u8) bool {
|
||||
const pfx = "attributes #";
|
||||
if (!std.mem.startsWith(u8, line, pfx)) return false;
|
||||
var k: usize = pfx.len;
|
||||
const start = k;
|
||||
while (k < line.len and line[k] >= '0' and line[k] <= '9') k += 1;
|
||||
return k > start and std.mem.startsWith(u8, line[k..], " = {");
|
||||
}
|
||||
|
||||
fn dropIrLine(line: []const u8) bool {
|
||||
return std.mem.startsWith(u8, line, "; ModuleID =") or
|
||||
std.mem.startsWith(u8, line, "source_filename =") or
|
||||
std.mem.startsWith(u8, line, "target datalayout =") or
|
||||
std.mem.startsWith(u8, line, "target triple =") or
|
||||
isAttributesLine(line);
|
||||
}
|
||||
|
||||
/// Apply `s/%([a-z]+)[0-9]+/%\1N/g` to one line — collapse LLVM's auto-suffixed
|
||||
/// temporaries (`%tmp17` -> `%tmpN`) so renumbering doesn't desync snapshots.
|
||||
fn appendIrSubst(arena: std.mem.Allocator, out: *std.ArrayList(u8), line: []const u8) !void {
|
||||
var i: usize = 0;
|
||||
while (i < line.len) {
|
||||
if (line[i] == '%') {
|
||||
const lstart = i + 1;
|
||||
var j = lstart;
|
||||
while (j < line.len and line[j] >= 'a' and line[j] <= 'z') j += 1;
|
||||
const letters_end = j;
|
||||
const dstart = j;
|
||||
while (j < line.len and line[j] >= '0' and line[j] <= '9') j += 1;
|
||||
if (letters_end > lstart and j > dstart) {
|
||||
try out.append(arena, '%');
|
||||
try out.appendSlice(arena, line[lstart..letters_end]);
|
||||
try out.append(arena, 'N');
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try out.append(arena, line[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of `normalize_ir()` in run_examples.sh.
|
||||
fn normalizeIr(arena: std.mem.Allocator, in: []const u8) ![]u8 {
|
||||
var out: std.ArrayList(u8) = .empty;
|
||||
var lines = std.mem.splitScalar(u8, in, '\n');
|
||||
var first = true;
|
||||
while (lines.next()) |line| {
|
||||
if (dropIrLine(line)) continue;
|
||||
if (!first) try out.append(arena, '\n');
|
||||
first = false;
|
||||
try appendIrSubst(arena, &out, line);
|
||||
}
|
||||
return out.items;
|
||||
}
|
||||
|
||||
/// Match the shell runner's `$(...)` capture, which strips trailing newlines
|
||||
/// from both expected and actual before comparing.
|
||||
fn trimNl(s: []const u8) []const u8 {
|
||||
return std.mem.trimEnd(u8, s, "\n");
|
||||
}
|
||||
|
||||
/// bash `$?` convention: normal exit -> code; signal-terminated -> 128+signal.
|
||||
fn termCode(term: std.process.Child.Term) u32 {
|
||||
return switch (term) {
|
||||
.exited => |c| c,
|
||||
.signal, .stopped => |s| 128 + @as(u32, @intCast(@intFromEnum(s))),
|
||||
.unknown => |u| u,
|
||||
};
|
||||
}
|
||||
|
||||
fn deadline(io: std.Io) std.Io.Timeout {
|
||||
const dur: std.Io.Clock.Duration = .{
|
||||
.raw = std.Io.Duration.fromSeconds(TIMEOUT_SECS),
|
||||
.clock = .awake,
|
||||
};
|
||||
return .{ .deadline = std.Io.Clock.Timestamp.fromNow(io, dur) };
|
||||
}
|
||||
|
||||
fn readOptional(io: std.Io, gpa: std.mem.Allocator, abs_path: []const u8) ?[]u8 {
|
||||
return std.Io.Dir.readFileAlloc(.cwd(), io, abs_path, gpa, .limited(MAX_OUTPUT)) catch null;
|
||||
}
|
||||
|
||||
/// Run every `<root>/expected/*.exit` test. Appends a formatted diagnostic to
|
||||
/// `failures` (owned by `fail_gpa`) for each mismatch. Returns the number of
|
||||
/// tests actually run (markers whose `.sx` is missing are skipped).
|
||||
fn sweepRoot(
|
||||
fail_gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
root_dir: []const u8,
|
||||
failures: *std.ArrayList([]const u8),
|
||||
) !usize {
|
||||
// Repo root (parent of examples/ or issues/) is the child's cwd: relative
|
||||
// source paths land in diagnostics already-normalized, and tests/fixtures/
|
||||
// imports resolve here.
|
||||
const repo_root = std.fs.path.dirname(root_dir) orelse ".";
|
||||
const root_base = std.fs.path.basename(root_dir); // "examples" | "issues"
|
||||
|
||||
var name_arena_state = std.heap.ArenaAllocator.init(fail_gpa);
|
||||
defer name_arena_state.deinit();
|
||||
const name_arena = name_arena_state.allocator();
|
||||
|
||||
const expected_dir_path = try std.fs.path.join(name_arena, &.{ root_dir, "expected" });
|
||||
var dir = std.Io.Dir.openDirAbsolute(io, expected_dir_path, .{ .iterate = true }) catch return 0;
|
||||
defer dir.close(io);
|
||||
|
||||
// Collect marker names first (entry.name is only valid until the next
|
||||
// iterate step; spawning subprocesses mid-iteration is asking for trouble).
|
||||
var names: std.ArrayList([]const u8) = .empty;
|
||||
var it = dir.iterate();
|
||||
while (try it.next(io)) |entry| {
|
||||
if (entry.kind == .directory) continue; // accept .file and .unknown d_type
|
||||
if (!std.mem.endsWith(u8, entry.name, ".exit")) continue;
|
||||
const name = entry.name[0 .. entry.name.len - ".exit".len];
|
||||
try names.append(name_arena, try name_arena.dupe(u8, name));
|
||||
}
|
||||
|
||||
var work_state = std.heap.ArenaAllocator.init(fail_gpa);
|
||||
defer work_state.deinit();
|
||||
|
||||
var ran: usize = 0;
|
||||
var skipped: usize = 0;
|
||||
var updated: usize = 0;
|
||||
for (names.items) |name| {
|
||||
_ = work_state.reset(.retain_capacity);
|
||||
const a = work_state.allocator();
|
||||
|
||||
const sx_abs = try std.fs.path.join(a, &.{ root_dir, try std.fmt.allocPrint(a, "{s}.sx", .{name}) });
|
||||
std.Io.Dir.access(.cwd(), io, sx_abs, .{}) catch { // marker without source
|
||||
skipped += 1;
|
||||
std.debug.print("[corpus-run] skip {s} (no {s}.sx)\n", .{ name, name });
|
||||
continue;
|
||||
};
|
||||
ran += 1;
|
||||
|
||||
const rel_path = try std.fmt.allocPrint(a, "{s}/{s}.sx", .{ root_base, name });
|
||||
const exp_dir = expected_dir_path;
|
||||
const exit_raw = readOptional(io, a, try std.fmt.allocPrint(a, "{s}/{s}.exit", .{ exp_dir, name })) orelse "";
|
||||
const out_raw = readOptional(io, a, try std.fmt.allocPrint(a, "{s}/{s}.stdout", .{ exp_dir, name })) orelse "";
|
||||
const err_raw = readOptional(io, a, try std.fmt.allocPrint(a, "{s}/{s}.stderr", .{ exp_dir, name })) orelse "";
|
||||
const ir_raw = readOptional(io, a, try std.fmt.allocPrint(a, "{s}/{s}.ir", .{ exp_dir, name }));
|
||||
|
||||
// --- sx run ---
|
||||
const run_res = std.process.run(a, io, .{
|
||||
.argv = &.{ corpus_paths.sx_exe, "run", rel_path },
|
||||
.cwd = .{ .path = repo_root },
|
||||
.timeout = deadline(io),
|
||||
}) catch |err| {
|
||||
try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: `sx run` {s}{s}", .{
|
||||
name,
|
||||
@errorName(err),
|
||||
if (err == error.Timeout) " (>10s)" else "",
|
||||
}));
|
||||
continue;
|
||||
};
|
||||
|
||||
const act_exit = termCode(run_res.term);
|
||||
const act_out = trimNl(try normalizeStd(a, run_res.stdout));
|
||||
const act_err = trimNl(try normalizeStd(a, run_res.stderr));
|
||||
|
||||
// --- sx ir (only when a snapshot already exists; mirrors the shell's
|
||||
// `$has_ir` gate — update mode never CREATES new .ir files) ---
|
||||
var act_ir: ?[]const u8 = null;
|
||||
if (ir_raw != null) {
|
||||
const ir_res = std.process.run(a, io, .{
|
||||
.argv = &.{ corpus_paths.sx_exe, "ir", rel_path },
|
||||
.cwd = .{ .path = repo_root },
|
||||
.timeout = deadline(io),
|
||||
}) catch |err| {
|
||||
try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: `sx ir` {s}", .{ name, @errorName(err) }));
|
||||
continue;
|
||||
};
|
||||
// `sx ir` writes IR to stdout; mirror the shell's `2>&1` by appending
|
||||
// stderr (empty for a clean dump).
|
||||
const merged = try std.fmt.allocPrint(a, "{s}{s}", .{ ir_res.stdout, ir_res.stderr });
|
||||
act_ir = trimNl(try normalizeIr(a, merged));
|
||||
}
|
||||
|
||||
// --- update mode: overwrite snapshots with freshly-normalized output ---
|
||||
if (corpus_paths.update_goldens) {
|
||||
try writeGolden(io, a, exp_dir, name, "exit", try std.fmt.allocPrint(a, "{d}", .{act_exit}));
|
||||
try writeGolden(io, a, exp_dir, name, "stdout", act_out);
|
||||
try writeGolden(io, a, exp_dir, name, "stderr", act_err);
|
||||
if (act_ir) |ir| try writeGolden(io, a, exp_dir, name, "ir", ir);
|
||||
updated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- verify against stored snapshot ---
|
||||
const exp_exit = std.fmt.parseInt(u32, std.mem.trim(u8, exit_raw, " \t\r\n"), 10) catch {
|
||||
try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: unparseable expected exit '{s}'", .{ name, std.mem.trim(u8, exit_raw, " \t\r\n") }));
|
||||
continue;
|
||||
};
|
||||
const exp_out = trimNl(try normalizeStd(a, out_raw));
|
||||
const exp_err = trimNl(try normalizeStd(a, err_raw));
|
||||
|
||||
var diag: std.ArrayList(u8) = .empty;
|
||||
if (act_exit != exp_exit)
|
||||
try diag.appendSlice(a, try std.fmt.allocPrint(a, " exit: expected={d} actual={d}\n", .{ exp_exit, act_exit }));
|
||||
if (!std.mem.eql(u8, act_out, exp_out))
|
||||
try appendDiff(a, &diag, "stdout", exp_out, act_out);
|
||||
if (!std.mem.eql(u8, act_err, exp_err))
|
||||
try appendDiff(a, &diag, "stderr", exp_err, act_err);
|
||||
if (ir_raw) |ir_expected_raw| {
|
||||
const exp_ir = trimNl(try normalizeIr(a, ir_expected_raw));
|
||||
if (!std.mem.eql(u8, act_ir.?, exp_ir))
|
||||
try appendDiff(a, &diag, "IR", exp_ir, act_ir.?);
|
||||
}
|
||||
|
||||
try recordIfFailed(fail_gpa, failures, name, diag.items);
|
||||
}
|
||||
if (skipped > 0)
|
||||
std.debug.print("[corpus-run] {s}: {d} marker(s) skipped (no matching .sx)\n", .{ root_base, skipped });
|
||||
if (corpus_paths.update_goldens)
|
||||
std.debug.print("[corpus-run] {s}: {d} snapshot(s) regenerated\n", .{ root_base, updated });
|
||||
return ran;
|
||||
}
|
||||
|
||||
/// Overwrite `<exp_dir>/<name>.<ext>` with `content` + a trailing newline —
|
||||
/// matching the shell runner's `echo "$x" > file` (command substitution strips
|
||||
/// trailing newlines; echo re-adds exactly one). Update mode only.
|
||||
fn writeGolden(
|
||||
io: std.Io,
|
||||
a: std.mem.Allocator,
|
||||
exp_dir: []const u8,
|
||||
name: []const u8,
|
||||
ext: []const u8,
|
||||
content: []const u8,
|
||||
) !void {
|
||||
const path = try std.fmt.allocPrint(a, "{s}/{s}.{s}", .{ exp_dir, name, ext });
|
||||
const data = try std.fmt.allocPrint(a, "{s}\n", .{content});
|
||||
try std.Io.Dir.writeFile(.cwd(), io, .{ .sub_path = path, .data = data });
|
||||
}
|
||||
|
||||
fn recordIfFailed(
|
||||
fail_gpa: std.mem.Allocator,
|
||||
failures: *std.ArrayList([]const u8),
|
||||
name: []const u8,
|
||||
diag: []const u8,
|
||||
) !void {
|
||||
if (diag.len == 0) return;
|
||||
try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}:\n{s}", .{ name, diag }));
|
||||
}
|
||||
|
||||
const DIFF_CAP = 2000;
|
||||
|
||||
fn appendDiff(a: std.mem.Allocator, diag: *std.ArrayList(u8), label: []const u8, expected: []const u8, actual: []const u8) !void {
|
||||
try diag.appendSlice(a, try std.fmt.allocPrint(a, " --- {s}: expected ---\n{s}\n --- {s}: actual ---\n{s}\n", .{
|
||||
label, cap(expected), label, cap(actual),
|
||||
}));
|
||||
}
|
||||
|
||||
fn cap(s: []const u8) []const u8 {
|
||||
return if (s.len > DIFF_CAP) s[0..DIFF_CAP] else s;
|
||||
}
|
||||
|
||||
fn reportFailures(label: []const u8, ran: usize, failures: []const []const u8) !void {
|
||||
std.debug.print("[corpus-run] {s}: {d} ran, {d} failed\n", .{ label, ran, failures.len });
|
||||
for (failures) |f| std.debug.print("FAIL {s}\n", .{f});
|
||||
if (failures.len > 0 and !corpus_paths.update_goldens) std.debug.print(
|
||||
\\
|
||||
\\ ── snapshot mismatch ──────────────────────────────────────────────
|
||||
\\ If the new output is CORRECT (intentional change), regenerate snapshots:
|
||||
\\ zig build test -Dupdate-goldens
|
||||
\\ git diff examples/expected/ issues/expected/ # review before committing
|
||||
\\ Otherwise this is a regression — fix the code, don't update the snapshot.
|
||||
\\ ───────────────────────────────────────────────────────────────────
|
||||
\\
|
||||
, .{});
|
||||
try std.testing.expect(failures.len == 0);
|
||||
}
|
||||
|
||||
test "examples corpus: every examples/*.sx runs and matches its snapshot" {
|
||||
const io = test_io();
|
||||
var failures: std.ArrayList([]const u8) = .empty;
|
||||
defer failures.deinit(std.testing.allocator);
|
||||
|
||||
const ran = try sweepRoot(std.testing.allocator, io, corpus_paths.examples_dir, &failures);
|
||||
defer for (failures.items) |f| std.testing.allocator.free(f);
|
||||
try std.testing.expect(ran > 0);
|
||||
try reportFailures("examples", ran, failures.items);
|
||||
}
|
||||
|
||||
test "issues corpus: every pinned issues/*.sx repro runs and matches its snapshot" {
|
||||
const io = test_io();
|
||||
var failures: std.ArrayList([]const u8) = .empty;
|
||||
defer failures.deinit(std.testing.allocator);
|
||||
|
||||
const ran = try sweepRoot(std.testing.allocator, io, corpus_paths.issues_dir, &failures);
|
||||
defer for (failures.items) |f| std.testing.allocator.free(f);
|
||||
try reportFailures("issues", ran, failures.items);
|
||||
}
|
||||
@@ -2917,7 +2917,11 @@ pub const LLVMEmitter = struct {
|
||||
const ir_str = c.LLVMPrintModuleToString(self.llvm_module);
|
||||
defer c.LLVMDisposeMessage(ir_str);
|
||||
const len = std.mem.len(ir_str);
|
||||
std.debug.print("{s}\n", .{ir_str[0..len]});
|
||||
// Write to fd 1 (stdout), not std.debug.print (stderr): `sx ir` is a
|
||||
// data-emitting command meant to be piped/redirected, so the IR text
|
||||
// belongs on stdout. Mirrors core.flushInterpOutput's raw-write route.
|
||||
_ = std.c.write(1, ir_str, len);
|
||||
_ = std.c.write(1, "\n", 1);
|
||||
}
|
||||
|
||||
/// Emit the module as an object file to disk.
|
||||
|
||||
@@ -717,6 +717,14 @@ pub const Lowering = struct {
|
||||
return self.resolveTypeWithBindings(node);
|
||||
}
|
||||
|
||||
/// Bare TYPE-NAME twin of `resolveInner` for callers holding a name rather
|
||||
/// than an AST node (e.g. an error-set reference `!Named`) — routed through
|
||||
/// the visibility-aware `resolveNominalLeaf`, so a same-name-shadowed set
|
||||
/// resolves to the querying module's own author (issue 0132's class).
|
||||
pub fn resolveName(self: *Lowering, name: []const u8) TypeId {
|
||||
return self.resolveNominalLeaf(name, false, null);
|
||||
}
|
||||
|
||||
/// Fixed-array dimension hook for `TypeResolver.resolveCompound`. A literal
|
||||
/// `[16]T` and a named-const `N :: 16; [N]T` must resolve to the SAME length:
|
||||
/// the dimension folds to a compile-time integer (looked up in the comptime /
|
||||
@@ -936,6 +944,28 @@ pub const Lowering = struct {
|
||||
// literal (`(i32, i32)`); validate its elements are types and reject
|
||||
// non-type elements loudly.
|
||||
.tuple_literal => return self.resolveTupleLiteralTypeArg(node),
|
||||
// Inline type declarations used as a field type (`x: enum { ... }`,
|
||||
// `x: struct { ... }`, `x: union { ... }`): build their bodies with
|
||||
// THIS lowering as the `inner` recursion hook, so a payload / field
|
||||
// type NAME resolves in the enclosing module's visibility context —
|
||||
// the SAME own-wins-over-namespaced rule the top-level registration
|
||||
// uses (issue 0132's class). Delegating to the flat `else` below
|
||||
// dropped `self`, leaving inline-decl payloads on the global
|
||||
// `findByName` first-match.
|
||||
.enum_decl => return type_bridge.resolveInlineEnum(&node.data.enum_decl, &self.module.types, self),
|
||||
.struct_decl => return type_bridge.resolveInlineStruct(&node.data.struct_decl, &self.module.types, self),
|
||||
.union_decl => return type_bridge.resolveInlineUnion(&node.data.union_decl, &self.module.types, self),
|
||||
// A NAMED error-set reference (`!Named`) resolves its name through
|
||||
// `self` (visibility-aware) too; the bare `!` inferred set has no name
|
||||
// to shadow. NOTE: this reference-side resolution is currently DORMANT
|
||||
// for same-name error-set collisions — error-set DECLARATIONS don't
|
||||
// yet get per-decl nominal identity (E6a covers struct/enum/union
|
||||
// only), so a same-name set collapses to one TypeId at registration
|
||||
// and there is nothing distinct for the reference to select. See issue
|
||||
// 0134; once decls get nominal identity this activates with no change
|
||||
// here. `error_set_decl` is NOT in this switch: it interns only tag
|
||||
// names, resolving no type names, so it stays on the flat `else`.
|
||||
.error_type_expr => return type_bridge.resolveErrorType(&node.data.error_type_expr, &self.module.types, self),
|
||||
else => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,7 +703,7 @@ pub fn registerStructDecl(self: *Lowering, sd: *const ast.StructDecl, source_fil
|
||||
if (const_node.data == .const_decl) {
|
||||
const cd = const_node.data.const_decl;
|
||||
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, cd.name }) catch continue;
|
||||
const ty: ?TypeId = if (cd.type_annotation) |ta| type_bridge.resolveAstType(ta, table, &self.program_index.type_alias_map, &self.program_index.module_const_map) else null;
|
||||
const ty: ?TypeId = if (cd.type_annotation) |ta| self.resolveType(ta) else null;
|
||||
self.struct_const_map.put(qualified, .{ .value = cd.value, .ty = ty }) catch {};
|
||||
}
|
||||
}
|
||||
@@ -725,7 +725,12 @@ pub fn registerEnumDecl(self: *Lowering, ed: *const ast.EnumDecl) void {
|
||||
const name_id = table.internString(ed.name);
|
||||
const decl_key: *const anyopaque = @ptrCast(ed);
|
||||
const nominal_id: u32 = if (table.type_decl_tids.get(decl_key)) |id| nominalIdOf(table.get(id)) else self.shadowNominalId(name_id);
|
||||
const info = type_bridge.buildEnumInfo(ed, table, &self.program_index.type_alias_map, &self.program_index.module_const_map);
|
||||
// Pass `self` (the visibility-aware `Lowering` resolver) as the `inner`
|
||||
// recursion hook — the same seam `resolveCompound` uses — so a payload type
|
||||
// NAME resolves in the enum's OWN module visibility context (own author wins
|
||||
// over a namespaced same-name import), not via a global `findByName`
|
||||
// first-match (issue 0132's class).
|
||||
const info = type_bridge.buildEnumInfo(ed, table, self);
|
||||
_ = self.internNamedTypeDecl(decl_key, name_id, info, nominal_id);
|
||||
}
|
||||
|
||||
@@ -736,7 +741,8 @@ pub fn registerUnionDecl(self: *Lowering, ud: *const ast.UnionDecl) void {
|
||||
const name_id = table.internString(ud.name);
|
||||
const decl_key: *const anyopaque = @ptrCast(ud);
|
||||
const nominal_id: u32 = if (table.type_decl_tids.get(decl_key)) |id| nominalIdOf(table.get(id)) else self.shadowNominalId(name_id);
|
||||
const info = type_bridge.buildUnionInfo(ud, table, &self.program_index.type_alias_map, &self.program_index.module_const_map);
|
||||
// `self` as the visibility-aware `inner` hook — see `registerEnumDecl`.
|
||||
const info = type_bridge.buildUnionInfo(ud, table, self);
|
||||
_ = self.internNamedTypeDecl(decl_key, name_id, info, nominal_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ const StatelessInner = struct {
|
||||
pub fn resolveInner(self: StatelessInner, node: *const Node) TypeId {
|
||||
return resolveAstType(node, self.table, self.alias_map, self.consts);
|
||||
}
|
||||
/// Bare TYPE-NAME twin of `resolveInner`, for callers that hold a name
|
||||
/// rather than an AST node (an error-set reference `!Named`). Flat:
|
||||
/// registered name → alias → stub, no visibility scoping.
|
||||
pub fn resolveName(self: StatelessInner, name: []const u8) TypeId {
|
||||
return resolveTypeName(name, self.table, self.alias_map, false);
|
||||
}
|
||||
/// Fixed-array dimension at registration time: a literal `[16]T`, a named
|
||||
/// module-global const `N :: 16; [N]T` (typed `N : i64 : 16` too), or a
|
||||
/// constant-foldable expression over those (`[M + 1]`, `[(M + 1) * 2]`).
|
||||
@@ -183,12 +189,15 @@ pub fn resolveAstType(node: ?*const Node, table: *TypeTable, alias_map: AliasMap
|
||||
// type — so the omission surfaces; the lowering-side `resolveParamType`
|
||||
// turns it into a real diagnostic.
|
||||
.inferred_type => .unresolved,
|
||||
// Inline type declarations (used as field types)
|
||||
.enum_decl => |ed| resolveInlineEnum(&ed, table, alias_map, consts),
|
||||
.struct_decl => |sd| resolveInlineStruct(&sd, table, alias_map, consts),
|
||||
.union_decl => |ud| resolveInlineUnion(&ud, table, alias_map, consts),
|
||||
// Inline type declarations (used as field types). Enum/union bodies are
|
||||
// built through the shared `inner`-parameterized builders; the stateless
|
||||
// path passes `si` (the `StatelessInner` already constructed above) — the
|
||||
// same `resolveInner` recursion hook `resolveCompound` receives.
|
||||
.enum_decl => |ed| resolveInlineEnum(&ed, table, si),
|
||||
.struct_decl => |sd| resolveInlineStruct(&sd, table, si),
|
||||
.union_decl => |ud| resolveInlineUnion(&ud, table, si),
|
||||
.error_set_decl => |esd| resolveInlineErrorSet(&esd, table),
|
||||
.error_type_expr => |ete| resolveErrorType(&ete, table, alias_map),
|
||||
.error_type_expr => |ete| resolveErrorType(&ete, table, si),
|
||||
else => {
|
||||
// A non-type AST node reached type resolution — a caller bug.
|
||||
// Returning a plausible `.i64` would silently fabricate an 8-byte
|
||||
@@ -340,14 +349,17 @@ fn resolveParameterizedType(pt: *const ast.ParameterizedTypeExpr, table: *TypeTa
|
||||
|
||||
// ── Inline type declarations ─────────────────────────────────────────
|
||||
|
||||
/// Stateless inline-enum resolution for a FIELD-type position (`x: enum {...}`):
|
||||
/// the legacy `findByName` short-circuit keeps a single global slot per display
|
||||
/// name. The TOP-LEVEL per-decl nominal identity path (`Lowering.registerEnumDecl`)
|
||||
/// Inline-enum resolution for a FIELD-type position (`x: enum {...}`). Payload
|
||||
/// type NAMES resolve through the injected `inner` recursion hook: the stateless
|
||||
/// `StatelessInner` (flat) when reached from `resolveAstType`, or `*Lowering`
|
||||
/// (visibility-aware) when reached from `Lowering.resolveTypeWithBindings` — so a
|
||||
/// payload name resolves in the enclosing module's context (issue 0132's class).
|
||||
/// The TOP-LEVEL per-decl nominal identity path (`Lowering.registerEnumDecl`)
|
||||
/// shares the body via `buildEnumInfo` but interns under its own nominal id.
|
||||
fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId {
|
||||
pub fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, inner: anytype) TypeId {
|
||||
const name_id = table.internString(ed.name);
|
||||
if (table.findByName(name_id)) |existing| return existing;
|
||||
const info = buildEnumInfo(ed, table, alias_map, consts);
|
||||
const info = buildEnumInfo(ed, table, inner);
|
||||
const id = table.internNominal(info, 0);
|
||||
table.updatePreservingKey(id, info);
|
||||
return id;
|
||||
@@ -361,7 +373,7 @@ fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia
|
||||
/// enum builds a `.tagged_union`; a payload-less enum a plain `.enum`. Nested
|
||||
/// payload structs / variant field types ARE interned here — they are distinct
|
||||
/// nested nominals, not the enum's own identity.
|
||||
pub fn buildEnumInfo(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeInfo {
|
||||
pub fn buildEnumInfo(ed: *const ast.EnumDecl, table: *TypeTable, inner: anytype) TypeInfo {
|
||||
const alloc = table.alloc;
|
||||
const name_id = table.internString(ed.name);
|
||||
|
||||
@@ -384,7 +396,7 @@ pub fn buildEnumInfo(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia
|
||||
} else {
|
||||
var sfields = std.ArrayList(TypeInfo.StructInfo.Field).empty;
|
||||
for (sd.field_names, sd.field_types) |fname, ftype_node| {
|
||||
const fty = resolveAstType(ftype_node, table, alias_map, consts);
|
||||
const fty = inner.resolveInner(ftype_node);
|
||||
sfields.append(alloc, .{
|
||||
.name = table.internString(fname),
|
||||
.ty = fty,
|
||||
@@ -398,10 +410,10 @@ pub fn buildEnumInfo(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia
|
||||
table.updatePreservingKey(field_ty, sinfo);
|
||||
}
|
||||
} else {
|
||||
field_ty = resolveAstType(vt, table, alias_map, consts);
|
||||
field_ty = inner.resolveInner(vt);
|
||||
}
|
||||
} else {
|
||||
field_ty = resolveAstType(vt, table, alias_map, consts);
|
||||
field_ty = inner.resolveInner(vt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,7 +427,7 @@ pub fn buildEnumInfo(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia
|
||||
var backing_type: ?TypeId = null;
|
||||
var tag_type: ?TypeId = null;
|
||||
if (ed.backing_type) |bt| {
|
||||
const backing_ty = resolveAstType(bt, table, alias_map, consts);
|
||||
const backing_ty = inner.resolveInner(bt);
|
||||
backing_type = backing_ty;
|
||||
// Extract tag type from first field of backing struct
|
||||
const backing_info = table.get(backing_ty);
|
||||
@@ -495,7 +507,7 @@ pub fn buildEnumInfo(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia
|
||||
if (ed.backing_type) |bt| {
|
||||
// Only use simple backing types (u8, u16, u32, etc.), not struct backing (enum struct)
|
||||
if (bt.data != .struct_decl) {
|
||||
enum_backing = resolveAstType(bt, table, alias_map, consts);
|
||||
enum_backing = inner.resolveInner(bt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +520,14 @@ pub fn buildEnumInfo(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia
|
||||
} };
|
||||
}
|
||||
|
||||
fn resolveInlineStruct(sd: *const ast.StructDecl, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId {
|
||||
/// Inline-struct resolution for a FIELD-type position (`x: struct {...}`). Field
|
||||
/// type NAMES resolve through the injected `inner` hook (flat `StatelessInner`
|
||||
/// from `resolveAstType`, or visibility-aware `*Lowering` from
|
||||
/// `resolveTypeWithBindings` — issue 0132's class). The TOP-LEVEL struct path
|
||||
/// (`Lowering.registerStructDecl`) builds its own field list directly via
|
||||
/// `self.resolveType` (it also expands `#using` and qualifies `__anon` names),
|
||||
/// so it does not route through here.
|
||||
pub fn resolveInlineStruct(sd: *const ast.StructDecl, table: *TypeTable, inner: anytype) TypeId {
|
||||
const alloc = table.alloc;
|
||||
const name_id = table.internString(sd.name);
|
||||
|
||||
@@ -516,7 +535,7 @@ fn resolveInlineStruct(sd: *const ast.StructDecl, table: *TypeTable, alias_map:
|
||||
|
||||
var fields = std.ArrayList(TypeInfo.StructInfo.Field).empty;
|
||||
for (sd.field_names, sd.field_types) |fname, ftype_node| {
|
||||
const field_ty = resolveAstType(ftype_node, table, alias_map, consts);
|
||||
const field_ty = inner.resolveInner(ftype_node);
|
||||
fields.append(alloc, .{
|
||||
.name = table.internString(fname),
|
||||
.ty = field_ty,
|
||||
@@ -531,13 +550,16 @@ fn resolveInlineStruct(sd: *const ast.StructDecl, table: *TypeTable, alias_map:
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Stateless inline-union resolution for a FIELD-type position. The TOP-LEVEL
|
||||
/// per-decl nominal identity path (`Lowering.registerUnionDecl`) shares the body
|
||||
/// via `buildUnionInfo` but interns under its own nominal id.
|
||||
fn resolveInlineUnion(ud: *const ast.UnionDecl, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId {
|
||||
/// Inline-union resolution for a FIELD-type position. Field type NAMES resolve
|
||||
/// through the injected `inner` hook (flat `StatelessInner` from `resolveAstType`,
|
||||
/// or visibility-aware `*Lowering` from `resolveTypeWithBindings` — issue 0132's
|
||||
/// class). The TOP-LEVEL per-decl nominal identity path
|
||||
/// (`Lowering.registerUnionDecl`) shares the body via `buildUnionInfo` but interns
|
||||
/// under its own nominal id.
|
||||
pub fn resolveInlineUnion(ud: *const ast.UnionDecl, table: *TypeTable, inner: anytype) TypeId {
|
||||
const name_id = table.internString(ud.name);
|
||||
if (table.findByName(name_id)) |existing| return existing;
|
||||
const info = buildUnionInfo(ud, table, alias_map, consts);
|
||||
const info = buildUnionInfo(ud, table, inner);
|
||||
const id = table.internNominal(info, 0);
|
||||
table.updatePreservingKey(id, info);
|
||||
return id;
|
||||
@@ -547,13 +569,13 @@ fn resolveInlineUnion(ud: *const ast.UnionDecl, table: *TypeTable, alias_map: Al
|
||||
/// nominal slot — the shared body-BUILDER behind both the stateless inline
|
||||
/// field-type path (`resolveInlineUnion`) and the stateful per-decl registration
|
||||
/// (`Lowering.registerUnionDecl`).
|
||||
pub fn buildUnionInfo(ud: *const ast.UnionDecl, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeInfo {
|
||||
pub fn buildUnionInfo(ud: *const ast.UnionDecl, table: *TypeTable, inner: anytype) TypeInfo {
|
||||
const alloc = table.alloc;
|
||||
const name_id = table.internString(ud.name);
|
||||
|
||||
var fields = std.ArrayList(TypeInfo.StructInfo.Field).empty;
|
||||
for (ud.field_names, ud.field_types) |fname, ftype_node| {
|
||||
const field_ty = resolveAstType(ftype_node, table, alias_map, consts);
|
||||
const field_ty = inner.resolveInner(ftype_node);
|
||||
fields.append(alloc, .{
|
||||
.name = table.internString(fname),
|
||||
.ty = field_ty,
|
||||
@@ -589,8 +611,8 @@ fn resolveInlineErrorSet(esd: *const ast.ErrorSetDecl, table: *TypeTable) TypeId
|
||||
/// function by the whole-program SCC pass (E1.4); for now every bare `!`
|
||||
/// resolves to the same empty inferred set, which is correct while no
|
||||
/// function raises (E1.3+).
|
||||
fn resolveErrorType(ete: *const ast.ErrorTypeExpr, table: *TypeTable, alias_map: AliasMap) TypeId {
|
||||
if (ete.name) |name| return resolveTypeName(name, table, alias_map, false);
|
||||
pub fn resolveErrorType(ete: *const ast.ErrorTypeExpr, table: *TypeTable, inner: anytype) TypeId {
|
||||
if (ete.name) |name| return inner.resolveName(name);
|
||||
// `!` is not a legal type/identifier name, so this reserved StringId can
|
||||
// never collide with a user-declared set.
|
||||
const name_id = table.internString("!");
|
||||
|
||||
@@ -520,7 +520,9 @@ fn dumpSxIR(allocator: std.mem.Allocator, io: std.Io, input_path: []const u8, st
|
||||
sx.ir.printModule(&ir_module, &aw.writer) catch return error.CompileError;
|
||||
var result = aw.writer.toArrayList();
|
||||
defer result.deinit(allocator);
|
||||
std.debug.print("{s}", .{result.items});
|
||||
// Emit to stdout (fd 1), not stderr: `ir-dump` is a data-emitting command
|
||||
// meant to be piped/redirected. Matches `sx ir`'s stdout routing.
|
||||
_ = std.c.write(1, result.items.ptr, result.items.len);
|
||||
}
|
||||
|
||||
fn emitIR(allocator: std.mem.Allocator, io: std.Io, input_path: []const u8, target_config: sx.target.TargetConfig, stdlib_paths: []const []const u8) !void {
|
||||
|
||||
@@ -17,6 +17,7 @@ pub const imports_tests = @import("imports.test.zig");
|
||||
pub const core = @import("core.zig");
|
||||
pub const c_import = @import("c_import.zig");
|
||||
pub const c_import_tests = @import("c_import.test.zig");
|
||||
pub const corpus_run_tests = @import("corpus_run.test.zig");
|
||||
pub const ir = @import("ir/ir.zig");
|
||||
|
||||
pub const lsp = struct {
|
||||
|
||||
@@ -31,6 +31,28 @@ if [[ "${1:-}" == "--update" ]]; then
|
||||
UPDATE=1
|
||||
fi
|
||||
|
||||
# Per-test wall-clock guard. GNU `timeout` (or `gtimeout` from Homebrew
|
||||
# coreutils) kills a hung test after $TIMEOUT seconds. Neither ships on a
|
||||
# bare macOS, so degrade gracefully: when no timeout binary is found, run the
|
||||
# command directly (a hang then blocks the suite, but the suite still works).
|
||||
TIMEOUT_CMD=()
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD=(timeout "$TIMEOUT")
|
||||
elif command -v gtimeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD=(gtimeout "$TIMEOUT")
|
||||
fi
|
||||
|
||||
# Run a command under the timeout wrapper if one is available, else directly.
|
||||
# The length check (not "${arr[@]}") keeps this safe under bash 3.2 + `set -u`,
|
||||
# where expanding an empty array trips "unbound variable".
|
||||
run_sx() {
|
||||
if [[ ${#TIMEOUT_CMD[@]} -gt 0 ]]; then
|
||||
"${TIMEOUT_CMD[@]}" "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Normalize stdout/stderr for snapshot diffing. Applied identically to both
|
||||
# expected and actual, so it can only reconcile location/host noise — never
|
||||
# desync an otherwise-matching pair. The path rule collapses any absolute
|
||||
@@ -75,7 +97,7 @@ for root in "${ROOTS[@]}"; do
|
||||
fi
|
||||
|
||||
printf " %-48s" "$name"
|
||||
actual_out=$(timeout "$TIMEOUT" "$SX" run "$sx_file" 2>"$TMP_ERR" | normalize)
|
||||
actual_out=$(run_sx "$SX" run "$sx_file" 2>"$TMP_ERR" | normalize)
|
||||
actual_exit=${PIPESTATUS[0]}
|
||||
actual_err=$(normalize < "$TMP_ERR")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user