Lyric Language Reference
Concise reference for writing Lyric code. Based on parser, checker, stdlib, and test files as of 2026-06-16.
Bootstrap Philosophy
The #1 goal of the bootstrap is to make Lyric as awesome as Go — maybe even better — for writing compilers. The bootstrap compiler is being ported from Go to Lyric. Every time we hit jank, friction, or missing features, we fix the compiler rather than work around the issue. The bootstrap process is the design feedback loop that makes Lyric great.
Key principles:
- Don't work around language issues — fix them in the compiler
- If something feels janky, that's a signal to improve the language
- The bootstrap .ly files (ast.ly, lexer.ly, parser.ly, etc.) are the primary test of language ergonomics
- Target: C backend via monomorphization (Go backend deleted)
Modules and Packages
Package = Directory
A package is a directory of .ly files. All .ly files in a directory belong to the same package. The package name is the directory name (not the filename).
myproject/
lyric.mod # module root
main.ly # package "myproject" (or "main" if entry point)
ast/
ast.ly # package "ast"
expr.ly # package "ast" (same directory)
parser/
parser.ly # package "parser"
expr_parser.ly # package "parser"
All .ly files within a package are merged into a single compilation unit — declaration order across files doesn't matter.
Module = Project
A module is a project rooted at a lyric.mod file. A module defines the import path prefix and is the unit of compilation — it produces either a program binary or a shared library.
# lyric.mod
module github.com/user/mycompiler
Imports
Import a package by name — the compiler resolves it to a directory relative to the module root:
import ast
import parser
func main() {
let file = parser.parse("hello.ly")
let node = ast.new_node("expr")
print(node.name)
}
The package name is both the import identifier and the directory name. Access is always qualified: ast.Node, parser.parse().
Visibility: Only pub declarations are accessible through an import. Non-pub declarations are package-private.
// ast/ast.ly
pub struct Node { name: string } // visible to importers
func helper() -> i32 { return 42 } // package-private
Cycle detection: Circular imports are a compile error.
Nested packages: For packages in subdirectories, use a path with an alias:
import v2 from "parser/v2"
The lyric Block (Optional)
lyric BlockName {
// types, functions, relations, impls, constants
}
The lyric wrapper is optional. Bare .ly files with top-level declarations are valid — the package name comes from the directory, not the block name or filename:
// ast/expr.ly — no wrapper needed, package is "ast"
enum Color { Red, Green, Blue }
func greet(name: string) -> string { return f"Hello {name}" }
When a lyric block is present, its name is used for C symbol prefixing but does not affect the package name.
Compilation Model
Lyric compiles an entire module at once — all packages are resolved, merged with namespace prefixing, and emitted as a single C file compiled to one binary. There is no separate compilation of individual files or packages.
lyric compile . # compile module in current directory
lyric compile ~/projects/mycompiler/ # compile module at path
lyric compile main.ly -o myprogram # single-file, no module needed
lyric compile main.ly ast.ly # multi-file, no module needed
When given a directory, the compiler looks for lyric.mod, finds main() in the root package, and recursively resolves all imports. When given a .ly file, it checks parent directories for lyric.mod — if found, uses module mode; otherwise, single-file mode.
The stdlib (stdlib/std.ly, stdlib/string.ly, etc.) is auto-imported into all packages.
Newlines and Multi-line Expressions
Newlines are statement terminators. However, inside () and [] brackets, newlines are treated as whitespace, enabling multi-line expressions:
let result = add(
first_arg,
second_arg,
third_arg
)
let xs: [i32] = [
10, 20, 30,
40, 50, 60
]
Note: {} braces do NOT suppress newlines (they delimit blocks with statements).
Primitives
bool, u8, u16, u32, u64, u128, u256, i8, i16, i32, i64, i128, i256, f32, f64, f128, string, unit, any
- Default integer literal:
i32. Cast withx as u64. - Implicit numeric widening: smaller types widen to larger in binary ops (e.g.
i32 + i64→i64). unit— void/no-value type (for functions with no return value).int/uint— platform-width, Go interop only. NOT part of the Lyric numeric tower.any— stdlib union alias /void*.- Character literals:
'A'→u8(65). Escapes:\n \r \t \\ \' \" \0 \x##. - Both
nullandnullaccepted by the lexer (mapped to the same token).
Type Expressions
T? // optional (nullable)
T | U // union type (exhaustively typed)
[T] // slice (fat pointer: data + len + cap)
(T1, T2) // tuple (access via ._0, ._1)
fn(T1, T2) -> R // function type
channel<T> // channel
No built-in map[K]V — use Dict<K,V> from stdlib (see Stdlib Classes).
Slices
Slices are dynamic arrays: a fat pointer with data, len, and cap. Type syntax: [T].
let xs: [i32] = [1, 2, 3]
let empty: [string] = []
Indexing and slicing:
xs[0] // element access (0-indexed)
xs[1:3] // sub-slice [low:high) — shares underlying data
Concatenation:
let combined = xs + [4, 5, 6] // returns new slice
xs.extend(other) // append other's elements in-place
Builtin functions:
len(xs) // length
append(xs, 42) // returns new slice with element appended
Methods:
xs.push(elem) // append in-place (mutates xs)
xs.pop() // remove and return last element
xs.len() // same as len(xs)
xs.contains(elem) // linear search, returns bool
xs.index_of(elem) // returns i32 index, -1 if not found
xs.first() // returns T? (null if empty)
xs.last() // returns T? (null if empty)
xs.is_empty() // returns bool
xs.sort() // in-place sort
xs.reverse() // in-place reverse
xs.remove(elem) // remove first occurrence
xs.join(sep) // join string slice with separator → string
xs.extend(other) // append all elements of other in-place
Mutating methods (.push, .pop, .sort, .reverse, .remove, .clear, .extend) modify the receiver in-place. When called on a class field (obj.items.push(x)), the compiler automatically uses a reference to avoid the copy-then-discard problem.
Strings
Strings are [u8] byte slices. String literals create lyric_string values (length-prefixed, with hidden trailing \0 for C interop).
let s = "hello"
let ch = s[0] // u8 (104)
let sub = s[1:4] // "ell" — sub-slice, shares data
Concatenation:
let s = "hello" + " world" // returns new string
push_bytes(dst, src) // append src bytes to dst in-place
Methods:
s.len() // byte length (i32)
s.contains(needle) // bool
s.has_prefix(p) // bool
s.has_suffix(p) // bool
s.index_of(needle) // i32 (-1 if not found)
s.is_empty() // bool
s.trim() // strip whitespace from both ends → new string
s.to_lower() // → new string
s.to_upper() // → new string
s.replace(old, new) // replace all occurrences → new string
s.repeat(n) // repeat n times → new string
s.split(sep) // → [string]
s.char_at(i) // → string (single-byte string)
Stdlib (string.ly): str_concat(a, b), str_split_n(s, sep, n), str_trim_left(s), str_trim_right(s), str_replace(s, old, new), str_repeat(s, n), str_join(parts, sep), str_has_prefix(s, p), str_has_suffix(s, p), str_index_of(s, needle), str_to_upper(s), str_to_lower(s).
Structs (Value Types)
struct Point {
x: f64
y: f64
}
- Copied by value. No methods (use classes for methods).
- Direct field access:
p.x. - Fields can have defaults:
width: i32 = 800. - Generic structs are supported:
struct Pair<T> { first: T, second: T }. - Positional construction inside expressions:
Point { 1.0, 2.0 }(inside parens, brackets, or arg lists where{is unambiguous). - Cannot be relation targets (no identity, no heap allocation).
Classes (Heap-Allocated, By Reference)
class Counter {
count: i32
items: [string]
func increment(self) {
self.count = self.count + 1
}
func get(self) -> i32 {
return self.count
}
}
// Struct-literal construction
let c = Counter { count: 10 }
c.increment()
- Fields declared in body. Unset fields zero-initialized.
- Fields can have defaults:
count: i32 = 0. pubprefix for public fields:pub name: string.- Construction uses struct-literal syntax:
ClassName { field: value }. selffor receiver. Direct field access:self.count.- Generic:
class Pair<T> { first: T second: T } .destroy()— deterministic destruction (from relation destructors).
Explicit Constructors
class HttpClient {
url: string
timeout: u32 = 30
pool: ConnectionPool?
func HttpClient(self, base_url: string, timeout: u32) {
self.url = base_url
self.timeout = timeout
self.pool = ConnectionPool { base_url: base_url }
}
}
// Call syntax when explicit constructor exists
let client = HttpClient("http://api.com", 60)
Without an explicit constructor, only struct-literal syntax is allowed.
final Function
Classes may declare a final function, called immediately before the auto-generated destructor:
class Connection {
fd: i32
final func cleanup(self) {
close_fd(self.fd)
}
}
Execution order on .destroy(): final → auto-destructor (cascade + unlink) → free slot.
No Inheritance
Lyric does not support classical inheritance. Classes can declare implements to signal interface satisfaction (compiler-checked):
class Task implements Displayable, Prioritizable { ... }
Shared behavior belongs in interfaces or in separate classes held as dependencies.
Enums
// Simple (no associated data — emits C typedef enum)
enum Color { Red Green Blue }
// With associated data (tagged union)
enum Shape {
Circle(radius: f64)
Rect(w: f64, h: f64)
Point
}
// Qualified access
let c = Color.Red
Match on enums:
match shape {
Circle(r) => { println(f"radius: {r}") }
Rect(w, h) => { println(f"{w}x{h}") }
Point => { println("point") }
}
Multi-pattern match arms
Multiple patterns per arm separated by |:
match kind {
OPlus | OMinus => { PREC_ADDITIVE }
OStar | OSlash | OPercent => { PREC_MULT }
OEqEq | OBangEq => { PREC_EQUALITY }
_ => { PREC_NONE }
}
This works for simple variants, variants with bindings (if all alternatives bind the same names), and wildcard _.
Match guards
match token {
Ident(name) if name == "self" => { handle_self() }
Ident(name) => { handle_ident(name) }
_ => { handle_other() }
}
Match as expression
match can be used as an expression (returns a value):
let prec = match kind {
OPlus => { 9 }
OStar => { 10 }
_ => { 0 }
}
Match on non-enum values
Match works on enum types. For non-enum dispatch, use if/else if chains.
if let — conditional pattern match
Extract a single variant without a full match:
if let Circle(r) = shape {
println(f"radius: {r}")
} else {
println("not a circle")
}
Bindings (r) are scoped to the then-block. The else branch is optional.
let..else — assertive pattern extract
Extract variant data into the surrounding scope, with a mandatory diverging else:
let Circle(r) = shape else {
return -1.0
}
// r is now in scope
println(f"radius: {r}")
The else block must diverge (return, break, continue). Bindings escape into the outer scope — this avoids rightward drift for the common "extract or bail" pattern.
Type Aliases
type Name = string
type Callback = fn(i32) -> bool
Union Types
let value: string | i32 = 42
func process(x: string | i32) -> string {
match x {
string => { "it's a string" }
i32 => { "it's an int" }
}
}
Any member type is assignable to the union. C backend emits tagged unions; Go backend emits any with switch val.(type).
Variables & Constants
let x = 42 // immutable
let mut y: i32 = 0 // mutable, typed
let ref view = data[5:10] // immutable view (no copy, shared backing)
let mut ref buf = packet[0:16] // mutable view (write through, no copy)
Copy-on-assign: Assignment always copies for value types (primitives, structs, tuples, slices). let mut y = x creates an independent mutable copy.
ref bindings: let ref creates a zero-copy view into existing data instead of copying. Useful for parsing, serialization, and crypto. The source must outlive the ref binding. let mut ref allows writing through the view.
Binding grammar: let [mut] [ref] name [: Type] = expr
Top-level constants can be declared directly inside lyric blocks:
lyric parser {
let PREC_NONE: i32 = 0
let PREC_OR: i32 = 1
// ...
}
These compile to static globals in C.
Control Flow
if cond { ... } else if cond2 { ... } else { ... }
// if-expression (both branches required, same type)
let result = if cond { a } else { b }
while cond { ... }
for item in collection { ... }
for item, idx in collection { ... }
match expr {
Pattern => ...
}
Variant Check: is
if expr.kind is ExprCall { ... }
if !(node is Leaf) { ... }
Returns bool. Does not bind variables — use if let for destructuring.
Type Casts: as
Postfix as for numeric conversions:
let x: i32 = 42
let y: i64 = x as i64 // widen
let z: i32 = y as i32 // narrow (may truncate)
Only numeric ↔ numeric casts are supported. All casts are unchecked.
Block Scoping
Lyric has block-level scoping. Any { } block creates a new scope — variables declared inside are local to that block:
func example() {
let x = 1
{
let x = 2 // shadows outer x, legal
println(x) // prints 2
}
println(x) // prints 1
}
Functions
func add(a: i32, b: i32) -> i32 { return a + b }
pub func public_fn() { ... }
// Generic
func identity<T>(x: T) -> T { return x }
// External method (multi-class interface pattern)
func T.method(self) -> i32 { ... }
// Lambdas (two syntaxes)
let f = (x: i32) -> i32 { return x * 2 } // paren-lambda
let g = |x: i32| -> i32 { return x * 2 } // pipe-lambda
func is the function declaration keyword. fn is for type syntax only (e.g., fn(i32) -> bool) and is a contextual keyword — it can be used as a variable name. func and fn are interchangeable in declarations.
Mutable Parameters (mut)
Structs are value types — passing them to a function copies them.
Use mut on both the parameter declaration and call site to pass by mutable reference:
struct Point { x: i32, y: i32 }
func translate(mut p: Point, dx: i32, dy: i32) {
p.x = p.x + dx // modifies caller's copy
p.y = p.y + dy
}
let mut pt = Point { x: 10, y: 20 }
translate(mut pt, 5, 3)
assert_eq(pt.x, 15) // mutation visible to caller
Slice elements can also be passed as mut, enabling in-place mutation:
func double_x(mut p: Point) {
p.x = p.x * 2
}
let mut points = [Point { x: 1, y: 2 }, Point { x: 3, y: 4 }]
double_x(mut points[0]) // mutates the element in-place
assert_eq(points[0].x, 2) // doubled
assert_eq(points[1].x, 3) // unchanged
Rules:
mutrequired on both parameter and call site (prevents accidental mutation)- Variables and slice element accesses can be passed as
mut - For classes (already heap-allocated),
mutis unnecessary — they're already pointers - Don't use
muton class params — creates double-pointer segfaults - In C backend:
mutparams becomeT*, call sites emit&xor&slice.data[i]
Try Operator and Error Handling
Lyric uses (T, error) tuples for error handling, similar to Go.
The error interface
error is a built-in interface declared in stdlib:
interface error {
func error.message(self) -> string
}
Any class with a message(self) -> string method satisfies error via structural subtyping. The stdlib provides a default concrete implementation:
class Error {
msg: string
pub func message(self) -> string { return self.msg }
}
Custom error types
class ParseError {
msg: string
span: Span
source_line: string
pub func message(self) -> string {
return f"{self.span.start.line}: {self.msg}"
}
}
Any class with message(self) -> string can be used where error is expected.
The ? operator
The ? operator propagates errors from (T, error) returns:
func load() -> (string, error) {
let data = read_file("x.txt")? // propagates error on failure, data is string
return (data, null)
}
? unwraps the success value: after let x = foo()?, x is T (not T?). If the error is non-null, the function returns immediately with the error.
Statement-level only. Containing function must return (T, error).
F-strings
let msg = f"Hello {name}, count={x + 1}"
Escaped braces: {{ → literal {, }} → literal }.
Triple-quote Strings
Triple-quoted strings preserve newlines and don't require escaping quotes:
let sql = """
SELECT *
FROM users
WHERE name = "Alice"
"""
Multi-Class Interfaces
Interfaces span multiple type parameters, defining relationships between types.
interface Graph<G, N, E> {
// Abstract methods bound to type params
func G.nodes(self) -> [N]
func N.out_edges(self) -> [E]
func E.tgt_node(self) -> N
// Default method
pub func count_edges(graph: G) -> i32 {
let mut total: i32 = 0
let nodes = graph.nodes()
let mut i: i32 = 0
while i < len(nodes) {
total = total + len(nodes[i].out_edges())
i = i + 1
}
return total
}
// Field injection — adds fields to implementing classes
field P.first: C?
field C.parent: P?
// Destructor injection
destructor P { ... }
destructor C { ... }
}
Interface Embedding
interface OwningList<P, C> {
embed DoublyLinked<P, C> // copies fields and destructors (not methods)
destructor P { ... } // can add/override
}
Impl Blocks
// Alias: wire interface method → class method
impl Graph<SimpleGraph, SimpleNode, SimpleEdge> {
G.nodes = SimpleGraph.get_nodes
N.out_edges = SimpleNode.get_edges
E.tgt_node = SimpleEdge.get_target
}
// Field bind: wire interface field → concrete field
impl DoublyLinked<Folder, File> {
P.children <-> Folder.items
C.label <-> File.title
}
// Inline implementation
impl Printable<Widget> {
P.to_string = (self) -> string { return f"Widget({self.name})" }
}
Where Clauses
pub func count<P, C>(p: P) -> i32 where DoublyLinked<P, C> {
return len(p.children())
}
Relations
Relations declare ownership between classes using stdlib interfaces. They trigger field injection and auto-generate impl bindings.
Syntax:
relation [Hint] Parent[:parent_label] owns|refs [Child[:child_label]]
- Hint — stdlib interface name (ArrayList, OwningList, RefList, HashedList)
- Labels — prefix for injected field names. Parent label prefixes parent's fields, child label prefixes child's fields.
- owns — cascade destroy children when parent destroyed
- refs — unlink children when parent destroyed (no cascade)
ArrayList — dynamic array ownership
class Team {
name: string
}
class Player {
name: string
}
relation ArrayList Team:roster owns [Player:team]
impl ArrayList<Team, Player> {
P.children <-> Team.roster_children
}
Injected fields: Team.roster_children: [Player], Player.team_parent: Team?, Player.team_index: i32.
Functions: array_append<Team, Player>(t, p), array_remove<Team, Player>(p).
OwningList — doubly-linked list, cascade destroy
relation OwningList Team:team owns [Player:player]
Injected fields: Team.team_first: Player?, Team.team_last: Player?, Player.player_next: Player?, Player.player_prev: Player?, Player.player_parent: Team?.
Functions: dll_append<Team, Player>(t, p), dll_remove<Team, Player>(p).
RefList — doubly-linked list, no cascade
relation RefList Room:room refs [Guest:guest]
Same fields as OwningList but parent destruction only unlinks, doesn't destroy children.
HashedList — hash table ownership
class Entry {
key: u64
value: i32
pub func hash_key(self) -> u64 { return self.key }
}
relation HashedList Registry:reg owns [Entry:entry]
impl HashedList<Registry, Entry> {
P.children <-> Registry.reg_children
P.buckets <-> Registry.reg_buckets
P.hash_cap <-> Registry.reg_hash_cap
P.hash_count <-> Registry.reg_hash_count
C.parent <-> Entry.entry_parent
C.index <-> Entry.entry_index
}
Child must implement hash_key(self) -> u64. Functions: hash_insert, hash_lookup, hash_remove, hash_init.
Builtins
Core:
len(x)— length of slice, string, or mapappend(slice, elem)— returns new slice with element appendedpush_bytes(dst, src)— append stringsrcbytes to stringdstin-placeprintln(x),print(x),eprint(x),eprintln(x)— output (autoto_string)isnull(x)— test if optional/class is null
Strings:
hash_string(s) -> u64itoa(n) -> string— integer to stringatoi(s) -> (i64, bool)— string to integerchar_to_string(b) -> string— single byte to string
IO/OS:
read_file(path) -> (string, bool),write_file(path, content) -> boolos_args() -> [string],os_exit(code),os_getwd() -> stringexec_command(name, args) -> (string, bool)path_join(parts: [string]) -> string,path_dir(p),path_base(p),path_ext(p)list_dir(path) -> ([string], bool),file_exists(path) -> bool,mkdtemp() -> string
Testing: assert(cond, msg), assert_eq(actual, expected, msg). See Testing.
Operators:
x!— unwrap optional (panic if null)expr as T— type castx[i]— index into slice/stringx[lo:hi]— sub-slice+— addition for numerics; concatenation for strings and slices (returns new value)
Testing
Lyric has built-in testing support. No frameworks, no ceremony — just assertions, a naming convention, and a CLI command.
Test Functions
Any function whose name starts with test_ is a test. No arguments, no return value:
func test_lexer_keywords() {
let lex = Lexer { source: "if else while" }
let tok = lex.next()
assert_eq(tok.kind, TIf, "expected if keyword")
}
func test_lower_type_primitives() {
let lowerer = Lowerer { temp_id: 0, scope: Dict<Sym, LType> {} }
let te = TypeExpr { kind: TEIdent, name: "i32" }
let lt = lowerer.lower_type(te)
assert(!isnull(lt), "should resolve i32")
assert_eq(lt!.kind, TyI32, "should be TyI32")
}
Assertions
Two builtins, both compiler-provided (they inject file and line automatically):
| Builtin | Behavior |
|---|---|
assert(cond, msg) | If cond is false, prints FAIL file:line: msg and exits with code 1 |
assert_eq(a, b, msg) | If a != b, prints FAIL file:line: msg with expected/actual values and exits with code 1 |
assert_eq uses auto-generated to_string() for enums (variant name), structs and classes (field dump, e.g., Pos{line: 1, col: 5}), and primitives.
Running Tests
lyric test test_lexer.ly lexer.ly ast.ly
lyric test compiles all listed .ly files, discovers test_* functions, generates a main() that calls each one, compiles the C output with gcc, and runs it:
PASS test_lexer_keywords (0.1ms)
PASS test_lexer_strings (0.1ms)
FAIL test_lexer_escapes
assert_eq failed at test_lexer.ly:47
expected: TStringLit
got: TError
3 tests, 2 passed, 1 failed
Tests run sequentially in declaration order. A failed assertion exits that test immediately but does not stop the suite — remaining tests still run.
Test File Conventions
- Test files are regular
.lyfiles — no special syntax or annotations - Name test files
test_*.lyby convention (not enforced) - Test functions can use all language features including classes, generics, and relations
- Tests share a compilation unit with the code they test
Stdlib Classes
Sym— interned symbol with pre-computed FNV-1a hash. Create viasym("name")or backtick syntax`name`(which desugars tosym("name")at parse time). Methods:get_name() -> string,get_hash() -> u64. ImplementsHashable.Dict<K,V>— generic hash table whereK: Hashable. Methods:set(key, val),get(key) -> V?,has(key) -> bool,remove(key),keys() -> [K],len() -> i32. Constructor:Dict<K,V>().Hashable— interface requiringget_hash(self) -> u64.Symimplements it.stringdoes NOT implementHashable— wrap strings withsym()to use as hash keys.Comparable— interface for ordering (numeric, string, bool).Equatable— interface for equality comparison (numeric, string, bool).Error— for(T, error)returns.message() -> string. Create viaError { msg: "..." }.StringBuilder—write(s),write_byte(b),to_string(),len(). Create vianew_string_builder().
Concurrency
spawn { ... }
let ch: channel<i32> = make_channel<i32>()
ch.send(value)
let v = ch.receive()
select { case v = <- ch => ... default => ... }
lock(mutex) { ... }
Compiles to pthreads in the C backend. spawn auto-captures referenced variables into a context struct.
Generators
func range(start: i32, end: i32) -> gen i32 {
let mut i = start
while i < end {
yield i
i = i + 1
}
}
for val in range(0, 10) {
println(val)
}
C backend: Duff's device state machine with _init/_next/_free. The stdlib provides range(start, end) -> gen i32 for common iteration.
Memory Model
- Primitives — registers/stack, copied by value.
- Structs/Tuples — stack, copied on assign.
mutparams pass by reference (scoped, cannot escape). - Classes — slab-allocated. Two modes: AoS (default, pointer-based) and SoA (parallel arrays, u32 handles, enabled via
--soaflag). Owned classes destroyed by owner; non-owned classes ref-counted. - Slices
[T]— fat pointer (data + len + cap). Assignment copies backing data. Parameter passing shares backing data (no copy).let refcreates a zero-copy view. - Relations — ownership graph.
owns= cascade destroy,refs= auto-unlink on destroy. No dangling references. - Scope-exit freeing — escape analysis injects
StSliceFree/StSlabFreefor locally-allocated slices and classes that don't escape their scope. final— optional pre-destroy hook on classes, called before auto-generated destructor.trusted— blocks/functions where manualref(x)/unref(x)is allowed and auto ref-counting is disabled. For stdlib containers.destroys— compiler-inferred annotation on functions that may destroy class instances. References become dead after such calls.mut resize— annotation on parameters that may grow/shrink a slice. Prevents use while element references exist.- No GC — deterministic destruction via ownership + ref counting.
Design Annotations
class Foo {
why: "Explanation of design intent"
}
doc "Section" {
"Documentation content"
}
invariant: "claim" verified_at: "date"
source: ["file.go"]
These are for human/AI understanding, not runtime behavior.
Note: Annotation keywords (source, why, doc, fake, field, lock, implements) are contextual — they lex as identifiers and are only interpreted as keywords in annotation contexts. They CAN be used as variable/field names. The parser uses targeted lookahead to disambiguate (e.g., lock is only a keyword when followed by ().
Known Gotchas
- Annotation keywords are contextual —
source,why,doc,fake,field,lock,implementscan be used as variable/field names (they lex as identifiers). fnvsfunc— both work for declarations;fnalso works in type syntax (fn(i32) -> bool).- Structs are value types — must read→modify→write-back for nested fields. Recurring source of bugs.
stringis NOTHashable— usesym("key")or backtick`key`for hash table keys.- Enum variant construction — must use positional args:
Variant(a, b, c). Named args likeVariant(x: a, y: b)are not supported in call-syntax construction (use struct literal syntaxStruct { x: a }for structs only). append(slice, item)builtin — exists for plain slices; for relation-owned lists usearray_append<P,C>(parent, child).muton class params — don't do it. Classes are already pointers;mutcreates double-pointer segfaults.- Tuple access — use
._0,._1(underscore-prefixed indices), not.0,.1. nullandnull— both accepted by the lexer (mapped to same token). Book convention usesnull.
Self-Hosting Status
Lyric is self-hosting as of June 2026. The bootstrap compiler (lyric.c, ~90K lines of C) is compiled from ~30K lines of Lyric source across 12 packages (src/ast/, src/lir/, src/parser/, etc.) plus stdlib. The compiler achieves a fixed point: bootstrap2.c == bootstrap3.c. 78 tests pass. The legacy Go compiler has been moved to legacy/go-compiler/.
Compiler architecture notes (learned during bootstrap):
CheckFileuses three-phase processing: Phase 0 (preregister type names), Phase 1 (register signatures), Phase 2 (check bodies) across ALL files before proceeding.DesugarDestructorswraps each relation's destructor body in aStmtBlockto avoid variable name collisions.MergeStdlibmerges stdlib into the first block only (not every block) to avoid duplicate type definitions. Must merge ALL files BEFORE desugar/check.- Monomorphization is iterative — Phase 2 re-collects from specialized bodies.
- AST
Expris a VALUE TYPE — any copy loses checker annotations; always use&slice[i].
Key design rules for bootstrap code:
- Classes for most things, structs only for simple value types (Pos, Span, LexerState)
- ArrayList relations for parent→child ownership
- Dict<K,V> for hash tables, Sym for identifiers (hash once)
- All parameters must be named:
func foo(x: i32)notfunc foo(i32)