← All docs

How Irij Works

Under the hood of a token-efficient, effect-oriented language on the JVM — from source text to running bytecode. Reflects the codebase as of v0.8.x (post the 2026-07 refactor). Deep dives live in docs/internals/.

1The pipeline at a glance

Irij is bytecode-only: there is no interpreter (the tree-walker was removed in v0.6.20). Every run — a file, a REPL line, an nREPL eval, a packaged app — goes through the same four stages and executes as JVM classfiles.

source.irj UTF-8 text tokens ANTLR4 + indent AST sealed records classfiles ASM, V21 JVM defineClass
One path for everything: irij run, REPL, nREPL sessions, and irij build JARs.

Before emission, two whole-program passes run over the AST: ModuleInliner splices used modules into one declaration list, and EffectRowChecker verifies declared effect rows (::: Console Db) at compile time. Emission itself is multi-pass (signatures → methods → dispatchers → main<clinit>), so forward references just work.

2Lexing: indentation & dense ASCII

Irij's surface syntax is built around two ideas: blocks by indentation (strictly 2 spaces per level, hard error otherwise) and 1–2 character operators chosen to be single BPE tokens for LLMs (:= |> @ /? /+ ~> …).

ANTLR can't produce Python-style INDENT/DEDENT tokens natively, so IrijLexerBase measures leading spaces at each newline and synthesizes them, maintaining an indent stack. Blank lines and comments are consumed without emitting tokens (and, since a v0.8 fix, without desyncing line numbers). Maximal-munch ordering matters: ::: (effect row) must lex before :: (spec annotation), ..< before ...

Identifiers are kebab-case with optional ?/! suffix (empty?, validate!); _ works as a separator too (pw_hash), while bare _ stays the wildcard pattern. Strings interpolate with ${expr}.

3Parsing & the AST

The ANTLR4 grammar (IrijParser.g4) feeds AstBuilder, which produces a small, regular AST of Java sealed interfaces + records:

Family Variants (abridged)
Decl FnDecl, SpecDecl, EffectDecl, HandlerDecl, CapDecl, ImplDecl, BindingDecl, PubDecl…
Expr literals, Var, App, Lambda, IfExpr, MatchExpr, Block, Pipe, SeqOp, MapLit, StringInterp, Range, DotAccess…
Stmt Bind, MutBind, Assign, ExprStmt, IfStmt, MatchStmt, With, Scope
Pattern literal, Var, Wildcard, ConstructorPat, VectorPat (with spread), TuplePat, DestructurePat
SpecExpr Name, App, ArrowSpec, RecordSpec, enum/refinement forms

