Lyric
A statically typed application language with a bytecode VM — standalone, and embeddable in a host with a capability-based sandbox.
What changed in v1.8.0 — 2026-08-18
The editors catch up with the compiler. No language change and no format change: the language
server compiles the PROJECT instead of the open buffer, answers everything an editor asks, and
two installable clients ship beside the toolchain — the VS Code extension as a .vsix, and a
new JetBrains plugin.
Changed
The language server compiles the project, not the buffer. Under a
lyric.json, every.lyrfile beneath the source root is one compilation: find-references works in BOTH directions (standing on a declaration finds the uses in files that import it), files nobody has open get their errors into the Problems panel, a deleted file has its squiggles withdrawn, and a change behind the editor — a branch switch, another tool — is picked up through file watches. A file outside any project is compiled from itself, as before. Measured: a 14-file project checks in the same time as a single file — the standard library dominates either way.Find references and semantic highlighting underline the name, not the expression —
xinstead ofp.x,Pointinstead ofPoint { x = 1 }.
Added
- Rename (
F2/Shift+F6), project-wide: the declaration, every use, and theimportclauses that carry the name. What cannot be renamed says why — the standard library, a module, a built-in. Whether the NEW name collides is left to the compile that follows immediately; its diagnostics are the conflict analysis. Applied edits recompile clean, pinned by test. - Workspace symbols: every declaration of the project, searched by name.
- Semantic highlighting: every name colored by what the compiler resolved it to — a type in
an annotation, an initializer and an attribute alike;
letbindings as readonly. An unresolved name stays uncolored, which is the honest signal. - Signature help while typing a call — the declaration as written, the active parameter following the commas. Folding with the closing line kept visible. Inlay hints for the inferred type of unannotated bindings and loop variables.
- The VS Code extension grew up: a restart command, a status item that shows the server
state and version, a
lyric: buildtask wired to the Problems panel, snippets — and the extension ships asvscode-lyric-<version>.vsixon every release. - A JetBrains plugin (
jetbrains-lyric-<version>.zip): the same server in CLion, IntelliJ IDEA, Rider and the other commercial IDEs, 2026.1 or newer — diagnostics, completion, navigation, rename, semantic highlighting, signature help, folding and inlay hints through the platform's own LSP integration. Install from disk; neither client is on a marketplace.
The interpreter stops allocating — and so does the native boundary. No language change and no
format change: the same programs compile to faster, smaller modules, run with far fewer heap
allocations, and a host SDK can now put Vec2 in a native signature. The numbers below come
from tools/Bench (new in this release), Release, per operation, against v1.6.0.
Changed
A function call no longer allocates. Frames are pooled per function; a call went from 176 B to 0 B and from ~50 ns to ~8 ns. Deep recursion still works, exceptions and panics unwind exactly as before.
Small functions are inlined. A direct call to a function of roughly a dozen instructions — a
Vec2.add, an iterator'snext, a getter — is replaced by its body. Callers and callees withtry/deferare left alone, recursion stays a call, and a function that always throws keeps its frame.Objects that never leave their function are dissolved into locals. A
Vec2built, read and assigned inside a loop costs 0 bytes: construction plus method call went from 352 B / 271 ns to 0 B / 8 ns, the operator form (a + b) from 352 B / 252 ns to 0 B / 6 ns. This is what makes vector arithmetic through the v1.5.0 operators usable in a per-frame game loop.for-inno longer allocates its iterator. A range or array loop runs at 0 B per element (208 B before); a range loop is ~3× faster than in v1.6.0. TheIterableroute through an interface is devirtualized where the concrete iterator is provable — aSet/Maploop now calls itsnextdirectly instead of dispatching per element.Modules got smaller. A function whose every call was inlined is removed; the six-function
examples/arith.lyrcompiles to two. An attributed function always survives — the row is a promise to the host.A panic in an inlined function names the caller's frame with the callee's line. The line is right, the frame above it is gone — the trade every optimizing compiler makes. A deliberate
panic(...)keeps its full backtrace, because a function that never returns is not inlined.
Added
The native boundary takes and returns value structs, without allocating. A native signature may use a
structdeclared in the same native module (scalar and string fields only). The declaration stays fully typed on the script side; on the wire it is flattened:module engine.geo; pub struct Vec2 { x: float, y: float } pub fn setPosition(entity: int, at: Vec2); pub fn positionOf(entity: int): Vec2;A struct parameter crosses as its fields — the host registers the delegate it would have written for scalars. A struct return comes back through a buffer the runtime owns (
NativeRegistry.RegisterStructReturning): the implementation fills one value per field, the script sees an ordinary value, and value semantics keeps the shared buffer invisible. Layout disagreements between host and SDK are load errors with the import's name in them.Measured: a
Vec2built fresh and passed in, or received back, costs 0 B per call — the answer to the embedding question that produced this milestone (Erato'spositionOf).A native call no longer allocates its argument array. The
LyrValue[]handed to an implementation is pooled and reused; a one-argument crossing went from 40 B to 0 B, a four-argument one from 88 B to 0 B. The array is therefore a LOAN: read it during the call, copy values out, never store it — documented onNativeRegistry, and every implementation in the standard registry already complied.tools/Bench— the in-process measurement harness behind all numbers above:dotnet run -c Release --project tools/Bench. Allocated bytes and nanoseconds per operation, scalar-loop baseline subtracted, round-robin against JIT tiering, raw-registry boundary probes.
Not in this release
- Struct returns through the embedding layer's delegates.
RegisterStructReturningis aNativeRegistrysurface; aLangVm.RegisterNativeoverload that marshals a C# struct is sugar for a later release — the raw form is the one a game host uses anyway. - Structs from other modules in native signatures — the struct must live in the module that declares the native, which is where an SDK's value types belong.
- Escape analysis across surviving calls — a struct passed to or returned from a Lyric function that stays a function still allocates.
- The remaining optional ops in
for-in: a range loop still runs ~1.9× a hand-writtenwhile; the gap isoptsome/optissome/optgetand block hops, peephole material. - Value structs as a language feature. Deliberately: a
structalready HAS value semantics; this release makes the representation keep that promise everywhere it matters, with no new mechanism.