Spec M28 - mc lsp: the taught compiler as language server
Synthesis of the design panel (three proposals, two judges). Winner: "the parse is the index" - a
token log plus two frame edges plus a small module vocabulary, with no change to src/ast.mc.
Grafted: the diagnostic/resolution hook wired at the compiler's own name-resolution sites, the
two-phase emission that keeps colours alive through a syntax error, the arena bracket in the long-lived
process, the CPU guard and mc build --compiler-only.
1. Principle #
Only the project's taught compiler knows the developer's syntax, so
the server IS that compiler in lsp mode. Nothing about any specific taught language enters src/.
The server never learns what class, namespace, ref, . or a generic is; it reports what the
parser knows: a token's class from the tables the language itself built, a declaration's position, a
use's link, a diagnostic's range. Anything a module must tell the LSP that the core cannot deduce - that
Box__Circle__4 is written Box<Circle,4>, that geo.Circle resolves to geo__Circle - goes through
a generic mechanism justified by at least two imaginable languages, exactly as M21 SS1 requires. The
core never mangles, never guesses, never splits a name on __.
Inert by construction (M21 SS6.5): with nothing registered and the index off, every tests/*.mc object
and every --dump-ast/--dump-tokens byte is identical to the pre-M28 compiler's.
2. Server shape and protocol surface #
Three roles, two long-lived processes, one worker per parse. mc lsp [DIR] [--config F] is a
subcommand of main(), beside mc build.
- Front - the process the editor launches: framing, JSON, the document store, the last good index,
the debounce timer. It reads
mc.tomlfirst; when[compiler].modulesis non-empty it runsmc build --compiler-onlyandexecvs the resulting taught compiler with the samelsparguments, before answeringinitialize(so no document can exist yet). From that instant the server is the taught compiler. Becausejson.mc,index.mc,lspclass.mcandlsp.mcsit insidesrc/core.mc, every taught compiler is a language server for its own language at no extra cost, the same way it already carriesmc build. - Zygote - forked by the front at startup, before the front touches the arena, and it never
parses: its
hpstays 0 for the life of the server. Pristineness is structural, not maintained. - Worker - one per parse, forked by the zygote. Registers the overlay, the error hook and the ref hook, then runs the ordinary front end with no reset code at all.
Why a process and not a reset. The compiler's globals are one-shot; src/driver.mc says so in its
own header and spawns a second process for the second half of a taught build. Resetting means ~46 core
counters and every module's own tables, which the core cannot see: examples/api/oop.mc has
MAXIFACE 16 and a monotone nifaces, so an in-process loop dies with too many interfaces on parse
- A fork resets tables the core will never know exist, in zero lines. Measured here with
build/mc1and a bareextern i64 fork();: 437 us per fork+waitpid from a clean parent, 474 us with 16 MiB dirty - flat, because COW is lazy.posix_spawnofmc lsp --lsp-indexis the fallback whereforkdoes not exist (M19/M20), measured 1.5-1.8 ms.
Protocol. JSON-RPC 2.0 over stdio, Content-Length framing, reader/writer in src/json.mc. Every
field read or written is an integer or a string, so the absence of floating point costs nothing;
js_int skips a fractional part so a client sending 1.0 is harmless. Positions are 0-based line plus
UTF-16 character, computed in the worker, which holds every buffer. Exact request list, and nothing
else:
| in | initialize, initialized, shutdown, exit, $/cancelRequest (kill the worker) |
|---|---|
| sync | textDocument/didOpen, didChange, didClose - full sync only (textDocumentSync: 1) |
| out | textDocument/publishDiagnostics |
| query | semanticTokens/full, definition, references, hover, documentSymbol, completion |
| watch | workspace/didChangeWatchedFiles (mc.toml and [compiler].modules sources) |
Declined and advertised as such: incremental sync, semanticTokens/full/delta and /range, rename,
formatting, folding, code actions, signature help, inlay hints, workspace/symbol, progress. Requests
are handled strictly sequentially; LSP permits it and it removes all concurrency.
Reparse. didChange replaces the document and arms a 150 ms idle timer (poll's timeout - verified
in this repository: a 150 ms poll on an empty pipe returns 0 after 151 ms). On fire, any in-flight
worker is killed and reaped, then one new worker is forked. Exactly one worker exists at any moment, so
the queue cannot grow. Queries are answered from the last blob without reparsing.
Guards. The worker sets setrlimit(RLIMIT_CPU, {N,N}) on itself, so a taught handler that loops
forever is killed instead of wedging the editor - verified here: raw wait status 24 = SIGXCPU. A worker
that dies without a complete blob leaves the previous index in place; the front says the index is stale
rather than blanking the file.
3. Core changes #
Signatures, call sites, cost, and the second consumer that earns each one.
src/ast.mc is not touched: provenance is keyed on the token log, never on a node field. That is
deliberate - node_assign is a blind mem_copy of all ND_SIZE bytes, so a per-node position column
is silently overwritten by node_copy_subst with the template's own position, and M21's ref rewrite
discards nodes after the parse. It also leaves M21 SS5's objection to nd_uty standing instead of reopening it.
| # | mechanism | where | lines | justified by |
|---|---|---|---|---|
| 1 | cbol + TOK_COL (tok_col/set_tok_col, TOK_SIZE 48->56) + OF_BOL/OF_BASE saved and restored with cp/cend/cline | src/lex.mc | 14 | LSP needs a column; M30's DWARF rows carry DW_LNS_set_column, which the compiler cannot produce today at all; err_at could gain one later. Measured +1.2% on a full parse - noise |
| 2 | lx_raw (one global, one assignment before set_tok_line) + the token log: one 24-byte row per delivered token {frame u16, off u32, rlen u16, id u32, line u32, scope u32, decl i32}, i64 p_mark() = the serial of the token the parser is on | src/lex.mc (tables and lsp_tok live here so src/lexdump.mc needs no new include) | 110 | The raw span is required: lex_string rewrites tok_start into the arena and subst_apply rewrites it to the replacement lexeme, so tok_start/tok_len are not source coordinates for two token kinds. Second consumer: --dump-tokens becomes a printer over this table; third: M30 |
| 3 | frame table with origin_mark (the token that caused the push) and inner (pointer containment of the pushed buffer inside an ancestor's, with a byte delta) + void p_origin(i64 from, i64 to) | src/lex.mc, at lex_push_mem/lex_pop | 45 | origin and inner need no module cooperation at all. p_origin widens the default one-token origin to a whole construct: C++/C# template instantiation must blame Box<Circle,4>, not its ;; an import/using system must blame import geo; |
| 4 | void lex_set_overlay(uptr fn); uptr f(uptr path, uptr plen) returns the editor's unsaved text or 0, consulted by lex_push and lex_readable before read_file | src/lex.mc | 14 | Verbatim the lex_set_bundle precedent. Second consumer: drv_gen_compiler writes build/mc-<name>.mc to disk only because the lexer cannot be handed a buffer |
| 5 | scope chain: lsp_scope_push()/lsp_scope_pop() around parse_block's braces and around a function's parameters plus body in parse_top/parse_function | src/index.mc + 6 lines in src/parse.mc | 30 | Core bookkeeping. The parameter pair is not optional: without it parameters land at file scope (a real bug found in a panel prototype) |
| 6 | core declaration recording: p_decl(name, kind, mark) at parse_top, parse_var, parse_params, parse_extern (token-exact); def_add and top_add (line-granularity, from the node's own file/line); lsp_word in word_add for a word registered while parsing | src/parse.mc (6 sites), src/hooks.mc (1) | 55 | top_add is the whole zero-cooperation story: with examples/api/oop.mc unmodified, rect_w, set_rect_w, shape_area and rect_new already resolve to the right source line, because the module's generated node carries the position its handler set |
| 7 | module vocabulary: i64 p_decl(uptr name, i64 kind, i64 mark) -> 1-based id, 0 when the index is off; void p_ref(i64 decl, i64 mark); i64 p_decl_find(uptr name); void p_decl_show(i64 decl, uptr display, uptr detail); void p_decl_parent(i64 decl, i64 parent) | src/index.mc, re-exported next to p_mark/p_start/p_depth in src/parse.mc | 55 | p_decl: a module declares a named entity the core never sees (C#/C++ fields and methods - oop.mc does this today; Zig comptime; a units DSL). p_ref: a module links a token to a declaration it resolved in its own table (C# geo.Circle, C p.x, any import). p_decl_show is the one mangling mechanism - C++ overload mangling, ObjC -[Rect area], Java Outer$Inner, an ML functor and a minifier are the same shape. kind is module-chosen from an LSP-SymbolKind-derived enum, which is how a member declared by an enum handler paints as a type. All are no-ops when the index is off, so a module carries the calls at zero cost in a normal build |
| 8 | void ref(uptr fn) + ref_note(cls, name, file, line, col, len), called at the compiler's own resolution sites: def_find in parse_primary, and local_find/global_find/func_find plus local_add/param/global declaration in src/gen_arm64.mc | src/hooks.mc + 1 site in src/parse.mc + 7 in src/gen_arm64.mc | 30 | This is the fidelity graft. The core parser does not bind names - parse_primary builds an N_IDENT carrying a string and resolution happens inside gen_lower. Hooking there means the editor reports what the compiler actually resolved, with its real shadowing, instead of a parallel resolver that can drift. Second consumer: a cross-reference / dead-code tool; third: M30's local-variable list is this stream plus frame offsets |
| 9 | void err_set(uptr fn); one line at the top of err_at, err_at2, die, die2 before the existing _exit. Printed text unchanged, so every tests/err/* expectation stands | src/arena.mc | 20 | err_node already routes through err_at, so this catches lexer, parser and codegen diagnostics including M21's instantiated from prefix. Second consumer: mc build --diagnostics=json for CI, which today needs screen-scraping - and the i18n tables in scripts/i18n-map.tsv are about to make that wording move |
| 10 | i64 arena_mark() / void arena_release(i64 m) | src/arena.mc | 6 | The two lines a bump allocator was always one step from. Not optional: a panel prototype without it died with arena exhausted after 393 edits of a 3.1 KB document; with the mark taken before the framed message is read, 25 000 edits ran flat. The workers are self-cleaning, but the front never forks itself and must bracket every request. Second consumer: mc build, which accumulates a TOML parse plus two compiles |
| 11 | best-effort resolver (lsp_resolve), UTF-16 positions, blob writer/reader | src/index.mc, src/lsp.mc | 235 | See SS4 |
| 12 | src/lspclass.mc - i64 lsp_class(i64 mark) over tables that already exist | new file, in core.mc only | 75 | See SS4 |
| 13 | src/json.mc - span reader (no DOM), buf_* writer | new file | 260 | Required; no dependency may be added. src/toml.mc is 491 lines for a comparable format |
| 14 | src/lsp.mc - framing, document store, fork supervisor, poll loop, the handlers | new file | 600 | The server. src/backend_exe.mc is 890 lines for a comparable job |
| 15 | mc lsp / --lsp-index / --dump-index / --dump-sem in main(); --compiler-only in drv_build; 3 #includes in core.mc, 1 in astdump.mc | src/main.mc, src/driver.mc, src/core.mc, src/astdump.mc | 30 | Subcommand, not flag - the mc build precedent. --compiler-only prints the path of [compiler].out after drv_gen_compiler + drv_compile and returns, which the extension needs and CI wants anyway |
| 16 | DE_LEN on DefEnt (optional, separable) | src/parse.mc | 6 | sample puts 57% of parse time inside def_find, almost all of it cstrlen recomputing the 426 #define names per identifier. Caching the length takes src/mc.mc from 77.8 ms to 49.0 ms, verified byte-identical output. Helps every mc build |
Total ~1585 lines: ~355 in existing files (lex.mc 183, parse.mc 78, hooks.mc 30, arena.mc
26, gen_arm64.mc 8, main.mc/driver.mc/core.mc/astdump.mc 30, ast.mc zero) and ~1230 in
four new ones (json.mc 260, index.mc 300, lspclass.mc 75, lsp.mc 600). stage0 is untouched:
mc lsp does not exist in C, exactly as --exe does not.
A rule for review: src/index.mc must stay compilable by the frozen seed build/mc0 with only
arena.mc, lex.mc and ast.mc in front of it, because src/astdump.mc includes it for check-ast.
Any use of a hooks.mc table inside it breaks that and belongs in lspclass.mc.
4. Provenance model #
Ranges. Every position is a token serial. lsp_tok appends one row per delivered token;
p_mark() is the serial of the token the parser is on. A declaration's range is its own row, a use's
range is its own row, a diagnostic's range is the row of the token the error was raised on. There is no
second position system and no heuristic. tk_col derives the byte column from cbol; UTF-16 conversion
happens once, in the worker, with a running cursor advanced in token order (tokens within a frame have
increasing offsets), counting +1 per byte with (b & 0xC0) != 0x80 and +1 extra per byte >= 0xF0.
The front never reads source text.
Token classes, with zero new tables. lsp_class(mark) is a pure function of the recorded id plus
predicates hooks.mc already exports: id < 256 selects identifier/int/char/string/directive/hole;
K_U8..K_VOID type, K_IF..K_EXTERN keyword, K_LPAR..K_ARROW operator or punctuation; id > K_ARROW
means the lexeme was taught, and which table claims it says what it is - alias_find type,
infix_is_taught operator-with-handler (so +, in the same table without a handler, is never
mislabelled), syntax_find/syntax_stmt_find/syntax_expr_find/rule_find keyword, else te_word
decides word versus punctuation. def_find marks a macro. An SC_TAUGHT bit rides along, giving the
editor a taught modifier over the developer's own vocabulary. Because the recorded id already
reflects every tok_add that had happened when the token was produced, classification is exact by
construction: a name used as an identifier at line 10 and taught at line 100 records as an identifier
and stays one.
Declarations, in three tiers. (1) Core, token-exact: the six p_decl sites of SS3.6. (2) Core,
line-granularity: def_add (a DefEnt keeps no position) and top_add, which records every
N_FUNC/N_GLOBAL/N_EXTERN/N_PROTO a module appends, from the node's own file/line. (3) Taught
words: word_add records the position of the token the parser is on, so class Rect makes Rect
navigable while syntax("class") inside user_init correctly records nothing - the module's vocabulary
is not the program's. Tier 3 is skipped when a module already claimed the name, so cooperation upgrades
instead of duplicating.
Uses, two passes, the compiler's answer winning. The worker runs both:
- Best-effort (
lsp_resolve, afterparse_unit): for each identifier row that is not itself a declaration, match by lexeme against the declaration table walking the scope chain outward, requiring declaration-before-use for locals and params and order-free at file scope. It is non-fatal, which is the point:gen_lowerdies on the first unknown name, and an editor buffer is unresolved most of the time someone is typing. - Authoritative (
ref_note, duringgen_lower): the compiler's ownlocal_find/global_find/func_findresults overwrite the row'sdeclcolumn and set aconfirmedbit. What the editor shows is then what the compiler resolved, including its real shadowing.
A row that neither pass resolved is reported as "no definition" - never guessed.
Taught constructs. Four mechanisms, none of them about any language.
- Frame origin (free). Every token inside an
#include, a namespace body or a generic instantiation maps up to the token that caused the push.make slot<i64,3>;yields a frame named by the module (slot__i64__3 instantiated from prog.lx:27) whose origin is the real source range. - Frame inner (free). If the pushed buffer lies inside an ancestor frame's buffer - the module
replayed a span recorded verbatim with
p_skip_balanced- every offset converts to the ancestor's text by a constant delta, so go-to-definition insideBox<Circle,4>lands on the corresponding token of theBoxtemplate. When the module composes a new buffer instead (a mangled header prepended), containment fails andoriginremains - which is exactly the gap M21 SS3 already documents, inherited rather than re-invented. p_decl/p_reffor what no bookkeeping recovers: a module resolvesgeo.Circletogeo__Circlein its own table and lowersc.rtold64(c + 8); the core sees three tokens and an arithmetic node. Panel measurement: 11 lines inexamples/api/oop.mcupgrade the line-granularity answers to token-exact onw,h,area,Rect,Shapeand linkclass Rect : Shapeto the interface's own name token; 12 lines inlib/user_syntax_demo.mc'ssyntax_infix("~>")handler declare a member at first use and make every later use resolve to it, theobj.fieldcase of M22, through a real Pratt hook.p_decl_showfor the developer's spelling: hover, outline and completion readBox<Circle,4>while the symbol isBox__Circle__4. The core stores it and hands it back; it never parses it.
Diagnostics. err_set intercepts all four funnels before _exit: the handler appends the record
with the current token's range, flushes the blob and exits 0. A range inside a synthetic frame maps out
through inner (exact) then origin (the use site), so
Pair__i64__3 instantiated from prog.lx:15: unknown name lands on the instantiation with M21's prefix
intact as the message. Two-phase emission keeps a broken file usable: the worker flushes the token
log (phase 1, lex-only - --dump-tokens exits 0 on a file where --dump-rules dies at line 2) before
the parse, so colours and the outline survive to the end of the file even when the parse stops at line
- Everything goes through a
Bufwith oneio_write: field-by-field emission of 13 000 records measured 86.5 ms against 2.3 ms buffered, 37x.
Determinism. Every table is linear in production order - token rows in lex order, files in push
order, declarations and uses in walk order - searched back to front where last-wins is intended. No
hashing, no pointer ordering, nothing iterated for output (docs/determinism.md rule 1). Two runs of
--dump-index on the same bytes are byte-identical, which is what makes the goldens a diff.
5. Performance budget #
Measured. Rows marked (*) were re-measured in this repository against build/mc1; the rest come from
the panel's prototypes on copies of src/.
| step | realistic taught unit (examples/api/main.mc + libs, 977 lines) | this compiler (src/mc.mc, 676 KB, 22 files) |
|---|---|---|
| fork + waitpid (*) | 0.44 ms | 0.47 ms (flat in dirty pages) |
| lex + parse | 2.0 ms | 48.2 ms (77.8 without DE_LEN) |
fold + gen_lower | 0.8 ms | 32.1 ms |
| emit + read the blob | 0.1 ms | 0.3 ms |
| round trip | ~3.5 ms | ~84 ms |
Parse runs at ~14 MB/s of source, parse+resolve at ~8.5 MB/s, so a 100 ms budget covers ~850 KB of unit.
Index overhead: +0.6% compiled in but off (inside noise), +2.6% while indexing. Blob: 24 B/token
plus 48 B/declaration - 87 KB for the realistic unit, 2.4 MB for this compiler. Queries never reparse:
definition is a binary search over the document's rows, references one linear pass (98 986 rows is
~0.1 ms), semanticTokens/full one filtered pass at ~250 ns/token. Front memory is flat because every
request is bracketed by arena_mark/arena_release and the document text lives in a __bss slab;
worker memory dies with the worker. The arena is the ceiling: this compiler reaches 24.8 MB of a 32 MiB
heap with gen_lower, so a unit 30% larger fails - a diagnostic under the worker model, not a dead
server. M23 lifts it.
No incrementality is built, and none is needed below ~5 k lines. Sub-unit incrementality would be
unsound anyway: #define, #token, #infix, #rule, syntax*, type_alias and
p_resplit_punct all mutate lexer and parser state as the parse proceeds, so file k's token stream
(its lexeme boundaries included) is a function of files 1..k-1. There is no prefix-independent unit
to cache.
6. What stays out, and why #
- More than one diagnostic per parse.
err_atends in_exit(1)and there is no recovery point inparse_unit. Recovery would change error text thattests/err/*andscripts/test.shpin byte for byte. Mitigated, not solved, by phase 1 and by the flushed prefix. This is the single largest gap and it is inherited, not introduced. - Anything needing a type. No type checker exists and M21 SS5 refused
nd_utyand a core symbol table for reasons that still hold. So: no hover type for an arbitrary expression, no member completion after a taught., no type errors, no signature help driven by argument types. Each is possible for a taught language if its module emits the facts and impossible for the core. - Rename. A module derives several spellings from one name (
w->rect_w,set_rect_w,RECT_W) and only the module knows the derivation; renaming what the core resolved would silently corrupt source. Would need ap_rename_grouphook. Not offered. - Formatting, folding, code actions, comment colouring.
skip_spacediscards comments and whitespace and they never become tokens; there is no lossless CST and no end position on a construct. M29's TextMate grammar colours comments, layered under semantic tokens. - Workspace scope. One
mc.tomlis one unit is one server, noworkspace/symbol; and the compiler has no translation unit smaller than[project].entry, so a file outside every closure gets an empty index and a message saying why. - Un-registering a word. Structural:
tok_addis append-only, sosyntax_stmt("log")removeslogfrom the identifier vocabulary program-wide and completion can never offer it as a variable. The core already says so plainly and the server repeats it verbatim.
7. Acceptance #
--dump-indexand--dump-semare deterministic text dumps beside--dump-tokens/--dump-ast/--dump-rules, diffed against goldens undertests/lsp/byscripts/check-index.sh: the whole provenance model is testable withdiffand no node client, in the project's own idiom.tests/lsp/client.mc- a scripted LSP client written in the language, driving a real server over a pipe and diffing replies - covers framing, JSON and the handlers.make checkgainscheck-lsp.- Against
lib/user_syntax_demo.mc(exists today, built as a taught compiler byscripts/check-surface.sh):enum,unless,tmpl,make,bits,pipecome back askeyword+taught;~>,.+(code handlers) and<+>(Tier 1#token+#infix) asoperator+taught;+as a plain operator;p ~> len = 3andp ~> at(2)resolve to the member declared at its first use andreferenceson it lists every~>site;make slot<i64,3>;produces a frame whose origin is themakesite and whose diagnostics carry theinstantiated fromprefix mapped to a real range. - Against
examples/lang/main.lxthrough its taught compiler (M22):class,ref,namespacecome back as taught words and.as a taught operator;definitionof a method resolves to theclassbody line;referencesof a field lists every.fielduse;hoveron a#defineshows its folded value and on an instantiation showsBox<Circle,4>, notBox__Circle__4;documentSymbolnests methods under their class throughp_decl_parent; a syntax error reports the right range and the file stays coloured to its end. - Inertness gate (M21 SS6.5): with the index off, every
tests/*.mcobject and every--dump-astoutput is byte-identical to the pre-M28 compiler's, andcheck-lex/check-aststay green under the frozen seedbuild/mc0. make checkgreen; golden rewritten once; stage0 untouched and under budget;docs/surface.mdgains the module vocabulary of SS3.7 with the same emphasis M21 gave the lookahead contract: capture the mark while the parser is still on the token you mean.
8. Decisions (architect, 2026-09-03) #
DE_LEN: in, as a separate commit before the server (its own golden rewrite), since it is a byte-identical parse speedup unrelated to the LSP.gen_loweron every keystroke: yes;[lsp] resolve = "parse"inmc.tomlis the escape for very large units. The authoritative use links andunknown nameare worth +66% of a ~50 ms parse.- Documents outside the entry's closure: scanned as their own root, reported as such;
[lsp] unit = "file"for projects that want per-file navigation only. p_declkinds: start from LSPSymbolKindvalues, documented indocs/reference/hooks.md; append-only afterwards.forkon Windows: accept the asymmetry; the--lsp-indexspawn fallback is the Windows path and is documented, not hidden.- Diagnostics beyond the first: build the bounded rescan now (blank the offending line in the overlay, rescan, at most 3 iterations), because one squiggle at a time is the first thing every developer will complain about; it needs no core change.