Sealed hierarchies mean every consumer is an exhaustive switch over records — the compiler enforces coverage when a new node appears. Function bodies come in three forms, disambiguated by the first token after the indent: ( → lambda body, pattern => → match arms, bare => → imperative block. Lambda chains curry: (a -> b -> body)(a -> (b -> body)).

4Bytecode emission

The emitter lives in dev.irij.compiler as an orchestrator plus focused modules (one concern per class since the 2026-07 split):

Class Job
ClassEmitter passes, per-source-file class management, <clinit>, shared state (arities, effect rows, spec/proto/handler registries)
FnEmitter named fns, tail returns, spec checks, pre/post contracts, fn-as-value wrappers
ExprEmitter expressions & statements: literals, binds, calls, pipes, collections
PatternEmitter match lowering: test-and-bind chains per pattern kind
LambdaEmitter lambda bodies as synthetic statics + free-variable capture analysis
IntrinsicsEmitter builtin fast paths — direct INVOKESTATIC into the runtime
EffectEmitter / SmClassifier / SmEmitter the effect system (§7)
ProtoEmitter protocol dispatchers: type-tag switch → impl$method$Type statics

Each program becomes one root class (irij.Program) holding main, statics for top-level binds, and all synthetic methods. Functions inlined from other source files move to sibling classes (irij.Program$vrata_html) whose JVM SourceFile attribute names the real file — so stack traces read at irij.Program$vrata_html.el(vrata/html.irj:40) instead of blaming the root program. Per-expression LineNumber entries make those traces line-accurate.

The runtime the emitted code calls into is split by domain: RuntimeSupport (callable dispatch, nREPL namespace, hot-redef bootstrap) plus RtOps, RtMath, RtStrings, RtCollections, RtEffects, RtConcurrency, RtInterop, RtIo. Emit sites don't hard-code an owner: RtOwners.of("conj") resolves the declaring class reflectively at compile time and fails fast if a name is missing or ambiguous — the runtime classes are the single source of truth.

5The value model

Irij JVM representation
Int / Float / Bool / Str boxed Long / Double / Boolean / String
() Values.UNIT singleton
:keyword, 3/4 Values.Keyword, Values.Rational records
#[…] #(…) #{…} {k= v} IrijVector / IrijTuple / IrijSet / IrijMap — immutable copies (List.copyOf et al.)
ADT / record values Values.Tagged(tag, fields, namedFields, specName)specName is the certification stamp
functions RuntimeSupport.IrijFn SAM (Object[] → Object), built by LambdaMetafactory at call sites

Everything is Object in method signatures; HotSpot's boxing elimination and inlining recover most of the cost at hot sites. Maps are string-keyed (JSON-shaped); a dynamic key {(expr)= v} must evaluate to Str.

6Tail-call optimization

Self-recursive calls in tail position compile to GOTO back to the function entry with parameter slots rebound in place — O(1) stack, no trampoline. emitTailExpr walks tail positions (if-branches, last block expression, match arm bodies, the last expression of a do); everything else falls through to emitExpr + ARETURN. New argument values are all evaluated on the JVM stack before any slot is overwritten, so sum-to (acc + n) (n - 1) can't read a half-updated frame. Mutual recursion is deliberately deferred (no JVM-level general TCO).

7Effects: state-machine lowering

Algebraic effects are Irij's universal joint — IO, state, concurrency and mocking all flow through effect declarations, perform call sites, and with handler blocks. The lowering (“14c.3”) turns each with body into a resumable state machine; there are no threads or queues in the dispatch path.

Classification

SmClassifier A-normalizes the body — every op call, including one embedded inside a pipe, map literal, string interpolation, range, record update, or do, is lifted in source order to x := op args or a bare op statement — then classifies it into a WithBodyShape:

Shape Meaning
Pure no op calls — body runs directly
SingleOp exactly one op call at top level
Sequence a straight line of segments split at op calls; locals crossing a split are lifted into continuation fields
EffIR branching control flow with ops — lowered to basic blocks with Terms (Return / Jump / Branch / Perform)

Runtime protocol

smstep$N(k) switch (k.state) — runs a segment throw PerformSignal stackless; carries op + continuation dispatch loop catch → find handler clause clause runs; resume v throw TailResume(v, target) loop re-enters step with k.state = next
A perform = one stackless throw; a resume = one more. The loop iterates — no stack growth per op.

The continuation object (IrijContinuation) holds state (which segment runs next) and fields (lifted locals). Handler mutable state (state :! 0, <-) is a named global cell: one static field per handler declaration, initialized once at the declaration and never reset by with — successive or cross-thread with h blocks share it, and h.state (a plain field read) stays valid after the with returns, which is how test handlers hand results back. Mutating one handler's state from parallel fibers is a knowing data race. Handlers compose with >> (CompiledComposedHandler); nested with persists the inner continuation in the outer's fields. A clause body that performs its own foreign effect compiles as its own SM continuation (“tier-c”); ops the native machinery can't route fall back to SM_STACK — a thread-local stack of handler frames — so semantics never depend on which path was taken. PerformSignal/TailResume are created fresh per throw (stackless construction; pooling was benchmarked at ~zero and removed).

8Hot redefinition

Dev builds compile each user-fn call site as invokedynamic; the bootstrap (RuntimeSupport.redefBootstrap) links a MutableCallSite and registers it in a global map keyed by owner.method:descriptor. Redefining a fn (REPL, nREPL) swaps the call site's target method handle — every previously-JITted caller re-links to the new code. irij build --direct-linking emits plain invokestatic instead: no indirection, maximal JIT inlining, Clojure-style deploy semantics.

9Specs & contracts

Irij is spec-oriented rather than statically typed: annotations are runtime-validated shapes on function boundaries, with a gradual spectrum (dynamic → schemas → contracts).

pub fn greet :: Str Int Str ::: Console
  pre  (n -> n >= 0)
  post (r -> length r > 0)
  (name n -> ...)

10Modules, seeds & versioning

use vrata.html is resolved by ModuleInliner at compile time: the module's source is parsed, its pub decls are unwrapped, short-name aliases are rewritten at call and dot-access positions, and everything lands in one decl list (per-file classes keep stack traces honest, §4). Lookup order: stdlib classpath (std/*.irj) → project source roots → installed seeds.

Seeds are Irij's packages: irij.toml declares metadata + deps (registry / git); irij install resolves them, irij publish tars sources and uploads to the registry (irij.online) with Bearer auth. Versions are commit-count basedMAJOR.MINOR.<git rev-list --count HEAD>; publish runs only from a clean main so patch numbers are monotone and nobody hand-picks them. The engine itself versions the same way.

11Concurrency

Everything is virtual threads. spawn starts a fire-and-forget vthread; scope/fork/await give structured concurrency (CompiledScopeHandle), with par, race, timeout on top. The built-in HTTP server (IrijHttpServer) is vthread-per-connection with blocking IO — a dead SSE peer kills only its own thread (the com.sun.net.httpserver selector-wedge bug that motivated it is gone).

Effect context crosses threads by snapshot: at fork time the parent's handler stacks (SM_STACK, threaded EffectSystem.STACK, effect row) are copied into a ParentSnapshot and installed in the child before its body runs; HTTP request handlers replay a snapshot taken at serve time. The session-scoped values — nREPL namespace NS and stdout capture SESSION_OUT — are JDK 25 ScopedValues: bound lexically around each eval/fiber/request, structurally unable to leak. The mutable handler stacks stay ThreadLocal on purpose: emitted code pushes/pops them in generated-code scope, and children need copies, not shared references.

12Capabilities & Java interop

Effects abstract what; capabilities bind how. Stdlib effect modules (std.db, std.http, std.serve, std.fs, std.session, std.log…) pair a pub effect with a default handler whose clauses call raw-* builtins backed by Java capability classes (JdbcCapability, ServeCapability, …). Tests swap the handler; production code never changes.

Java interop is reflective and unified: Class/member resolves statics, obj.method args falls through dot-access to instance reflection, both via JavaInterop's coercion/overload resolution. Calling arbitrary Java is impure by definition, so it demands the ::: JVM row — purity stays honest at the boundary.

13The stdlib boundary

The standard library is real Irij (src/main/resources/std/*.irj, 18 modules) except where Java is irreducible: raw access to internal collection types, JDBC/HTTP/crypto, and hot arithmetic. Since the 2026-07 dedup there is one implementation per builtin: the Rt* statics serve call-position intrinsics directly, and value-position BuiltinFn registry entries delegate to the same statics. Higher-order builtins are effect-transparent — a callback runs in the caller's effect row.

14Tooling: REPL to LSP

Surface How it works
irij / irij repl JLine3 REPL; each form compiles as a namespace-mode class and evals in a persistent BytecodeSession
irij --nrepl-server bencode nREPL; cross-eval state via RT.nsGet/nsPut against the session's ScopedValue-bound namespace; powers the Emacs client and the irij.online Playground
irij lsp LSP4J over stdio: diagnostics from the real parser + effect-row checker, symbols from the AST index
irij --mcp-server MCP over stdio so AI agents can eval/run/test a project directly
irij build self-contained runnable JAR: emitted classes + dev/irij/** runtime + stdlib + resources/; --direct-linking for deploy
irij test discovers test-*.irj, counts std.test's [ok]/[FAIL] lines, per-file + grand totals

15Source map

Area Where
Grammar src/main/antlr/IrijLexer.g4, IrijParser.g4, parser/IrijLexerBase.java
AST ast/Expr, Stmt, Decl, Pattern, SpecExpr, AstBuilder
Compiler compiler/IrijCompiler, ClassEmitter + emitter modules, SpecValidator, EffectRowChecker, ModuleInliner
Runtime compiler/RuntimeSupport + Rt*, runtime types (IrijContinuation, PerformSignal…), runtime/Values, capabilities
Tooling cli/, repl/, nrepl/, lsp/, mcp/, module/ (seeds, registry, versioning)
Deep dives docs/internals/ — 18 topic pages, kept in lockstep with the code

Generated for Irij v0.8.x · 2026-07 · This page is a curated overview; the internals pages are the source of truth for details.