x-lang

Crafting a lang

docs/lang-contract.md says what a lang bundle is — the files, the declarations, what the platform ships and what a bundle must never vendor. This document says how you actually build one, distilled from building x-python: a Python 3 surface taken from a stub that answered #<python: not implemented> to everything, to a language with containers, classes, exceptions, comprehensions, slicing, and a working REPL — 426 specs green — in the space of a few days.

Almost every rule below was learned by getting it wrong first. Where that happened, the mistake is stated with its mechanism, because the mistakes are the transferable part: the next lang author will be tempted by the same wrong turns, and a rule without its failure mode reads as style advice and gets ignored.

1. The shape of a bundle

A lang is a bundle: lang.xon declares it, run.x is the entry, the implementation lives in one directory named for the lang, and tests/spec-runner.sh sources the platform’s runner and vendors nothing.

lang.xon                (lang "python") (dialect xe) (requires-release "…") (entry "run.x")
run.x                   imports the implementation, wires the REPL
python/tokens.x         the reader: token types on an isolated base
python/indent.x         (historical) line structure — now a name, see §4
python/types.x          the lang's values, as x types
python/runtime.x        what the lang's operators and rules MEAN
python/parse.x          grammar → emitted x forms
tests/spec-runner.sh    sources the platform runner; sets policy knobs
tests/specs/*.spec.md   the hand-written suite — the real construction record

Two rules from the contract bear repeating because they bite in practice:

2. Build against a scoreboard, construct against specs

Before x-python had a tokenizer it had a conformance suite: 657 upstream MicroPython test programs, each pinned by commit and sha256, each with its expected output taken from a real CPython run on the same machine. A stub that answers everything the same way scores 0 — and 0 against a suite that runs is worth more than green against six hand-picked cases.

But learn what that scoreboard is for. Each conformance case compares a whole program’s whole stdout, so one missing feature zeroes a sixty-line program: a dict group scoring 0/19 says nothing about dicts when every case also needs str(), while, += and er.args. Whole-program comparison forbids partial credit by design. So:

3. Where the seams go

The five-module split is not aesthetic; each boundary is a fact about the system:

4. The reader is the engine’s loop — use it

The single largest structural lesson. x-python first hand-wrote a recursive-descent pass over a flat token list, in interpreted x, scanning for closing brackets at 26 call sites and carrying a bracket-depth counter through its line-structure pass. None of the other bundles has such a pass, and the reason is that the engine already provides the loop.

The analyse protocol

A token type registers analyse and (optionally) read handlers on an isolated base — (Base make-tok) — via (Base make-type …). The analyse handler is called once per character and returns one of three things: another state function (keep consuming), a score (accept), or nil (reject).

Idioms that matter: the accept is the return value of %score-set — wrap it in a sequencing form that returns nil and you have written a reject. And state builders that close over the current character must copy it ((+ chr 0)), never capture the callback’s own binding.

Nesting is free: groups and blocks

(prim-ref 'tok 'read) reads the next expression from the same buffer, and a read handler may call it. So a delimited region collects its own contents by recursing through the engine’s reader:

After this conversion, x-python’s line-structure pass went from 134 lines to a name, four closing-bracket scanners were deleted rather than moved, and comma-splitting needed no depth count — an inner group is a single token.

What the reader cannot do

Precedence. Scoring answers one question — where does this token end — which is longest-match on boundaries. A bracket or an indented run fits because it is self-delimiting. Precedence is a ranking between tokens already read, and there is no place in analyse/read to say “I built this wrong, re-parent it”: analyse returns a state, a score, or nil; read returns a value. Nesting is not ranking. x-sweet’s curly reader confirms this by refusing — it folds {a + b + c} only when every operator is identical and otherwise hands $nfx$ to the program. Keep operator precedence in a recursive-descent ladder over the (now nested) token stream, as x-ash does.

Read handlers and errors

A read handler cannot raise. The C reader loop is driving, and an error unwinding out of a handler through it takes the interpreter down rather than reaching any guard. Worse, a value caught across that boundary arrives as nil — the payload does not survive the trip. So a reader-detected error (x-python’s IndentationError) is carried out as data: a flag the tokenize entry point re-raises once reading is over and x is driving again.

5. Values go on the type system, not the class system

x’s class system is for types written in x, resolved when a file loads. A lang’s values are built at run time and follow the lang’s own rules — one level lower is the level that fits. The door is the two-argument make-type (catalog type/make), which registers on the base it is called in — the running base, where the numeric tower already lives.

Two prims, and the difference decided a failed design:

prim registers onto use for
base-make-type the base you name token types on the isolated reader base
make-type the base it is called in the lang’s VALUE types

x-python first tried a child base per lang (Base make + base-make-type + Base eval) and reached 190/232 specs before discovering that a child base has no numeric tower — float and bigint are library types registered on whichever base loaded them. The wrong conclusion (“this design cannot work”) was written into a doc and survived a day; the capability it asked for already existed under a name in another catalog namespace. Before concluding the engine cannot do something, grep all of src/x-prim/*.c — the namespace/member pair is often not what the C name suggests — and look for a library type that already does the thing (x/num/rational.x and x/type/vector.x were working models of everything x-python needed).

What the handlers buy, mapped from Python:

lang feature type slot
repr / str write / display, calling back into the lang’s repr per element
len(x) length
x[i], and calling a class Foo() call
iteration iter
a + b on your types %type-push-op

Details with teeth:

6. The rules that bite

Every one of these cost a debugging cycle, most of them with the symptom far from the cause.

The %-globals share one flat namespace across the bundle. x-python collided four times (%py-len, %py-elems, %py-names-of, %py-block-of), and one collision presented as a syntax error from a runtime-only edit. Grep the bundle before defining a name. The platform’s check-percent-globals gate holds per-file budgets, shrink-only — when it rejects your new globals, the intended fix is fewer names, not a bigger budget.

def decides global-vs-local by save-stack depth. In a called function body, under TCO, a def can bind globally — clobbering a module name on every call — or locally inside a guard handler’s frame — vanishing with it. Use let for function locals (binds in-frame unconditionally). Use the base/def-global door when something evaluated at depth (a REPL loop, a guard handler) must define for the session.

A raise skips your restore. Any save/restore around a parse or eval leaks when the body raises — x-python’s lexical-class cell leaked out of a failed parse and a later super() error reported the wrong context. Per-run state gets reset at the entry point, not restored at exits.

Nothing collects unless you ask. x has no automatic collection: every sweep in the tree is a hand-placed (Heap collect) — one at the end of the boot amalgams, one at the top of each REPL turn (lib/x/repl/loop.x), and lib/x/codec/sha256.x schedules its own inside the digest loop. So the interactive session is fine and everything else accumulates until the process exits. A lang that replaces the REPL loop (§7) inherits that turn sweep as a duty: forget it and a long session, or a -f script with a loop in it, grows without bound. The platform’s own note calls the per-turn sweep “the seat is quiet” — the previous turn’s eval has finished and no reader is mid-flight, which is what makes everything unreachable there genuinely dead.

Whole-file paren balance can lie. Two miscounted closers in different functions cancel to a clean total. Check each edited definition closes at depth zero, not the file sum. And bound a text replacement by the text being replaced, never by “up to the next definition” — that once deleted 200 lines of a parser.

7. Interactive is a different loop, not a different prompt

The platform REPL’s customization surface is the prompt string and the printer. The read is the ambient sexp reader, and no banner changes what a reader is — a “Python” prompt over a sexp reader evaluates 1 + 2 as three forms across three prompts. A lang REPL replaces the loop: read a line (a block, when it opens one), parse with the lang’s parser, evaluate, echo by the lang’s rules. Wire it by set!-ing the launcher’s globals (%banner, repl) from run.x.

What the loop must know:

8. Making it fast

Boot cost decomposes before it optimizes. Measure with (quit) piped through each dialect: x-python’s 28s turned out to be ~15s of xenon tower amalgam boot (every tower dialect pays it; plain x boots in 1.3s) plus the bundle loading under xenon’s heavier reader — the bundle’s own code was 3s under a light dialect. Know whose cost you are looking at before “fixing” your share of it.

Interpreted analysers are the hot path — one call per character per contesting type — and the platform can compile them:

9. Testing is the construction method

10. The failure mode to fear is the silent wrong number

Across the whole build, the expensive bugs shared one shape: no error, plausible output, wrong value. [1] + [2] printing a pointer as an integer; 1e10 reading as 1; float('abc') answering 0.0; a float literal silently truncated to its integer prefix; a discard score arriving as +65535×len. Every one was found by a spec comparing against an external oracle (CPython, or arithmetic done by hand), and several had survived under green suites because nothing asked.

The defenses, in the order they pay: compare against an oracle, not your own expectations; refuse to trust a conversion or shortcut with input the program supplied (parse it yourself and raise the lang’s own error); and when you must diverge, make the divergence loud or make it a pending spec. A loud error is a gift; the silent wrong number is the one that ships.