Skip to content

Commit ad2084f

Browse files
committed
feat: []Type{elem, ...} composite-literal sugar for arrays; fix named-array-value type checking
Go-style typed array literal syntax, e.g. `xs := []int{1, 2, 3}`. Desugars to exactly the same construction `var xs []int = [1, 2, 3]` already compiles to (an anonymous array named_type built once, its object pushed as a constant, the plain array value passed to it as a single-arg call) -- reusing that machinery rather than a second implementation of element-type checking. Supports primitive, struct, named-type, empty, and nested ([][]int{...}) element types. Disambiguating this from a plain array literal or an empty [] is genuinely ambiguous by token lookahead alone: Gengo is newline- insensitive, so `xs := []` immediately followed by an unrelated new statement (`func foo() {...}`, `std.io.println(...)`, another `[...]`) is completely ordinary code that happens to start with a type-spec-like token. Resolved with real speculative parsing + rollback (save the 4 parser-position fields, attempt to parse a type spec followed by `{`, restore and fall back to the plain literal on failure) rather than token-type heuristics. Two more pre-existing bugs found and fixed along the way (reproduced with the equivalent `var` syntax too, so not introduced by this sugar): - vm_types.zig matchesTypeAlt's .array/.map cases never unwrapped a .named_value before checking isArrayObject/isMapObject -- passing an already-[]T-typed value to a []T-typed function parameter always failed with a confusingly identical-looking "expected []T, got []T" (both sides render the same runtime type name; the check just never looked inside the wrapper constructNamedType's .array_t/.map_t cases always produce). This is also why [][]int{...} previously failed: each inner element is itself a named-array-typed value. - The disambiguation logic's first draft (inline in parsePrecedence's switch) increased that recursive function's per-call stack frame size in an unoptimized/Debug build enough to trip a pre-existing "expression too deep" stack-overflow guard test before its own software depth counter could fire. Fixed by extracting the whole disambiguation into its own function so those locals don't live in the recursive function's frame. Verified clean under standard, -Dpreset=stress, and -Dgc_stress=true.
1 parent a4bd4e7 commit ad2084f

7 files changed

Lines changed: 349 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,46 @@ This changelog tracks notable language/runtime changes by implementation date.
44

55
## 2026-08-20
66

7+
### Language — `[]Type{elem, ...}` typed composite-literal sugar for arrays
8+
9+
Go-style typed array literal syntax, e.g. `xs := []int{1, 2, 3}`. Desugars
10+
to exactly the same construction `var xs []int = [1, 2, 3]` already
11+
compiled to (an anonymous array named_type built once, its object pushed
12+
as a constant, the plain array value passed to it as a single-arg call) —
13+
reusing that machinery rather than a second implementation of element-type
14+
checking. Supports primitive, struct, named-type, empty, and nested
15+
(`[][]int{...}`) element types.
16+
17+
Disambiguating this from a plain `[elem, ...]` array literal or an empty
18+
`[]` is genuinely ambiguous by simple token lookahead: Gengo is newline-
19+
insensitive, so `xs := []` immediately followed by an unrelated new
20+
statement (`func foo() {...}`, `std.io.println(...)`, another `[...]`)
21+
is completely ordinary code that happens to start with a type-spec-like
22+
token. Resolved with real speculative parsing + rollback (save the 4
23+
parser-position fields, attempt to parse a type spec followed by `{`,
24+
restore and fall back to the plain literal if that fails) rather than
25+
token-type heuristics, which correctly handles every case rather than
26+
just the common ones.
27+
28+
Two more bugs found and fixed along the way (both pre-existing, not
29+
introduced by this sugar — reproduced with the equivalent `var` syntax
30+
too):
31+
- `matchesTypeAlt`'s `.array`/`.map` cases never unwrapped a `.named_value`
32+
before checking `isArrayObject`/`isMapObject` — passing an already-`[]T`-
33+
typed value to a `[]T`-typed function parameter always failed with a
34+
confusingly identical-looking "expected []T, got []T" (both sides render
35+
the same runtime type name; the check just never looked inside the
36+
wrapper `constructNamedType`'s `.array_t`/`.map_t` cases always produce).
37+
This is also why `[][]int{...}` (or `var [][]int = [...]`) previously
38+
failed: each inner element is itself a named-array-typed value.
39+
- The disambiguation logic's first draft (token-type lookahead) increased
40+
`parsePrecedence`'s per-call stack frame size (4 new locals, unconditionally
41+
present regardless of which switch arm executes, in an unoptimized/Debug
42+
build) enough to trip a pre-existing "expression too deep" stack-overflow
43+
guard test before its own software depth counter could fire. Fixed by
44+
extracting the whole disambiguation into its own function so those locals
45+
don't live in the recursive function's frame.
46+
747
### Fix — cap:net IPv6 CIDR policy rules crashed (or hit UB in release builds) for virtually every real prefix length
848

