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.
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:
x.sh arms the bundle’s import
root and answers --share-dir; a bundle that reaches for
../../../lib works in one checkout and dangles everywhere else.tests/spec-runner.sh is a dozen lines of policy (which files, which
knobs) that sources the shared one.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:
.spec.md case with prose saying why the behavior is
what it is. x-python’s suite grew from 0 to 426 cases this way, and the
spec prose is now the best documentation the bundle has.type appears in 28 of 112 files” chooses better
than “the class group is at 0%”.The five-module split is not aesthetic; each boundary is a fact about the system:
%py-add, not +), and every one
is a place where a lang rule can be stated. A parser that emits the host’s
operators is writing a different language wearing the same clothes.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.
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).
(%score-set score 1 buffer) accepts including the current character.(%buffer-unread buffer) first accepts excluding it — how a token that
ends at a delimiter gives the delimiter back.(%score-variant! score K) declares which variant the accepting state saw —
an integer the type’s reader recovers with (%read-variant args) (its second
argument carries it as a raw cell; nil when no state declared one). The
analyser already knows whether a literal ran through the fraction or the
exponent state; this is how it says so, instead of the reader rescanning
the text.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.
(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:
tok read until it sees its
closer token, and the bracketed run becomes ONE token with its contents
nested inside. Strings are consumed by the string types before the group
handler ever asks, so ["]"] needs no quote tracking — the problem is not
solved, it never exists. Implicit line joining falls out: a newline inside
a group never reaches the line-structure machinery.x/reader/indent stack, so
tab policy has one answer across langs), and on an open recurses to
collect a (tok-block …). One read returns one token and a single dedent
can close several blocks, so the surplus lives in an “owed” counter that
each enclosing block loop collects.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.
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.
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.
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:
(fn (_ self) …) — first parameter is the TYPE.
Getting it wrong reads a non-pair and segfaults rather than raising.append mutates the cell and every reference
sees it. An immutable type (tuples) skips the cell — the instance IS the
value.(value . next-state) and only a nil pair ends the walk, so a nil
value (Python’s None) is an ordinary element. x-python registered
value-terminated steppers, nothing consumed the slot, and the bug sat
invisible until specs exercised it: a registered handler nobody steps is
untested code that looks done.* onto the host’s string
type changes what * means for every string in the process. The lang’s
rules for host types stay in runtime functions behind a predicate.write-to-str closes the str/repr gap. (prim-ref 'io 'write-to-str)
runs the writer with its sink redirected into a string, so the same type
handlers that print also render — nested containers come out right with no
second rendering path.Foo() needs no parser special case, just a
call handler. A constructor entry keyed by a name no lang identifier
can spell ("%ctor") lets builtin type objects convert instead of
allocate. A qualname slot travels with the class because the display
prefix is a fact the constructor’s caller knows.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.
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:
(Sys dup2 3 0) — or you will read the
exhausted boot pipe and every line arrives as EOF.let) can serve both, distinguished by
what it binds.repl inherits the
editor; the buffer, the cursor, the history and the raw-mode bracketing
carry no grammar. Three things do: set %repl-eval-line to a function
from the finished line’s text that parses your syntax, evaluates and
prints — asking for more lines itself with (Line read %repl-prompt-more)
when the entry is not complete — %repl-paint to a function from the
line’s text to the text to display for it, and %repl-complete to a
function from the Edit buffer to (typed . names). The last two take
() for none — but note that a nil %repl-paint means no painter
installed rather than no colour, which --no-color, NO_COLOR and
TERM=dumb already answer. Tab’s default prefix-searches the doc
registry, so a lang that parses its own syntax completes x-lang’s names
until it installs its own. Replacing repl instead gives all of this up.(Lang register! "NAME" alist) with
the seams above, then (Lang use! "NAME") if this lang owns the session.
(lang NAME) at x-lang’s prompt switches to it, and your own spelling of
the switch is a call to Lang use!; a seam the bundle does not name takes
x-lang’s value, so say () for no painter rather than leaving it out.-l is repeatable, and a bundle
named by a second or later -l is loaded beside the first lang. Its
entry runs either way; %lang-lead is the first name, so compare it with
your own and register without use! (and without replacing repl or
%banner) when it is not yours. Guard the read: a wrapper older than the
seam binds nothing, and the answer then is that you lead.%param-release (engine)
and %platform-release (x-lang) arrive as boot data; printing them plus
the resolved root makes every which-install-am-I-running mystery
self-answering. A -dirty in the banner is a feature.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:
compile-asm (the assembler lane) emits
machine code directly and needs no toolchain; compile (the cc lane)
shells out to a PATH cc at runtime. Analysers use the assembler lane.compile-asm’s third
argument says which — #t for an analyser, #f for an integer function —
and a compile that carries fvars refuses without it: an fvar is a handoff
target in one world and a named callee in the other, so both are
(fn (self a b c) …) over one vocabulary with nothing in the expression to
separate them. A compile with no fvars and no third argument is an integer
function. An analyser is for the tokenizer, not for you:
direct-calling it is outside the contract.=, not, and and or stay allowed on an object
param — testing a pointer for equality or for truth is meaningful, and
(= buffer ()) asks a real question. See
tests/x/specs/ext/jit-fvar-mode.spec.md.(%call HEAD arg ...) calls a prim the code computes — a callback
parameter, a pointer out of a dispatch table. Both build the same
argument list the self-call builds, and both check at run time that the
head really is a callable prim rather than branching into its first word.
Up to four arguments, and the callee gets every parameter it declares
(passing fewer segfaults). A head the emitter cannot resolve still
refuses at generation: silently compiling one as a self-call is what
once produced an infinite recursion and a crash far from the cause.%score-set’s sign folds (- 0 1) and raises loudly on other
non-literals; any other non-trivial constant belongs in an fvar.
%score-variant!’s variant is a literal integer the same way.failed and carries on pure-x. Compiling
costs seconds once; never per-call, and never unconditionally at load..spec.md
becomes a regression test the moment it teaches you something. Prototype
risky mechanisms (a new reader trick, a compiled callback) on a scratch
base in a throwaway spec before touching working code.LANG_LIB, X_BIN, policy knobs). Invoking a core directly
produces failures that misattribute themselves — both halves of a
two-session debugging saga were exactly this mistake, and the resulting
guards now fail loudly. Corollaries: after editing anything that is
amalgamated (tool/compile.x), make boot, or your edit silently does not
load; suites resolve modules against the root the harness baked in, so
point X= at the tree you mean.PARALLEL over many
tower-booting files, or run two heavy suites concurrently: the per-process
guards cannot bound total memory.SPEC_SEAM_COLLECT=0,
the accumulate-then-exit regime x-ash ran in until x-engine-c v0.2.7) a
spec job accumulates a whole file’s garbage, so the alloc-limit! guard is
bounding a sum. The platform default is 300M
objects, ~14 GB, calibrated for a dev box: on a 16 GB CI runner a process
approaching it exhausts the machine before the guard trips, and the job
dies with spec-gate: killed by SIGTERM and no output at all to say why.
Lower it until the guard fires first — a failed spec is legible, a killed
job is not. Measure on the small machine; a workstation that passes proves
nothing about the runner, and “it is green here” is how this gets shipped
twice.return inside try skipping finally) stays visible and
testable; a comment rots. And when a case’s example graduates — the
feature it relied on being absent gets built — keep the old expectation as
a new case asserting the new answer: the change of answer IS the feature.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.