Appendix A: The full Muspel grammar
Every production below was introduced piece by piece, in EBNF, across Chapters 2 to 6 and 20. This appendix collects the complete, final grammar in one place, exactly the state code/'s own parser implements. ? is zero-or-one, * is zero-or-more, | is alternation, parentheses group, and literal tokens are quoted.
Expression precedence here is shown the traditional way, one production per level, tightest binding at the bottom, even though the parser itself never has one procedure per level: Chapter 4's own precedence-climbing parser (infix_binding_power, one small table, one loop) implements every one of the logic_or through factor productions below without a dedicated function for any of them.
Lexical grammar
NUMBER ::= DIGIT+ ( "." DIGIT+ )? ;
STRING ::= '"' ( any character except '"' or a newline, with '\' escapes )* '"' ;
IDENT ::= ALPHA ( ALPHA | DIGIT )* ;
ALPHA ::= "a".."z" | "A".."Z" | "_" ;
DIGIT ::= "0".."9" ;
COMMENT ::= "//" ( any character except a newline )* ;
The lexer works on bytes, not runes (section 2.6): IDENT is ASCII-only by deliberate, defended scope cut. String and comment contents pass through untouched, so non-ASCII text inside a string literal is fine; it just can't appear in a name.
Keywords (reserved, never valid as an IDENT): var, const, func, if, else, while, for, in, return, yield, true, false, nil, struct, enum, import, as.
Automatic semicolon insertion (section 2.6): the lexer inserts a .Semicolon token wherever a newline (or end of file) follows a token that can end a statement (IDENT, NUMBER, STRING, true, false, nil, ), }, ], return, yield). Every grammar rule below that ends in ";" is satisfied by either a typed semicolon or one the lexer inserted; nothing downstream can tell the difference. The practical consequence: a return (or yield) value must start on the same line as the keyword itself, and } else { must keep the brace and else on one line, exactly Go's own ASI rule.
Semicolon optional before } (section 3.2): the statement terminator in varDecl, exprStmt, returnStmt, and yieldStmt may be omitted immediately before a closing }, the other half of Go's rule, which is what lets a block body sit entirely on one line (if (c) { x = 1 }, func f() { return v }). This is a parser relaxation, not a lexer one, and applies only before a block's }; it does not affect struct or array literals.
Program structure
program ::= declaration* EOF ;
declaration ::= funcDecl | structDecl | enumDecl | importDecl | varDecl | statement ;
funcDecl ::= "func" IDENT "(" params? ")" block ;
params ::= IDENT ( "," IDENT )* ;
structDecl ::= "struct" IDENT "{" ( IDENT ( "," IDENT )* )? "}" ;
enumDecl ::= "enum" IDENT "{" ( IDENT ( "," IDENT )* )? "}" ;
importDecl ::= "import" STRING "as" IDENT ";" ;
varDecl ::= ( "var" | "const" ) varBinding ( "," varBinding )* ";" ;
varBinding ::= IDENT ( "=" expression )? ;
A single var may declare several comma-separated bindings, var a = 1, b = 2, c; the parser desugars two or more into a Stmt_Multi of one Stmt_Var each, run in sequence in the current scope (Appendix G §G.7). The ";" here is still subject to the two rules above: ASI supplies it at a line end, and it may be omitted before a closing }.
const shares this production exactly, with two rules the grammar cannot express and the parser and resolver enforce instead (Appendix G §G.8): a const binding's "=" expression is mandatory rather than optional, and any later assignment to a const name is a resolve error. A const fixes the binding, not the value it holds: const xs = [1, 2, 3] still permits xs[0] = 9.
funcDecl/structDecl/enumDecl/importDecl are all top-level-only (section 4.4's own Design decision for funcDecl, extended the same way for the other three): nested/local functions are scoped out along with closures capturing by reference (section 12.4), and there is no notion of a struct, enum, or import declared inside a block at all. A block's own contents use a narrower blockDecl that excludes all four:
blockDecl ::= varDecl | statement ;
block ::= "{" blockDecl* "}" ;
Statements
statement ::= exprStmt | ifStmt | whileStmt | counterForStmt | forInStmt
| returnStmt | yieldStmt | block ;
exprStmt ::= expression ";" ;
ifStmt ::= "if" "(" expression ")" block ( "else" ( ifStmt | block ) )? ;
whileStmt ::= "while" "(" expression ")" block ;
counterForStmt ::= "for" "(" ( varDecl | exprStmt )? ";" expression? ";" expression? ")" block ;
forInStmt ::= "for" IDENT "in" expression block ;
returnStmt ::= "return" expression? ";" ;
yieldStmt ::= "yield" ";" ;
ifStmt/whileStmt bodies are always block, never a bare statement (section 3.4's own Design decision: this rules out the classic dangling-else ambiguity outright, at the cost of requiring braces everywhere). yieldStmt (section 15.4) carries no operand at all; the entire statement is one keyword. counterForStmt desugars entirely at parse time (section 4.6) into varDecl/ whileStmt nodes already covered above; it earns its own production here because it's real grammar a reader types, even though it produces no AST node of its own. forInStmt (section 6.4) is the one form that does get its own node, Stmt_For_In, since iterating an array needs a genuine new runtime operation a recombination of existing statements can't express. One token of lookahead after "for" ("(" versus a bare IDENT) decides which of the two productions applies; both share the keyword.
Expressions
expression ::= assignment ;
assignment ::= ( IDENT | fieldTarget | indexTarget ) "=" assignment | logic_or ;
logic_or ::= logic_and ( "||" logic_and )* ;
logic_and ::= equality ( "&&" equality )* ;
equality ::= comparison ( ( "==" | "!=" ) comparison )* ;
comparison ::= term ( ( "<" | "<=" | ">" | ">=" ) term )* ;
term ::= factor ( ( "+" | "-" ) factor )* ;
factor ::= unary ( ( "*" | "/" | "%" ) unary )* ;
unary ::= ( "!" | "-" ) unary | postfix ;
postfix ::= primary ( call | fieldAccess | index )* ;
call ::= "(" arguments? ")" ;
fieldAccess ::= "." IDENT ;
index ::= "[" expression "]" ;
arguments ::= expression ( "," expression )* ;
primary ::= NUMBER | STRING | "true" | "false" | "nil"
| IDENT | "(" expression ")"
| structLit | arrayLit ;
structLit ::= ( IDENT | IDENT "." IDENT ) "{" ( IDENT ":" expression ( "," IDENT ":" expression )* )? "}" ;
arrayLit ::= "[" ( expression ( "," expression )* )? "]" ;
assignment sits outside the precedence-climbing loop entirely, at the lowest precedence and right-associative (section 4.3): the parser parses a full logic_or-and-below expression first, and only reinterprets it as an assignment target once = follows, accepting a plain IDENT (Expr_Assign), a trailing fieldAccess (Expr_Field_Assign, section 5.4), or a trailing index (Expr_Index_Assign, section 6.2). postfix is one loop after primary, not three separate recursive rules, so f(1)(2), enemy.pos.x, list[i][j], and any mix of the three, all fall out of the same loop with no dedicated grammar rule per operator.
structLit's two-identifier form, IDENT "." IDENT "{" ... "}", is a qualified struct literal (common.Vec2{...}, section 20.2): the parser recognises it inside the same postfix fieldAccess handling, not as a separate primary production, but it's written here as its own alternative for clarity. structLit/arrayLit are suppressed (falling back to an ordinary fieldAccess/plain identifier read instead) while parsing a forInStmt's own list expression, the no_struct_literal flag section 6.3 walks through in full; the grammar above doesn't encode that context-sensitivity, since EBNF has no clean way to express "this production doesn't apply here," but the parser's own behaviour does.
&&/|| produce Expr_Logical nodes, not Expr_Binary (section 4.2's own note): they short-circuit, the right operand only evaluated (or, from Chapter 11 onward, only compiled to run) when the left operand doesn't already decide the result.
An enum member reference (Phase.Attacking) and a qualified function reference (common.lerp) both parse as an ordinary fieldAccess, indistinguishable at parse time from a struct field read; Chapter 7's resolver, not the grammar, is what tells the three apart, by checking whether the object side names a known enum, a known qualified function, or neither (in which case it's an ordinary runtime field access).
What's deliberately absent
- No closures capturing an outer local by reference. Muspel's closures capture by value at creation time only (section 12.4); full upvalue-style closures are a real, scoped-out feature.
- No pattern matching, no static type system. Every struct field carries no declared type (Chapter 5); nothing here is inferred or checked ahead of running, beyond the resolver's own name/arity/field/member checks (Chapter 7). Arrays are taught fixed-capacity in Chapter 6; the reference
code/extends them into growable lists (list/append/pop/remove) as a post-core addition, described in Appendix G.
The multiplicative rule's % (modulo) is likewise a post-core addition (Appendix G): Chapter 4's grammar has only * and /.
- No wildcard/unqualified imports. Every reference to something another file exports is qualified by its import alias (section 20.3's own Design decision); there is no
import "common.mus"form that pulls declarations directly into the importing file's namespace.
Every one of these is a real, well-scoped omission, not an oversight; §0 and section 12.4 name the trade-off for closures specifically, and Chapter 21.8 collects the rest as a closing "where to go next." Appendix G picks some of these back up and shows exactly what adding one involves.