Chapter 6: Arrays: fixed-capacity sequences
This chapter:
- Give Muspel a collection type: a fixed-capacity array, sized once at creation, never silently reallocated.
- Add
[index]as one more postfix operator, alongside the.fieldaccess Chapter 5 just added. - Add
for item in list, the last piece of control flow the language needs. - Resolve the ambiguity a bare-identifier-then-brace grammar creates for a
forloop's own list expression.
Where we left off: Muspel has structs, enums, and chained .field access. There's still no way to hold "a few of something": an enemy roster, an inventory, a waypoint path, without hand- declaring a separate variable for each one.
6.1 Why fixed-capacity, not growable
The rest of this monorepo's house style already treats "fixed capacity, no per-frame reallocation" as the default for hot-path data, and it's the right default here too, for a more specific reason than house style alone: Chapter 10's struct and array values will shortly live in a bounded, bump-allocated arena that has no clean way to grow an existing allocation in place. A growable array needs either amortised-growth reallocation, awkward inside a bump allocator that never reclaims individual allocations, or a separate general-purpose heap, exactly the complexity Chapter 5 chose an arena to avoid.
Design decision: fixed capacity, no
append. Alternatives considered: a growable array, the shape Python'slistor JavaScript'sArraytake, resizing itself as elements are added. Trade-off: growable arrays are easier to script against casually ("start empty, append as you go"), at the cost of needing either reallocation machinery or a general heap underneath. A game script's arrays are almost always "at most N enemies in this wave," a size known up front; the restriction costs little in the common case, and this book says so honestly rather than pretending it's free in every case. Choice: an array literal, orarray(capacity)for one built empty and filled in later, fixes its size at construction. There is noappend.
Note (beyond this chapter): the reference
code/later grows the fixed array of this chapter into an optional growable list, adding a live length alongside the fixed backing pluslist/append/pop/removebuiltins, without disturbing anything above: a literal still fixes its size, and the fixed array stays the default. That extension, and the arena caveat that keeps it honest, is walked file by file in Appendix G (§G.4). This chapter builds the fixed array; read it as written, and treat growth as the add-on it is.
6.2 Grammar: literals and indexing
arrayLit ::= "[" ( expression ( "," expression )* )? "]" ;
index ::= "[" expression "]" ;
Expr_Array_Literal is the simplest node in this chapter, an ordered list of element expressions:
Expr_Array_Literal :: struct {
line: int,
elements: []Expr,
}
Indexing joins .field in the same postfix loop Chapter 5 extended, a third case in the same for statement:
} else if match(p, .Left_Bracket) {
line := previous(p).line
index := parse_expression(p, allocator)
consume(p, .Right_Bracket, "expected ']' after index")
node := new(Expr_Index, allocator)
node^ = Expr_Index{line = line, object = expr, index = index}
expr = node
}
list[i][j], roster[0].pos.x, any mix of calls, field access, and indexing, all fall out of one loop, exactly the postfix chain Chapter 4 promised would extend cleanly. Assignment gets its own sibling shape again, Expr_Index_Assign, list[i] = value, the same pattern Chapter 5's Expr_Field_Assign already established.
len(list) needs no new grammar at all: it's an ordinary function call to a builtin, registered the same way print already is (Chapter 8 and Chapter 13 register it on the tree-walker and the VM respectively). Bounds checking is a language guarantee, not an accident: list[i] past the end is a well-defined runtime error, never an Odin-level out-of-bounds read, the same "never let a script corrupt the host process" ethic Part V builds out in full later.
6.3 The struct-literal ambiguity, one level up
Chapter 5's struct literal, TypeName{ ... }, already reads "a bare identifier immediately followed by {" as construction syntax. That's fine everywhere the grammar expects an ordinary expression, but for item in list { ... } puts an expression, list, directly in front of the loop's own mandatory {, with nothing in between. If list happened to be a struct type name, for e in Foo { ... } would misparse: Foo followed by { looks exactly like a struct literal swallowing the loop body whole.
This is the same ambiguity Go's own grammar has with an unparenthesised if/for header, and Muspel resolves it the same way Go does: a parser flag, cleared inside any bracketed sub-expression, that suppresses struct-literal parsing for exactly the span where it would be ambiguous.
Parser :: struct {
tokens: []Token,
pos: int,
errors: [dynamic]Parse_Error,
// no_struct_literal suppresses treating `Ident {` as a struct literal while parsing a
// for-in loop's `list` expression.
no_struct_literal: bool,
}
parse_for_in_stmt sets it for exactly the list expression, and clears it again immediately after:
saved := p.no_struct_literal
p.no_struct_literal = true
list := parse_expression(p, allocator)
p.no_struct_literal = saved
body := parse_block(p, allocator)
Every place a bracketed sub-expression can appear inside that list expression, a parenthesised group, a call's own argument list, an array literal's elements, an index expression, restores the flag to false for the duration of that sub-expression before restoring the saved value afterwards: for e in wrap(Vec2{x:1}) { ... } still parses the struct literal correctly, because by the time the parser is inside wrap(...)'s argument list, the ambiguity that motivated the flag no longer applies. parse_primary's own struct-literal check reads the flag directly:
case .Ident:
advance(p)
if !p.no_struct_literal && check(p, .Left_Brace) {
return parse_struct_literal(p, allocator, tok)
}
node := new(Expr_Ident, allocator)
node^ = Expr_Ident{line = tok.line, name = tok.lexeme, depth = -1, slot = -1}
return node
Gotcha: this is exactly the class of bug that only shows up on real, not synthetic, input. A short example (
for e in Foo { }) makes the ambiguity obvious once you're looking for it, but it's the kind of thing that's easy to miss entirely if every example while building the feature happens to loop over a plain variable rather than a bare type name or function call. Test the awkward case,for x in SomeStruct { ... }whereSomeStructis never actually a valid list to loop over, deliberately, even though it should still parse correctly (and fail later, at resolve or run time, for the right reason: not an array).
6.4 for item in list
forInStmt ::= "for" IDENT "in" expression block ;
Stmt_For_In :: struct {
line: int,
item_name: string,
list: Expr,
body: Stmt,
depth: int, // filled in by Chapter 7, exactly like Stmt_Var's own depth/slot
slot: int,
}
Section 4.6 already showed parse_for_stmt's one-token dispatch between this form and the counting for; parse_for_in_stmt itself is a short, direct read of the grammar above: an item name, in, a list expression (parsed with no_struct_literal set, section 6.3), and a block.
Unlike the counting for, this one earns a real AST node of its own rather than desugaring to something else at parse time: iterating an array needs a genuine new runtime operation (Chapter 10 gives it a bounds-checked GET_INDEX/length-check sequence under the hood), not just a recombination of nodes the language already had.
6.5 musc parse on arrays and iteration
./run.sh parse examples/arrays.mus
Against:
struct Vec2 { x, y }
var nums = [10, 20, 30]
print(nums[0], nums[2])
var roster = [Vec2{x:0,y:0}, Vec2{x:1,y:1}]
for it in roster {
print(it.x)
}
StructDecl Vec2 ["x", "y"]
Var nums
ArrayLiteral
Literal 10
Literal 20
Literal 30
ExprStmt
Call
Ident print [depth=-1 slot=-1]
Index
Ident nums [depth=-1 slot=-1]
Literal 0
Index
Ident nums [depth=-1 slot=-1]
Literal 2
Var roster
ArrayLiteral
StructLiteral Vec2 ["x", "y"] [-1, -1]
Literal 0
Literal 0
StructLiteral Vec2 ["x", "y"] [-1, -1]
Literal 1
Literal 1
ForIn it [depth=-1 slot=-1]
Ident roster [depth=-1 slot=-1]
Block
ExprStmt
Call
Ident print [depth=-1 slot=-1]
FieldAccess .x [index=-1]
Ident it [depth=-1 slot=-1]
Structs, enums, and arrays together are now the language's complete vocabulary for structured data, exactly what Chapter 21's common.mus needs to describe a wave's enemy roster and a boss's attack pattern as data instead of hand-coded logic.
Checkpoint
musc parse correctly parses array literals, indexing expressions, and for item in list loops, including the struct-literal-versus-loop-header ambiguity resolved correctly on real, adversarial input, completing the AST vocabulary every later chapter needs for Muspel's three structured-data types.
Exercises
- Parse
[](an empty array literal) and confirmExpr_Array_Literal.elementsis a zero-length slice, notnilfrom a skipped branch you didn't expect to take. - Parse
list[i][j] = 0and trace, from the printed tree, why the outer node is anIndexAssignwhoseobjectis itself anIndexnode, not a flat two-index assignment. - Deliberately write
for Vec2 in items { ... }(using a struct type name as the loop variable, which the grammar allows sinceitem_nameis just an identifier) and confirm it parses; think through why this is legal syntax even though it shadows something meaningful, and what Chapter 7 might have to say about it. - Remove the
saved := p.no_struct_literal; p.no_struct_literal = falserestoration around a call's argument list (in your own local copy, not the reference project) and confirmfor e in wrap(Vec2{x:1}) { }breaks in exactly the way section 6.3 predicts.
Further reading
- Odin's own fixed-size arrays versus its growable
[dynamic]T, the direct inspiration for choosing the fixed-capacity form here: https://odin-lang.org/docs/overview/#arrays - Go's own grammar note on why
if x {needs the same kind of disambiguation Muspel'sforneeded here, for exactly the same underlying reason: a specification worth reading once you've hit the problem yourself rather than before. - Lua's array-like tables, growing implicitly, the contrasting design this chapter explicitly declines to copy: https://www.lua.org/manual/5.4/manual.html#3.4.9