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.

  1. Front - the process the editor launches: framing, JSON, the document store, the last good index, the debounce timer. It reads mc.toml first; when [compiler].modules is non-empty it runs mc build --compiler-only and execvs the resulting taught compiler with the same lsp arguments, before answering initialize (so no document can exist yet). From that instant the server is the taught compiler. Because json.mc, index.mc, lspclass.mc and lsp.mc sit inside src/core.mc, every taught compiler is a language server for its own language at no extra cost, the same way it already carries mc build.
  2. Zygote - forked by the front at startup, before the front touches the arena, and it never parses: its hp stays 0 for the life of the server. Pristineness is structural, not maintained.
  3. 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

  1. A fork resets tables the core will never know exist, in zero lines. Measured here with build/mc1 and a bare extern i64 fork();: 437 us per fork+waitpid from a clean parent, 474 us with 16 MiB dirty - flat, because COW is lazy. posix_spawn of mc lsp --lsp-index is the fallback where fork does 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:

ininitialize, initialized, shutdown, exit, $/cancelRequest (kill the worker)
synctextDocument/didOpen, didChange, didClose - full sync only (textDocumentSync: 1)
outtextDocument/publishDiagnostics
querysemanticTokens/full, definition, references, hover, documentSymbol, completion
watchworkspace/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.

#mechanismwherelinesjustified by
1cbol + TOK_COL (tok_col/set_tok_col, TOK_SIZE 48->56) + OF_BOL/OF_BASE saved and restored with cp/cend/clinesrc/lex.mc14LSP 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
2lx_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 onsrc/lex.mc (tables and lsp_tok live here so src/lexdump.mc needs no new include)110The 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
3frame 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_pop45origin 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;
4void 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_filesrc/lex.mc14Verbatim 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
5scope chain: lsp_scope_push()/lsp_scope_pop() around parse_block's braces and around a function's parameters plus body in parse_top/parse_functionsrc/index.mc + 6 lines in src/parse.mc30Core bookkeeping. The parameter pair is not optional: without it parameters land at file scope (a real bug found in a panel prototype)
6core 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 parsingsrc/parse.mc (6 sites), src/hooks.mc (1)55top_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
7module 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.mc55p_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
8void 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.mcsrc/hooks.mc + 1 site in src/parse.mc + 7 in src/gen_arm64.mc30This 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
9void 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 standssrc/arena.mc20err_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
10i64 arena_mark() / void arena_release(i64 m)src/arena.mc6The 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
11best-effort resolver (lsp_resolve), UTF-16 positions, blob writer/readersrc/index.mc, src/lsp.mc235See SS4
12src/lspclass.mc - i64 lsp_class(i64 mark) over tables that already existnew file, in core.mc only75See SS4
13src/json.mc - span reader (no DOM), buf_* writernew file260Required; no dependency may be added. src/toml.mc is 491 lines for a comparable format
14src/lsp.mc - framing, document store, fork supervisor, poll loop, the handlersnew file600The server. src/backend_exe.mc is 890 lines for a comparable job
15mc lsp / --lsp-index / --dump-index / --dump-sem in main(); --compiler-only in drv_build; 3 #includes in core.mc, 1 in astdump.mcsrc/main.mc, src/driver.mc, src/core.mc, src/astdump.mc30Subcommand, 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
16DE_LEN on DefEnt (optional, separable)src/parse.mc6sample 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:

A row that neither pass resolved is reported as "no definition" - never guessed.

Taught constructs. Four mechanisms, none of them about any language.

  1. 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.
  2. 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 inside Box<Circle,4> lands on the corresponding token of the Box template. When the module composes a new buffer instead (a mangled header prepended), containment fails and origin remains - which is exactly the gap M21 SS3 already documents, inherited rather than re-invented.
  3. p_decl / p_ref for what no bookkeeping recovers: a module resolves geo.Circle to geo__Circle in its own table and lowers c.r to ld64(c + 8); the core sees three tokens and an arithmetic node. Panel measurement: 11 lines in examples/api/oop.mc upgrade the line-granularity answers to token-exact on w, h, area, Rect, Shape and link class Rect : Shape to the interface's own name token; 12 lines in lib/user_syntax_demo.mc's syntax_infix("~>") handler declare a member at first use and make every later use resolve to it, the obj.field case of M22, through a real Pratt hook.
  4. p_decl_show for the developer's spelling: hover, outline and completion read Box<Circle,4> while the symbol is Box__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

  1. Everything goes through a Buf with one io_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/.

steprealistic taught unit (examples/api/main.mc + libs, 977 lines)this compiler (src/mc.mc, 676 KB, 22 files)
fork + waitpid (*)0.44 ms0.47 ms (flat in dirty pages)
lex + parse2.0 ms48.2 ms (77.8 without DE_LEN)
fold + gen_lower0.8 ms32.1 ms
emit + read the blob0.1 ms0.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 #

7. Acceptance #

8. Decisions (architect, 2026-09-03) #

  1. 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.
  2. gen_lower on every keystroke: yes; [lsp] resolve = "parse" in mc.toml is the escape for very large units. The authoritative use links and unknown name are worth +66% of a ~50 ms parse.
  3. 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.
  4. p_decl kinds: start from LSP SymbolKind values, documented in docs/reference/hooks.md; append-only afterwards.
  5. fork on Windows: accept the asymmetry; the --lsp-index spawn fallback is the Windows path and is documented, not hidden.
  6. 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.

Edit this page