Lyric Grammar
Mirror. The canonical home of this document is
lyriclang/lyric-spec→spec/02-grammar.md. This copy exists for the toolchain's tests and doc site; CI diffs everything below the marker against the canonical body. Edit it there, mirror it here.
The formal grammar of the Lyric language. This document defines the syntax only; it says nothing about typing, name resolution or runtime behaviour.
Notation
EBNF with the following conventions:
| Form | Meaning |
|---|---|
'x' |
terminal |
X |
non-terminal |
{ X } |
zero or more |
[ X ] |
zero or one |
X \| Y |
alternative |
( X ) |
grouping |
X . |
end of production |
(* … *) |
note |
a'..'z denotes a character range. any-char denotes any character of the source encoding.
1. Lexical grammar
1.1 Source file
SourceFile = { Trivia | Token } EOF .
Trivia = Whitespace | LineComment | BlockComment .
Source is UTF-8. A byte-order mark at offset 0 is skipped. Line terminators are \n and \r\n.
1.2 Whitespace and comments
Whitespace = ' ' | '\t' | '\n' | '\r' .
LineComment = '//' { any-char-except-newline } .
BlockComment = '/*' { BlockComment | any-char } '*/' .
DocComment = '///' { any-char-except-newline } .
Block comments nest: /* /* */ */ is one comment. Doc comments are tokenized and carry no
grammatical meaning.
1.3 Identifiers
IdentStart = 'a'..'z' | 'A'..'Z' | '_' .
IdentCont = IdentStart | '0'..'9' .
IDENTIFIER = IdentStart { IdentCont } . (* unless it is a keyword *)
AT_IDENT = '@' IdentStart { IdentCont } .
AT_LBRACKET = '@' '[' . (* adjacent; opens an attribute group *)
1.4 Keywords
Reserved; never an identifier:
module import as pub static
struct class enum interface extend
fn mut let var params
if else while do for in match
break continue return yield resume defer
try catch throw
true false null
this
Contextual; reserved only in the position shown, an identifier everywhere else:
| Word | Position |
|---|---|
type |
first token of a top-level declaration, before an identifier |
throws |
after the return type of a function signature, and after a coroutine type |
Built-in type names (int, string, …) are identifiers, not keywords.
1.5 Literals
IntLit = ( DecLit | HexLit | BinLit | OctLit ) [ IntSuffix ] .
DecLit = DecDigit { DecDigit | '_' } .
HexLit = '0' ( 'x' | 'X' ) HexDigit { HexDigit | '_' } .
BinLit = '0' ( 'b' | 'B' ) BinDigit { BinDigit | '_' } .
OctLit = '0' ( 'o' | 'O' ) OctDigit { OctDigit | '_' } .
IntSuffix = ( 'i' | 'u' ) DecDigit { DecDigit } . (* i8 … i64, u8 … u64 *)
FloatLit = DecLit ( '.' DecLit [ Exponent ] | Exponent ) [ FloatSuffix ]
| DecLit FloatSuffix .
Exponent = ( 'e' | 'E' ) [ '+' | '-' ] DecDigit { DecDigit | '_' } .
FloatSuffix = 'f' DecDigit { DecDigit } . (* f32, f64 *)
StringLit = '"' { StringChar | EscapeSeq } '"' .
InterpolatedStr = 'f' '"' { StringChar | EscapeSeq | Interpolation } '"' .
Interpolation = '{' Expr [ ':' FormatSpec ] '}' .
CharLit = "'" ( CharChar | EscapeSeq ) "'" .
EscapeSeq = '\' ( 'n' | 'r' | 't' | '\' | '"' | "'" | '0'
| 'x' HexDigit HexDigit
| 'u' '{' HexDigit { HexDigit } '}' ) .
BoolLit = 'true' | 'false' .
NullLit = 'null' .
Inside an interpolated string, {{ and }} produce a literal brace. FormatSpec runs to the
matching }, tracking nested braces, parentheses and brackets. A \u{…} escape carries at
most eight hex digits and must name a Unicode scalar value.
1.6 Operators and punctuation
( ) { } [ ]
, . ; : :: -> =>
? ?. ?? !
+ - * / %
& | ^ ~
<< >>
== != < <= > >=
&& || !
++ --
.. ..=
= += -= *= /= %=
&= |= ^= <<= >>=
&&= ||= ??=
The lexer takes the longest match: <<= before << before <.
:: introduces an interface list and never appears in a module path; . separates path
segments. ! is postfix force-unwrap and prefix logical not.
2. Module structure
Module = { Attribute } ModuleHeader { TopLevelDecl }
| { TopLevelDecl } .
ModuleHeader = 'module' ModulePath ';' .
ModulePath = IDENTIFIER { '.' IDENTIFIER } .
Attribute = SingleAttr | AttrGroup .
SingleAttr = AT_IDENT { '.' IDENTIFIER } [ AttrArgs ] .
AttrGroup = AT_LBRACKET AttrEntry { ',' AttrEntry } [ ',' ] ']' .
AttrEntry = IDENTIFIER { '.' IDENTIFIER } [ AttrArgs ] .
AttrArgs = '{' [ AttrArg { ',' AttrArg } [ ',' ] ] '}'
| '(' Expr ')' .
AttrArg = IDENTIFIER '=' Expr .
ImportDecl = 'import' ModulePath [ ImportClause ] ';' .
ImportClause = '{' IDENTIFIER { ',' IDENTIFIER } [ ',' ] '}'
| 'as' IDENTIFIER .
TopLevelDecl = ImportDecl
| { Attribute } [ 'pub' ] ( FunctionDecl
| StructDecl
| ClassDecl
| EnumDecl )
| [ 'pub' ] ( InterfaceDecl
| ExtendDecl
| GlobalBinding
| TypeAlias ) .
GlobalBinding = BindingStmt . (* 'let' only *)
TypeAlias = [ 'opaque' ] 'type' IDENTIFIER '=' TypeExpr ';' .
Neither type nor opaque is a keyword; both are contextual, so both remain usable as
identifiers.
A plain alias is a name for a type, not a new type. An opaque alias (since v1.15) is a new
IDENTITY over the same layout: nothing converts implicitly in either direction, an explicit as
to exactly the underlying type and back is the one crossing, ==/!= compare within one alias,
and everything else — arithmetic, ordering, constraint satisfaction, f-string rendering — is
refused. At runtime an opaque value is its underlying value; in a native signature the alias
resolves to the underlying, which is how a handle crosses the host boundary as a plain number
while scripts cannot forge one.
The module header is optional. In an entry file the name then comes from the file name; in a file
reached through an import, the name is the imported path, and a header that disagrees with it is
an error.
An attribute before the header describes the module; in a file without a header an attribute at the
top belongs to the first declaration. The path names a struct type, and an AttrArg value must be
a value at compile time — an integer, float, string, char or bool literal, optionally
sign-prefixed, or (since v2.4) a name denoting a let bound to one — which is a semantic rule,
not a syntactic one. A parenthesized argument (since 3.9) carries exactly one such value — which
attribute admits that form is a semantic rule too (§4.7) — and a GROUP @[A, B { … }, C(…)]
declares the same attribute list the stacked spelling declares.
3. Declarations
3.1 Functions
FunctionDecl = [ 'pub' ] [ 'static' ] [ 'mut' ] 'fn' IDENTIFIER [ GenericParams ]
'(' [ ParamList ] ')' [ ':' TypeExpr ]
[ 'throws' [ TypeExpr ] ]
( Block | ';' ) .
GenericParams = '<' GenericParam { ',' GenericParam } '>' .
GenericParam = IDENTIFIER [ '::' '[' TypeExpr { ',' TypeExpr } ']' ] .
ParamList = Param { ',' Param } .
Param = [ 'params' ] IDENTIFIER ':' TypeExpr [ '=' Expr ] .
A body of ; declares a function without one. params may appear on the last parameter only,
whose type must be an array.
3.2 Structs
StructDecl = [ 'pub' ] 'struct' IDENTIFIER [ GenericParams ]
[ '::' InterfaceList ]
'{' [ StructBody ] '}' .
InterfaceList = '[' TypeExpr { ',' TypeExpr } ']' .
StructBody = { StructMember [ ',' ] } .
StructMember = Field | FunctionDecl | StaticBinding .
StaticBinding = [ 'pub' ] 'static' BindingStmt .
Field = IDENTIFIER ':' TypeExpr [ '=' Expr ] .
Members are separated by ,. Only a field requires it: a member that closes itself — a block body
ending in }, a bodiless method or a static let ending in ; — may omit it.
3.3 Classes
ClassDecl = [ 'pub' ] 'class' IDENTIFIER [ GenericParams ]
[ '::' InterfaceList ]
'{' [ ClassBody ] '}' .
ClassBody = { ClassMember [ ',' ] } .
ClassMember = Field | FunctionDecl | StaticBinding .
3.4 Enums
EnumDecl = [ 'pub' ] 'enum' IDENTIFIER [ GenericParams ]
[ '::' InterfaceList ]
'{' [ EnumBody ] '}' .
EnumBody = EnumVariant { ',' EnumVariant } [ ',' ]
[ ';' { FunctionDecl } ] .
EnumVariant = IDENTIFIER [ TupleVariant | StructVariant ] .
TupleVariant = '(' TypeExpr { ',' TypeExpr } ')' .
StructVariant = '{' Field { ',' Field } [ ',' ] '}' .
The ; separates the variant list from the method list.
3.5 Interfaces
InterfaceDecl = [ 'pub' ] 'interface' IDENTIFIER [ GenericParams ]
[ '::' InterfaceList ]
'{' { InterfaceMember } '}' .
InterfaceMember = FunctionDecl .
An interface member with a body is a default implementation.
The interface list names the parent: whoever conforms to the interface conforms to the parent too, transitively — abstract members of the whole chain must be implemented, default methods of the whole chain are inherited, and a constraint on the parent is satisfied by the child. Semantic rules, not syntactic ones: every entry names an interface, the chain is acyclic, an entry may not repeat an earlier one, and a member of the chain cannot be redeclared — an inherited member keeps its declaring interface. Several parents are allowed since 2.16 (§5.2); what two of them may not do is contribute the same member name from different declarations.
A value of interface type reaches the members of its whole chain. It does not, however, convert to a value of the parent's type: conformance is implied for the implementing type, so take the concrete value through the parent where a parent-typed value is needed.
A member carries no static: it is reached through a vtable slot, which takes a receiver, and a
static member has none. Declare it on the implementing type instead.
3.6 Extend blocks
ExtendDecl = 'extend' TypeExpr [ '::' InterfaceList ]
'{' { FunctionDecl } '}' .
4. Type expressions
TypeExpr = TypePrefix TypeAtom { TypeSuffix } [ ThrowsSuffix ] .
ThrowsSuffix = 'throws' [ TypeExpr ] . (* coroutine types only *)
TypePrefix = [ '?' ] .
TypeAtom = BuiltinType
| ModulePath [ '<' TypeExpr { ',' TypeExpr } '>' ]
| FunctionType
| TupleType
| GroupedType .
FunctionType = 'fn' '(' [ TypeExpr { ',' TypeExpr } ] ')' '->' TypeExpr .
TupleType = '(' TypeExpr ',' TypeExpr { ',' TypeExpr } ')' . (* arity >= 2 *)
GroupedType = '(' TypeExpr ')' .
TypeSuffix = '[' ']' .
BuiltinType = 'int' | 'uint' | 'float'
| 'int8' | 'int16' | 'int32' | 'int64'
| 'uint8' | 'uint16' | 'uint32' | 'uint64'
| 'float32' | 'float64'
| 'bool' | 'char' | 'string' | 'void' .
A function type extends as far to the right as possible: fn(int) -> void[] is a function
returning void[]. An array of function values is written (fn(int) -> void)[].
ThrowsSuffix says what pulling a coroutine may throw: Coroutine<int> throws Exception, or
throws alone for any Throwable. It is valid on a coroutine type and nowhere else — every other
value runs at its call, where the callee's own clause already says what it throws.
It is not parsed after a function's return type: the throws there is the FUNCTION's clause
and remains so. A coroutine function needs none, because that clause has always described the same
thing — fn gen(): Coroutine<int> throws Exception declares a coroutine whose pull may throw. The
suffix exists for the positions a clause cannot reach: a field, a parameter, a binding.
? binds to the atom together with its suffixes: ?T[] is an optional array. An array of
optionals is written (?T)[].
? does not nest: ??T is not a type.
5. Statements
Block = '{' { Statement } '}' .
Statement = Block
| BindingStmt
| DestructuringStmt
| IfStmt
| WhileStmt
| DoWhileStmt
| ForInStmt
| MatchStmt
| BreakStmt
| ContinueStmt
| ReturnStmt
| YieldStmt
| DeferStmt
| ThrowStmt
| TryStmt
| ExprStmt .
BindingStmt = ( 'let' | 'var' ) IDENTIFIER [ ':' TypeExpr ] [ '=' Expr ] ';' .
DestructuringStmt = ( 'let' | 'var' ) TuplePattern [ ':' TypeExpr ] '=' Expr ';' .
IfStmt = 'if' '(' Expr ')' Block [ 'else' ( Block | IfStmt ) ] .
WhileStmt = 'while' '(' Expr ')' Block .
DoWhileStmt = 'do' Block 'while' '(' Expr ')' ';' .
ForInStmt = 'for' '(' IDENTIFIER 'in' ( RangeExpr | Expr ) ')' Block .
RangeExpr = Expr ( '..' | '..=' ) Expr .
MatchStmt = 'match' '(' Expr ')' '{' { MatchArm } '}' .
MatchArm = Pattern [ 'if' Expr ] '=>' ( Expr | Block ) .
BreakStmt = 'break' ';' .
ContinueStmt = 'continue' ';' .
ReturnStmt = 'return' [ Expr ] ';' .
YieldStmt = 'yield' [ Expr ] ';' .
DeferStmt = 'defer' ( Block | Expr ';' ) .
ThrowStmt = 'throw' Expr ';' .
TryStmt = 'try' Block { CatchClause } .
CatchClause = 'catch' '(' CatchBinding ')' Block .
CatchBinding = '_'
| IDENTIFIER ':' TypeExpr
| IDENTIFIER .
ExprStmt = Expr ';' .
A destructuring binding requires an initializer. Its pattern admits names, _ and nested tuple
patterns; no form that can fail.
RangeExpr appears in ForInStmt and nowhere else — it is a loop head, not a value. There is no
range type: 0..9 says which numbers the loop walks, and a program that binds one, stores one or
passes one to a function is refused there rather than at a later stage. RangePattern (§Patterns)
uses the same two tokens for a different purpose and is unrelated to this rule.
ExprStmt admits a call, an assignment or resume. A statement does not begin with a struct
initializer: at that position Name { … } is a name followed by a block.
An arm whose body is an expression is followed by ,, except for the last arm before }. An arm
whose body is a block may omit it.
6. Expressions
6.1 Precedence
Highest first. All levels are left-associative unless stated.
| # | Operators | Associativity |
|---|---|---|
| 1 | postfix . ?. [ ] ( ) ++ -- ! |
left |
| 2 | prefix ! - ~ ++ -- resume |
right |
| 3 | as |
left |
| 4 | * / % |
left |
| 5 | + - |
left |
| 6 | << >> |
left |
| 7 | .. ..= |
non-associative |
| 8 | & |
left |
| 9 | ^ |
left |
| 10 | \| |
left |
| 11 | < <= > >= |
left |
| 12 | == != |
left |
| 13 | && |
left |
| 14 | \|\| |
left |
| 15 | ?? |
right |
| 16 | assignment | right |
6.2 Grammar
Expr = Assign .
Assign = Coalesce [ AssignOp Assign ] .
AssignOp = '=' | '+=' | '-=' | '*=' | '/=' | '%='
| '&=' | '|=' | '^=' | '<<=' | '>>='
| '&&=' | '||=' | '??=' .
Primary = IntLit | FloatLit | StringLit | InterpolatedStr
| CharLit | BoolLit | NullLit
| 'this'
| IDENTIFIER
| TypePath
| '(' Expr ')'
| IfExpr
| MatchExpr
| StructInit
| ArrayLit
| TupleLit
| Lambda .
Lambda = '(' [ LambdaParam { ',' LambdaParam } ] ')' [ ':' TypeExpr ]
'=>' ( Expr | Block ) .
LambdaParam = IDENTIFIER [ ':' TypeExpr ] .
ResumeExpr = 'resume' UnaryExpr .
StructInit = TypePath '{' [ StructInitField { ',' StructInitField } [ ',' ] ] '}' .
StructInitField = IDENTIFIER '=' Expr .
TypePath = ModulePath [ '<' TypeExpr { ',' TypeExpr } '>' ] [ '.' IDENTIFIER ] .
CallArgs = [ '<' TypeExpr { ',' TypeExpr } '>' ] '(' [ Expr { ',' Expr } ] ')' .
ArrayLit = '[' [ Expr { ',' Expr } [ ',' ] ] ']' .
TupleLit = '(' Expr ',' Expr { ',' Expr } ')' .
IfExpr = 'if' '(' Expr ')' Expr 'else' Expr .
MatchExpr = 'match' '(' Expr ')' '{' { MatchArm } '}' .
else is mandatory in IfExpr; else if is a nested IfExpr.
In a TypePath, the trailing '.' IDENTIFIER names an enum variant: Opt<int>.Some. The type
arguments belong to the type and precede that segment.
6.3 Resolving <
A < after a name opens a type-argument list when it closes balanced and only tokens that can
occur in a type expression stand between the two, and the token that follows is one of:
| Follower | Form |
|---|---|
( |
call with explicit type arguments — f<int>() |
{ |
struct initializer — Pair<int> { … } |
. |
type path in value position — Pair<int>.of(3) |
In every other case < is the comparison operator.
7. Patterns
Pattern = '_'
| Literal
| IDENTIFIER
| TypePath [ '(' Pattern { ',' Pattern } ')' ]
| TypePath '{' [ FieldPattern { ',' FieldPattern } [ ',' ] ] '}'
| TuplePattern
| Pattern '|' Pattern
| RangePattern .
FieldPattern = IDENTIFIER [ '=' Pattern ] .
TuplePattern = '(' Pattern ',' Pattern { ',' Pattern } ')' .
RangePattern = Literal ( '..' | '..=' ) Literal .
Literal is an integer, float, string, char, bool or null literal. A FieldPattern without =
binds the field to its own name.