949
A coverage audit found `net_state.zig`'s `ipv6InCidr` had zero test

docs/language.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,16 @@ nums := [1, 2, 3]
595595
std.io.println(nums[0])
596596
```
597597

598+
An array literal can also be prefixed with an explicit element type — Go-
599+
style composite-literal syntax — equivalent to (and desugars to exactly the
600+
same construction as) `var xs []Type = [elem, ...]`:
601+
602+
```gengo
603+
xs := []int{1, 2, 3}
604+
pts := []Point{Point{x: 1, y: 2}, Point{x: 3, y: 4}}
605+
empty := []int{}
606+
```
607+
598608
Maps:
599609

600610
```gengo

src/compiler_test.zig

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6649,11 +6649,58 @@ test "compiler: int_div raises RangeError (not a silently wrong answer) for i64:
66496649
try std.testing.expectError(error.RangeError, rt.callGlobal("g", &.{ .{ .int = std.math.minInt(i64) }, .{ .int = -1 } }));
66506650
}
66516651

6652+
// A copy-paste-divergence audit found .int_div's OTHER path -- reached when
6653+
// either operand is a named-type-wrapped int, via numericBinaryOp/
6654+
// pushNumericResultWithCarrier rather than the plain int/int fast path
6655+
// above -- had silently reintroduced the exact bug the fast path was
6656+
// already fixed for: it returned minInt(i64) unmodified instead of raising
6657+
// RangeError.
6658+
test "compiler: int_div's named-type carrier path also raises RangeError for i64::MIN div -1" {
6659+
var rt = try setup();
6660+
defer rt.deinit();
6661+
try runSrc(&rt,
6662+
\\type Big int
6663+
\\func g() int { return int(Big(-9223372036854775808) div Big(-1)) }
6664+
);
6665+
try std.testing.expectError(error.RangeError, rt.callGlobal("g", &.{}));
6666+
}
6667+
6668+
// The same copy-paste-divergence audit found .mod's plain int/int path
6669+
// (unlike its two siblings .int_div and .rem, just above) never checked
6670+
// for this case at all -- @mod(minInt(i64), -1) traps/UB's on the same 2^63
6671+
// overflow, since floored and truncating division coincide when dividing
6672+
// by exactly -1. x mod 1 is always mathematically 0.
6673+
test "compiler: mod does not trap/overflow for i64::MIN mod -1" {
6674+
var rt = try setup();
6675+
defer rt.deinit();
6676+
try runSrc(&rt,
6677+
\\func g(a int, b int) int { return a mod b }
6678+
);
6679+
const result = try rt.callGlobal("g", &.{ .{ .int = std.math.minInt(i64) }, .{ .int = -1 } });
6680+
try std.testing.expectEqual(@as(i64, 0), result.int);
6681+
}
6682+
66526683
// std.json.stringify used to serialize any bigint as `null` (falling through
66536684
// the object-tag switch's generic else branch), silently discarding the
66546685
// value entirely. std.conv.to_int/to_float rejected bigint with TypeError
66556686
// even though the direct-dispatched `int(...)`/`float(...)` builtins already
66566687
// supported it — an inconsistency between two paths meant to be equivalent.
6688+
// A capability-module audit found argon2id validated only t/m/p's LOWER
6689+
// bounds (unlike bcryptHash's cost, which is bounded both ways) before
6690+
// @intCast-ing them into std.crypto.pwhash.argon2.Params' u32/u32/u24
6691+
// fields -- an out-of-range value (e.g. m between u32::max and i64::max)
6692+
// panicked that cast immediately, a crash trivially reachable from Gengo
6693+
// source with no wire format or policy misconfiguration needed.
6694+
test "compiler: std.crypto.argon2id rejects an out-of-range m instead of panicking the @intCast" {
6695+
var rt = try setup();
6696+
defer rt.deinit();
6697+
try runSrc(&rt,
6698+
\\std := import("std")
6699+
\\func g() string { return std.crypto.argon2id("password", "somesalt1", 1, 999999999999, 1, 32) }
6700+
);
6701+
try std.testing.expectError(error.ValueError, rt.callGlobal("g", &.{}));
6702+
}
6703+
66576704
test "compiler: std.json.stringify serializes bigint as digits, not null; std.conv accepts bigint" {
66586705
var rt = try setup();
66596706
defer rt.deinit();
@@ -6698,6 +6745,125 @@ test "compiler: array/map element write re-enforces the named element type's pre
66986745
try std.testing.expectEqual(@as(i64, 30), ok.int);
66996746
}
67006747

