Axiom 0.3.5
0.3.5 — 2026-08-28
The language server grows from four questions to twenty-two, most of
what rust-analyzer answers for Rust, syntax highlighting included. Every per-keystroke request is answered
from the raw parse tree and the document's own bytes with no macro
expansion (MAC-TOOL-3); the two that run the pipeline do so because
the pipeline's output is the answer — a code action reads the checker's
own fixes, and axiom/expandMacro expands because expanding is the
question. Every request is gated by scripts/check-lsp-selfhost.sh two
ways: a derived check that asks it the right question on a document the
driver writes itself, and a sweep that asks every advertised request the
wrong question at every kind of position. 28 passed, 0 failed; the sweep
answers every advertised provider at every kind of position.
No gate was added for this release: the thirty-seven gates that build
the compiler under test through gate_build_axc already include
check-lsp-selfhost.sh, and the sweep is a section of that gate rather
than a new one.
Added
-
The server knows what a name refers to.
textDocument/references,
textDocument/documentHighlight,textDocument/prepareRenameand
textDocument/renameare projections of one walk over the parse tree
that records every occurrence of a name with the KEY of the binding it
resolves to, following the language's own scoping: the innermost local
first, then this document's declarations (constructors included, last
wins), then "not this document's" — so the twois of two functions
are two names, and aletthat shadows a parameter is a different
binding from it.definitionandhoverask the local half FIRST,
because a local shadows a declaration of the same name: definition from
a read lands on ITS binder through a shadowinglet, and hover on a
parameter answerspw : Intfrom the signature's arrow. References and
rename reach every OTHER open document whose imports resolve to this
file, under its own uri; rename refuses (null) a spelling that is not
one identifier, a keyword, an imported or builtin name — renaming
across files the server did not open would leave a broken workspace,
and a stdlib name must never be renamed from a client — and a
collision, which for a local is found by a second walk with a probe
that asks, at every emission, whether the new spelling is bound around
the binding or captured inside its scope.What the tree could not say:
fnandlambdaparameters carry no
span. Their anchors are recovered from the header's bytes
(lspHeaderParamSpans) and used only when the count agrees with the
tree; a curried lambda's nodes take the one scan's spans in order.Measured on this repository's own sources, 60 positions each, every
one of the six requests costs 0.41x–0.63x of thedidOpenon the same
file (typecheck.ax 14 ms against 26; codegen.ax 18 against 32).
drive.py's NAV block derives every expected range from four documents
written in the driver; the cross-file rename is APPLIED in Python,
written to disk and reopened — the checker publishing nothing for the
renamed pair is the proof that no occurrence was missed. -
Six ways to read a document without changing it.
textDocument/signatureHelpanswers the call the cursor is inside — a
fnof the document, one of its constructors, or an importedfn
with its module named — as(add x y) : (-> Int Int Int)with the
type cut from the::beside thefn, each parameter as the UTF-16
offset pair that slices the label to its name, the paragraph above
the declaration as documentation, andactiveParametercounted from
the bytes by the same string-and-comment-aware scanner every other
request uses.textDocument/inlayHintshows three things the source
does not spell:x:before each argument of a call to a declared or
importedfn(never before a variable spelled like its parameter),
: Intafter each parameter in a header and-> Intafter it, the
last two from the signature's arrow.textDocument/foldingRangefolds
every form and brace block whose opener and closer sit on different
lines, comment runs ascommentand import runs asimports, from a
bracket scan — so a half-typed file still folds.selectionRange
answers word → form → … → document;documentLinkturns every
(import M)the resolver's own search can find into a link over the
dotted name as written;workspace/symbolsearches every open document
and every module each imports for a case-folded substring, capped at
200.The one measurement that changed the code: parameter-name hints look a
callee up once per call site, so the declaration list is indexed per
request. Keyed on the name's first byte, this repository's naming —
lsp*,vec*,f0..f2049— puts every function in one bucket:
0.050 s for a whole-document request over 2,050 headers against a
0.031 sdidOpen. A djb2 hash over 1,024 slots: 0.012 s. Ratios on
the gate's document: signature help 0.29x, folding 0.10x, selection
0.01x, links 0.07x, workspace/symbol 0.54x at 200 items, a
whole-document hint request 0.25x (0.81x on a variant where every
function calls another; a 60-line editor window 0.54x). -
The server formats, fixes, runs and expands.
textDocument/formatting
answers the formatter's output as one edit over the whole document,
from the samefmtFormatthataxiom fmtruns,[]for an
already-formatted file and null for one that does not parse — the
gate holds the edit's text equal, byte for byte, to whatfmtwrote to
a copy.textDocument/codeActionoffers the compiler's machine-
applicable fixes as quickfixes — a help carrying a fix span, exactly
what AXDL prints after~>, becomes a preferred CodeAction, so a code
that gains a fix intypecheck.axgains a quickfix without a line
changing here — and an Add type signature forfassist for afn
with no::, written from the type the checker inferred, in the
parser's own spelling(-> Int Int)(tyRender's(Int -> Int)is
refused by the parser, which the assist found). The gate holds
AX3012's fix to the derived range of the BINDER — not thesetthe
diagnostic anchors on, thirteen columns away — with textmut x,
AX3001's to the misspelt call, and the assist toaxiom symbols'
type rewritten; then applies all three in Python, reopens the result
and requires no diagnostics at all.textDocument/typeDefinitionlands
on thedata,structortypeof a signed fn's result, a header
parameter or a constructor, null for builtins.textDocument/codeLens
puts a▶ Runlens onmainand an Expand macro lens on every
pub macro; the commands are the client's to run, as rust-analyzer's
are, and the server runs nothing.axiom/expandMacro, the analogue ofrust-analyzer/expandMacro,
answers what a macro generated as Axiom source: on a top-level
invocation its own products, on a macro declaration everything it
generated. It required the firstASTNode-to-source printer anywhere
in the compiler —format.axprints from its own token forms,
symbolsrenders types alone — whose promise is that its output parses
and means what the tree meant. The gate reopens the rendering as a
document and requires a clean parse with an outline of exactly the
generated name; measured beyond the gate on a template holding a
mutablelet, a block,set,while,match,cond, a lambda,
struct construction, field access, an escaped string, a char, a float,
a negative literal and a generateddata,typeandstruct,check
on the rendering reported exactly the diagnostics it reported on the
template. A joined product name records span 0, so an invocation's
products are attributed structurally — the fresh parse is expanded
with every other invocation removed — rather than by position.Costs on the gate's 2,050-declaration document against a 0.031 s
didOpen: formatting 0.66x, codeAction 0.74x (1.39x before the
is-there-a-signature question was asked before the walk),
typeDefinition 0.50x, codeLens 0.18x, expandMacro 0.69x. -
Syntax highlighting, by what a name resolves to.
textDocument/semanticTokens/fulland/range, the protocol's
highlighting channel and the feature rust-analyzer is known by. Every
identifier is classified by WHAT IT RESOLVES TO — the same occurrence
walk that answersreferences, expanding nothing — and not by how it
is spelled: a parameter, alet(readonlyunlessmut,
modificationat itsset), this document's function, type or
macro, a constructor, an effect, or a name from elsewhere
(defaultLibrary). Keywords, strings, char literals, numbers,;
comments,#| |#blocks, AXTAG lines (decorator) and
operator-spelled names come from one byte scan inlspFormEnd's
order, so nothing depends on a parse: on the keystroke that breaks
it,(fn (add x y) ...)keepsadda declared function andya
parameter, and a parameter READ in the body falls tovariable,
which is the honest answer once there is nothing to resolve it
against — highlighting that flickers on every unbalanced paren is
worse than none. The legend is fourteen types and four modifiers,
advertised ininitialize; no delta request is offered.Measured on the gate's 2,050-declaration document against a 0.023 s
didOpen: the full request, 22,556 tokens, 0.57x; a 60-line range,
0.07x. drive.py derives 54 anchors over all fourteen types from a
document written there, decodes the delta array by a second
implementation, holds every token sorted, non-overlapping, inside
its line and spelling its class, holdsrangeequal tofull's
tokens on its lines, and holds the broken twin's keywords and
literals unchanged. The gate's sweep firesfullandrangewith
every other request. -
One door for what the server learns next.
lsp.ax's dispatch was
a nestedifchain and its capabilities one expression; both are now
three sections — NAV, VIEW, FIX — each answering "do you handle this
method", "what do you reply", "what do you advertise", asked in a fixed
order, and each with its own region oftests/lsp/drive.py. Three
engineers added to one file at once without a merge conflict, which is
the whole reason. Beside the sections,lspFormEnd(factored out of
lspFormText),lspEnclosingOpensandlspSpanHasanswer where a
form ends and which forms contain an offset — three sections were
about to ask — andlspResolveForstates the import preamble once
where the file had grown six copies of it. -
Every advertised request, at every kind of position, on files
written for something else.check-lsp-selfhost.shends with a
sweep whose method list is DERIVED from the capabilities the server
advertises: a key the sweep cannot build a request for fails the gate,
and it refuses to run under twelve providers, so a server advertising
three things cannot pass by sweeping three. Every 97th byte of
stdlib/Json.axplus the positions that break scanners — 0, EOF, one
past EOF, a line past the end, inside a string, inside a comment, on
(, on)— then the same module cut off mid-form, then an empty
document. Every id must be answered, no answer may be an error, and
the shutdown afterwards must still be answered. 10,564 requests over
19 providers, all answered, in about 12 s.
Reviewed
An adversarial pass drove every request at real files and at documents
written to ask what the sections' own tests did not, and found four
wrong answers, each reproduced by a request before it was touched and
each now pinned by a derived check the pre-fix server fails:
- A rename of a type left its uses behind. The occurrence walk read
expressions and declaration names and skipped type positions, so
renameonShaperewrote the declaration and left(:: area (-> Shape Int)), a struct field and an alias spelling the old name. Type
nodes carry no span, so the positions are now read from the bytes of
every::,data,struct,type,implandtraitform and
resolved against a TYPE table alone — afnspelled like adatais
not the same name. On four real files,referenceson twenty
top-level names equals a whole-identifier grep of the code. - Signature help and parameter hints answered a top-level function
for a local of the same name —(fn (t7 f) (f 1 2))beside a
top-levelf— and afnheader answered its own signature. A local
index over the declarations a range meets is asked first. - The assist wrote a type the parser refuses.
symbolsprints an
unresolved variable as_tN, and a type variable must start with a
lowercase letter; each is now lettered in order of appearance, and
over every unsignedfninstdlib/,tests/anddocs/all
fourteen offered signatures check clean. - An expansion was empty by name, and one did not lex. A generated
implis named after its trait, which the written list declares, so
(deriveEq Inner)answered nothing; products are known by identity
now. Asyntax/bindersvariable isx#0in the tree, and#is not
an identifier byte; every byte the lexer refuses is written_. The
differential — every top-level invocation undertests/anddocs/
expanded and spliced into a copy in place of the invocation,check
codes andsymbolsnames held equal to the original — passes all 43
invocations whose document expands.
Two costs were found and cut in the same pass: the whole-document code
action, 1.34x–1.87x of didOpen with two walks per unsigned fn, is
0.87x–1.28x with bucket lookups.
Found, not fixed
Three compiler defects the language-server work ran into, recorded here
because the next release should carry the fix and this one does not:
- A generated
struct's fields carry no type.expand.ax's
expBuildStructFieldsInsubstitutes the parsed field'sbslot —
the float flag — where the type node sits, so every struct a macro
generates has fields the checker treats as wildcards andsymbols
prints without types. The printer renders them(name), which
re-parses to the same wildcard;docs/macro-system.mdsays so beside
MAC-TOOL-3. checkaccepts a keyword as a variable name andfmtrefuses it.
(fn (g data) data)type-checks and builds;axiom fmtrefuses the
file, by the documented rule (tests/fmt/parity/070-keyword-in-expr)
it inherited from the retired compiler's lexer, which reserved the
structural keywords everywhere. The self-hosted parser reserves them
only in head position. Three functions in the language server named a
parameterdataand the battery'scheck-fmt.shcaught it; they are
renamed here, and the two subcommands disagreeing about a valid
program is the defect, recorded for the next release to settle one
way or the other.- A parameter named
t0collides with the emitter's temporaries.
(fn (f t0) ...)passescheckand dies inoptwith multiple
definition of local value named 't0', reported asAX4003. AnytN
spelling does it. A reserved-name refusal at the checker or a
mangling rule at the emitter is the fix; neither is in this release.