`Analyzer.resolveTypeNode` read the array `.length` node's `.int_literal` union field unconditionally. For a named-const dimension (`MAX :: 4; [MAX]u8`) that node is an `identifier`, so the access tripped Zig's checked-union panic and `sx lsp` aborted on didOpen. The main compiler was unaffected (it folds the dim through the IR). - New `arrayDimLength` helper switches on the dimension node tag: int_literal → value; identifier → a recorded module-const int value; anything else / out-of-u32-range → unknown. Never assumes a node shape. - `Type.ArrayTypeInfo.length` is now `?u32`; null is an explicit "editor couldn't fold this dimension" marker (rendered `[_]T`), never a fabricated concrete length. - New `const_int_values` registry records integer-literal consts at registration time for the identifier path. Regression: first `src/lsp/*.test.zig` (the minimal LSP harness), wired into the test graph via `src/root.zig`. Drives `analyzeDocument` over `[MAX]u8` (folds to 4, no panic), `[64]u8` (happy-path guard), and `[N]u8` (explicit unknown). Fail-before/pass-after verified. Sibling audit of the resolveTypeNode/fieldType family: the array dim was the only unchecked union-field access; all other arms recurse or tag-check first. Noted a non-crashing display gap in server.zig hover rendering for step B.
5.0 KiB
0099 — LSP analyzer panics on an identifier array dimension ([MAX]u8)
RESOLVED. The editor analyzer (src/sema.zig) read the array-dimension node's
.int_literal union field unconditionally. When the dimension is a named const
([MAX]u8), the .length node is an identifier, so the access tripped Zig's
checked-union panic and sx lsp aborted (SIGABRT) the moment the file was opened
(didOpen → analyzeDocument). The main compiler (sx run) was never affected — it
resolves [MAX]u8 through the IR's foldArrayDim machinery.
Symptom
- Observed: opening a buffer with a named-const dimension in an editor crashes the
language server:
src/sema.zigaborts withaccess of union field 'int_literal' while field 'identifier' is active. - Expected: the analyzer resolves
[MAX]u8to length 4 (or, when it can't fold the dimension, records an explicit "unknown" length) and never crashes.
Reproduction
MAX :: 4;
Thing :: struct { buf: [MAX]u8; }
sx run compiles this fine; sx lsp aborts on didOpen. (The crash is reached via
registerTopLevelDecl → fieldType → resolveTypeNode while building the editor
index — not a diagnostic pass.)
Root cause
Analyzer.resolveTypeNode in src/sema.zig, the array_type_expr arm:
const length: u32 = @intCast(ate.length.data.int_literal.value);
ate.length is an arbitrary expression node. For a literal [64]u8 it is
int_literal; for [MAX]u8 it is identifier. The code assumed int_literal
unconditionally — a textbook unchecked-union-field access. @intCast of the
non-existent payload never even runs; the union-field read panics first.
Fix
- New helper
Analyzer.arrayDimLength(len_node)switches on the dimension node's tag:int_literal→ its value;identifier→ the value recorded for that name in a newconst_int_valuesregistry (populated when a top-levelconst_declhas anint_literalvalue); anything else (unknown name, non-const expression) →null. A value outsideu32range also yieldsnull. No node shape is ever assumed without a tag check, so the path cannot panic. Type.ArrayTypeInfo.lengthchanged fromu32to?u32.nullis the explicit "editor couldn't fold this dimension" marker — never a fabricated concrete length (this is hover/metadata, not codegen).displayNamerenders an unknown length as[_]T.src/sema.zigsubstTypepasses the (now optional) length through unchanged; the only other consumer of the editorArrayTypeInfoisdisplayName, updated above.
The named-const value is captured at registration time, so resolution shares the analyzer's existing intra-pass forward-reference limitation: a const declared after the struct that uses it resolves to the safe "unknown" length rather than its value. This matches how the analyzer already treats a struct field that references a later-declared struct type, and never crashes.
Sibling audit (resolveTypeNode / fieldType family)
Audited every arm for the same unchecked .data.<field> pattern. The array dimension
was the only crashing site:
slice_type_expr,optional_type_expr,pointer_type_expr,many_pointer_type_expr,function_type_expr— all recurse throughresolveTypeNode, which dispatches on the node tag; no raw field access.parameterized_type_exprreturns.void_type;type_expr/identifierread.name/.is_rawonly aftertn.data == .type_expr or tn.data == .identifier.fieldType,typeExprName,typeExprIsRaw,resolveTypeAnnotation,resolveTypeRefall tag-check (switch capture or explicit==) before any payload read;resolveTypeAnnotation'sarray_type_exprcase delegates toresolveTypeNode, so it is fixed transitively.
Non-crashing display gap noted (not fixed here — out of scope for A):
src/lsp/server.zig (~line 2801) renders a struct's array field in hover/completion
detail directly off the AST. It already guards if (ate.length.data == .int_literal),
so it does not crash, but it renders a named-const dimension as an empty []u8
instead of [4]u8. Tracked for the broad LSP .data sweep (next step B), not addressed
in this crash fix.
Regression
First src/lsp/*.test.zig — establishes the minimal LSP test harness
(src/lsp/document.test.zig), wired into the zig build test graph via
src/root.zig's lsp.document_tests reference. Tests drive the real didOpen path
(DocumentStore.analyzeDocument) and inspect the editor index:
[MAX]u8(named const) → no panic; folded length is4, elementu8.[64]u8(int literal) → length64(happy-path guard).[N]u8(undeclared name) → explicit unknown length (null), no panic, no fabricated value.
Verified fail-before / pass-after: with the pre-fix unconditional .int_literal
dereference restored, the identifier test aborts with the exact original panic; with the
tag-switching fix it passes. Full gate green: zig build, zig build test (incl. the
new LSP test), bash tests/run_examples.sh (453 passed, 0 failed).