6748+
// Go-style typed composite-literal sugar for arrays: `[]Type{elem, ...}` as
6749+
// a standalone expression. Desugars to the exact same construction
6750+
// `var xs []Type = [elem, ...]` already compiles to (compiler_stmts.zig's
6751+
// varDecl: an anonymous array named_type built once, its object pushed as
6752+
// a constant, the plain array value constructed and passed to it as a
6753+
// single-arg call) — verified here across primitive, struct, named-type,
6754+
// empty, and nested element types, plus that element-type checking still
6755+
// fires (an ill-typed element must still be rejected, not silently
6756+
// accepted just because it went through the new sugar path).
6757+
test "compiler: []Type{...} composite-literal sugar constructs a typed array" {
6758+
var rt = try setup();
6759+
defer rt.deinit();
6760+
try runSrc(&rt,
6761+
\\std := import("std")
6762+
\\type Point struct { x int, y int }
6763+
\\type Meters int
6764+
\\func ints() []int { return []int{1, 2, 3} }
6765+
\\func strs() []string { return []string{"a", "b", "c"} }
6766+
\\func emptyInts() []int { return []int{} }
6767+
\\func points() []Point { return []Point{Point{x: 1, y: 2}, Point{x: 3, y: 4}} }
6768+
\\func meters() []Meters { return []Meters{Meters(1), Meters(2)} }
6769+
\\func nested() [][]int { return [][]int{[]int{1, 2}, []int{3, 4}} }
6770+
\\func typeName() string { return std.core.type_of([]int{1, 2}) }
6771+
);
6772+
// A `[]T`-declared function return (like a `[]T`-declared local or
6773+
// param) is a named_value wrapping the plain array — unwrap before
6774+
// inspecting, same as the matchesTypeAlt fix this test also exercises.
6775+
const arraySlice = struct {
6776+
fn get(v: Value) ![]Value {
6777+
const inner = v.namedInner() orelse v;
6778+
return vms.asArraySlice(inner.object);
6779+
}
6780+
}.get;
6781+
6782+
const ints = try rt.callGlobal("ints", &.{});
6783+
const ints_items = try arraySlice(ints);
6784+
try std.testing.expectEqual(@as(usize, 3), ints_items.len);
6785+
try std.testing.expectEqual(@as(i64, 1), ints_items[0].int);
6786+
6787+
const strs = try rt.callGlobal("strs", &.{});
6788+
try std.testing.expectEqual(@as(usize, 3), (try arraySlice(strs)).len);
6789+
6790+
const empty = try rt.callGlobal("emptyInts", &.{});
6791+
try std.testing.expectEqual(@as(usize, 0), (try arraySlice(empty)).len);
6792+
6793+
const points = try rt.callGlobal("points", &.{});
6794+
try std.testing.expectEqual(@as(usize, 2), (try arraySlice(points)).len);
6795+
6796+
const meters = try rt.callGlobal("meters", &.{});
6797+
try std.testing.expectEqual(@as(usize, 2), (try arraySlice(meters)).len);
6798+
6799+
// A copy-paste-divergence audit found matchesTypeAlt's .array/.map
6800+
// cases never unwrapped a named_value before checking isArrayObject —
6801+
// this is exactly the shape `[][]int{...}` produces for each inner
6802+
// element (a named-array-typed value nested inside another array
6803+
// literal), so this specific assertion is the regression guard for
6804+
// that fix, not just the sugar's own construction.
6805+
const nested = try rt.callGlobal("nested", &.{});
6806+
const nested_items = try arraySlice(nested);
6807+
try std.testing.expectEqual(@as(usize, 2), nested_items.len);
6808+
try std.testing.expectEqual(@as(i64, 1), (try arraySlice(nested_items[0]))[0].int);
6809+
6810+
const tn = try rt.callGlobal("typeName", &.{});
6811+
try std.testing.expectEqualStrings("array", try vms.asStringValue(tn));
6812+
6813+
try std.testing.expectError(error.TypeError, runSrc(&rt, "bad := []int{1, \"two\", 3}"));
6814+
}
6815+
6816+
// Gengo is newline-insensitive, so a naive "identifier right after ']'
6817+
// means a type name follows" check misfires on completely ordinary code:
6818+
// `xs := []` (a complete statement, empty untyped array) directly followed
6819+
// by an unrelated NEW statement that happens to start with an identifier
6820+
// (`std.io.println(xs)`) used to be swallowed as if it were
6821+
// `[]std...{...}`. The fix requires that identifier to actually be a known
6822+
// type name (isKnownTypeName) before treating it as the sugar.
6823+
test "compiler: []Type{...} sugar doesn't misfire on a plain [] followed by an unrelated statement" {
6824+
var rt = try setup();
6825+
defer rt.deinit();
6826+
try runSrc(&rt,
6827+
\\std := import("std")
6828+
\\empty := []
6829+
\\func describe() string { return std.core.type_of(empty) }
6830+
);
6831+
const result = try rt.callGlobal("describe", &.{});
6832+
try std.testing.expectEqualStrings("array", try vms.asStringValue(result));
6833+
}
6834+
6835+
// The same matchesTypeAlt fix as the nested-array assertion above, but
6836+
// isolated to its actual trigger: a []T-typed function parameter receiving
6837+
// an already []T-typed argument. Before the fix this failed with a
6838+
// confusingly identical-looking "expected []int, got []int" (both sides
6839+
// render the same runtime type name — the check just never looked past the
6840+
// named_value wrapper constructNamedType's .array_t case always produces).
6841+
// Reproduced via the plain `var` declaration form too, proving this was a
6842+
// pre-existing bug in typed-array declarations generally, not something
6843+
// the new []Type{} sugar introduced.
6844+
test "compiler: a []T-typed value passes a []T-typed function parameter (named_value unwrapping)" {
6845+
var rt = try setup();
6846+
defer rt.deinit();
6847+
try runSrc(&rt,
6848+
\\func sum(xs []int) int {
6849+
\\ total := 0
6850+
\\ for v in xs {
6851+
\\ total += v
6852+
\\ }
6853+
\\ return total
6854+
\\}
6855+
\\func viaSugar() int { return sum([]int{10, 20, 30}) }
6856+
\\func viaVarDecl() int {
6857+
\\ var ys []int = [1, 2, 3]
6858+
\\ return sum(ys)
6859+
\\}
6860+
);
6861+
const a = try rt.callGlobal("viaSugar", &.{});
6862+
try std.testing.expectEqual(@as(i64, 60), a.int);
6863+
const b = try rt.callGlobal("viaVarDecl", &.{});
6864+
try std.testing.expectEqual(@as(i64, 6), b.int);
6865+
}
6866+
67016867
// Every other arithmetic-carrier path (add/sub/mul, unary neg, abs) re-checks
67026868
// a named type's predicate after producing a new value; TypeName.succ/pred
67036869
// (both the bound-function form and the method-call form) didn't, so

