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.

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 layout 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: the 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 section 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.

Rules instead of states: the Lexer

Most bundles want the same half-dozen token shapes, and lib/x/reader/lexer.x builds the base from them. Each rule is data — a run of characters, a run to drop, a table of literals matched longest-first, a quoted literal with an escape byte, a span from one literal to another, a number with its label, a pattern of steps each a class and how many bytes of it (a printf directive, a numeric escape) — and (Lexer make rules) registers one type a rule on a (Base make-tok) child, in list order, so the first rule wins an equal-length tie:

(import x/reader/lexer)
(def c-lexer
  (Lexer make (list
    (Lexer skip " \t\n")
    (Lexer until () "/*" "*/")
    (Lexer table 'kw (list "int" "return" "if"))
    (Lexer run 'id (list (pair 97 122) (pair 65 90) 95) (list (pair 97 122) (pair 65 90) (pair 48 57) 95))
    (Lexer number 'num "uUlL")
    (Lexer quoted 'str 34 34 92)
    (Lexer table 'op (list "(" ")" "{" "}" ";" "=" "==" "+" "+=")))))
(c-lexer read-str "int x = 0x10;")
; => ((kw "int") (id "x") (op "=") (num "0x10" 3) (op ";"))

Every state is one (fn (me buffer score chr) …) form in the assembler lane’s dialect: it is compiled through compile-asm when the lane is open, and the same form is evaluated as the interpreted twin when it is not, so the token stream is the contract and the realization is reported ((l compiled)). The base and its states are process state — they are dropped before a state image is written and made again after a load — so a bundle holds the Lexer and nothing else. (l end "\n") sets the text appended before every read, which is how the last token meets a delimiter; a space unless set. Binary input has a rule of its own: (Lexer record tag shapes) reads a record whose first byte picks a shape and whose length the shape’s steps read from its own bytes – a machine instruction, a tagged field – and (l read-span s start len) reads a span of bytes, NULs included, with nothing appended, since a record ends on its own last byte. tests/x/specs/lib/lexer.spec.md is the executable reference, and a type the rules cannot express – typed instances, a read that recurses, a live stream – is still written by hand on the protocol above, each of its analyser states realized from the same kind of form through (Lexer state form fvars), so it compiles all the same.

Nesting is free: regions 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 became a name, four closing-bracket scanners were deleted rather than moved, and comma-splitting needed no depth count — an inner region 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 (catalogue type/make), which registers on the base it is called in — the running base, where the numeric tower already lives. This is about the lang’s values. The bundle’s own tables are classes, and its files are modules (lang-contract.md, “Modules, names and classes”).

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 had most of its specs green 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 catalogue namespace. Before concluding the engine cannot do something, grep all of src/x-prim/*.c — the namespace/method 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 ops, through push-op on the wrapped type (((Type wrap t) push-op (lit +) f))

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 binds in the current environment. A call makes a child environment, so a def in a function body, or in a guard handler’s frame, is private to that activation and goes with it; (eval (list (lit def) name value) e) binds in e, and eval! evaluates in the root (engine law 10). Use let for function locals. When something evaluated at depth (a REPL loop, a guard handler) must define for the session, evaluate the def with eval!, the value under lit so def does not evaluate it a second time.

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 most 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: most of x-python’s boot turned out to be the xenon tower amalgam, which every tower dialect pays, plus the bundle loading under xenon’s heavier reader; the bundle’s own code was a small share 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 pattern: 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.