src/lang/compiler.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2740,6 +2740,7 @@ pub const Compiler = struct {
27402740
const structInstanceLitAfterValue = compiler_expr.structInstanceLitAfterValue;
27412741
const subtypeDecl = compiler_decls.subtypeDecl;
27422742
const switchStmt = compiler_stmts.switchStmt;
2743+
pub const tryTypedArrayLit = compiler_stmts.tryTypedArrayLit;
27432744
pub const typeNameLiteral = compiler_expr.typeNameLiteral;
27442745
const unaryExpr = compiler_expr.unaryExpr;
27452746
const varDecl = compiler_stmts.varDecl;

src/lang/compiler_expr.zig

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,46 @@ pub fn arrayLit(c: anytype) !void {
5656
try c.cs.emit2(@intFromEnum(Op.build_array), count, c.prev.line);
5757
}
5858

59+
// Entry point for parsePrecedence's `.lbracket` prefix arm — pulled out into
60+
// its own function (not inlined into that switch arm directly) so its 4
61+
// saved-position locals live in THIS function's stack frame, not
62+
// parsePrecedence's. parsePrecedence recurses on every nested expression
63+
// (e.g. unary minus chains), and in an unoptimized/Debug build each of its
64+
// local variables — regardless of which switch arm actually uses them —
65+
// contributes to every single recursive frame's size; found via a
66+
// pre-existing "expression too deep" test that verifies MaxExprDepth's
67+
// software counter rejects deep nesting before the native stack actually
68+
// overflows, which started hitting the real stack limit first once these
69+
// locals were briefly inlined there instead.
70+
//
71+
// Disambiguates `[]Type{...}` (the typed composite-literal sugar) from a
72+
// plain array literal `[elem, ...]` or an empty `[]`. '[' is already
73+
// consumed (it's c.prev); c.cur is ']' only for either shape, and whether a
74+
// real type spec (then '{') follows can only be known by actually trying to
75+
// parse one — Gengo is newline-insensitive, so `xs := []` immediately
76+
// followed by an unrelated NEW statement (starting with an identifier,
77+
// `func`, another `[`, anything) is completely ordinary code, not a broken
78+
// program, and token-type lookahead alone can't tell that apart from
79+
// genuine `[]Type{...}` sugar in general. tryTypedArrayLit only commits
80+
// (emits bytecode) once '{' is actually confirmed; on any other outcome it
81+
// consumes nothing beyond the ']' the caller already confirmed, so a plain
82+
// position save/restore is sufficient to cleanly fall back to ordinary
83+
// array-literal parsing.
84+
fn arrayLitOrTypedArrayLit(c: anytype) !void {
85+
if (c.check(.rbracket)) {
86+
const saved_cur = c.cur;
87+
const saved_prev = c.prev;
88+
const saved_peek = c.peek_tok;
89+
const saved_lex = c.lex;
90+
if (try c.tryTypedArrayLit()) return;
91+
c.cur = saved_cur;
92+
c.prev = saved_prev;
93+
c.peek_tok = saved_peek;
94+
c.lex = saved_lex;
95+
}
96+
try arrayLit(c);
97+
}
98+
5999
// Returns true for an identifier token that names a concrete, comparable
60100
// type — usable opposite a '.type' expression. Interfaces are excluded
61101
// ('.type' never equals an interface name, since interfaces aren't concrete
@@ -893,7 +933,7 @@ pub fn parsePrecedence(c: anytype, p: Prec) anyerror!void {
893933
},
894934
.lbracket => {
895935
c.clearCurrentExprPrimInfo();
896-
try arrayLit(
936+
try arrayLitOrTypedArrayLit(
897937
c,
898938
);
899939
},

src/lang/compiler_stmts.zig

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2186,6 +2186,73 @@ fn fieldTypeAltLabel(hs: *heap.State, alt: FieldTypeAlt) []const u8 {
21862186
};
21872187
}
21882188

2189+
// Sugar for `[]Type{elem, elem, ...}` as a standalone expression (Go-style
2190+
// composite literal) — e.g. `xs := []int{1, 2, 3}`. Desugars to exactly the
2191+
// same construction `var xs []int = [1, 2, 3]` already compiles to below
2192+
// (an anonymous array named_type built once, its object pushed as a
2193+
// constant, the plain array value constructed and passed to it as a
2194+
// single-arg call) — reusing that machinery rather than duplicating its
2195+
// element-type checking/coercion rules a second time.
2196+
//
2197+
// Entered only from parsePrecedence's `.lbracket` prefix arm, which has
2198+
// already consumed the leading '[' (it's in c.prev) and confirmed c.cur is
2199+
// ']' — that's it; whether a real type spec (and then '{') follows can only
2200+
// be known by actually trying to parse one, since Gengo is newline-
2201+
// insensitive and a plain `[]` is a complete expression on its own (e.g.
2202+
// `xs := []` immediately followed by an unrelated new statement that
2203+
// happens to start with `func`/an identifier/another `[` is completely
2204+
// ordinary code, not a broken program).
2205+
//
2206+
// Returns false (having consumed NOTHING beyond confirming c.cur is ']')
2207+
// when this isn't actually the sugar shape — parseFieldTypeSpec errored, or
2208+
// no '{' follows the parsed type — so the caller can roll back to exactly
2209+
// where it started and fall back to treating '[]' as a plain empty array.
2210+
// Every side effect below this point (heap bumps for the FieldTypeSpec
2211+
// tree, c.setErr from a failed speculative parse) is either harmless to
2212+
// discard (compile-time-only scratch data) or gets overwritten by whatever
2213+
// real error the fallback path produces — see this function's caller for
2214+
// the actual state save/restore. Nothing is emitted to c.cs (bytecode)
2215+
// until AFTER the '{' is confirmed, so there is never anything to unwind
2216+
// on the chunk side.
2217+
pub fn tryTypedArrayLit(c: anytype) !bool {
2218+
const open_line = c.prev.line;
2219+
c.advance(); // consume ']' of the leading '[]' (caller already confirmed it)
2220+
const elem = c.parseFieldTypeSpec() catch return false;
2221+
if (c.cur.typ != .lbrace) return false;
2222+
const ep = c.hs.bump(FieldTypeSpec, 1) orelse return error.OutOfMemory;
2223+
ep[0] = elem;
2224+
const array_alt: FieldTypeAlt = .{ .typ = .array, .elem_spec = ep[0] };
2225+
const type_label = fieldTypeAltLabel(c.hs, array_alt);
2226+
const nt = c.hs.allocObject() orelse return error.OutOfMemory;
2227+
nt.* = .{ .named_type = .{
2228+
.name = type_label,
2229+
.qualified_name = type_label,
2230+
.base = .array_t,
2231+
.is_anonymous = true,
2232+
.elem_spec = ep[0],
2233+
} };
2234+
try c.cs.emitConst(.{ .object = nt }, open_line);
2235+
2236+
c.advance(); // consume '{' (caller already confirmed it via c.cur.typ == .lbrace)
2237+
var count: u8 = 0;
2238+
if (!c.check(.rbrace)) {
2239+
while (true) {
2240+
try c.expr();
2241+
if (count == 255) {
2242+
c.setErr("too many elements (max {d})", .{MaxLocals});
2243+
return error.TooManyElements;
2244+
}
2245+
count += 1;
2246+
if (!c.match(.comma)) break;
2247+
if (c.check(.rbrace)) break;
2248+
}
2249+
}
2250+
try c.consume(.rbrace);
2251+
try c.cs.emit2(@intFromEnum(Op.build_array), count, c.prev.line);
2252+
try c.cs.emitCall(1, open_line);
2253+
return true;
2254+
}
2255+
21892256
pub fn varDecl(c: anytype, has_keyword: bool, is_const: bool) !void {
21902257
if (has_keyword and c.cur.typ != .ident) return c.err("expected identifier, found {s}", .{c.tokenName(c.cur.typ)});
21912258
const name = c.cur;

0 commit comments

Comments
 (0)