07 — LSP server emitter
Article 04 introduced the EmitterRegistry and the grammar emitter. Article 05 wrote the manifest emitter — the assembler that runs last. Article 06 wrote the pair of fragment-contributing emitters that feed the manifest: snippets and executors. This article adds the biggest consumer of the LanguageIR contract in the Ide.Dsl build, and the one that most nakedly expresses the design thesis of the companion series: the LSP server emitter. It reads ir.lspFeatures: readonly IRLspFeature[] and writes a full vscode-languageserver node project under ${outDir}/server/ — a server.ts entry point that calls createConnection() and onInitialize(), one handler file per IRLspFeature, a package.json, a tsconfig.json, and nothing else. The handler logic — diagnostic computation, completion ranking, hover lookup, definition resolution, references indexing, rename planning — lives, by design, in @frenchexdev/ide-runtime as abstract base classes the emitted handler files extend. The emitter emits wiring. That is the whole article.
The design-series companion is 05 — DRY and the IR as contract. Article 05 argued that anything forcing two emitters to agree on more than the IR is a DRY violation; this article generalises the claim to the runtime boundary. LSP handler logic is complex, stateful, and evolves with the LSP spec itself — if the emitter inlined that logic into each generated extension, every Ide.Dsl user would be frozen at the LSP version of their last regeneration. Pulling the logic up into a runtime package the emitted server imports is what lets the runtime upgrade without regenerating the .vsix, and what keeps the emitter small enough to audit in one sitting.
REQ-IDEDSL-LSP-SERVER-WIRING-ONLY — the Requirement
REQ-IDEDSL-LSP-SERVER-WIRING-ONLY — For every
IRLspFeaturedeclared in aspec.tssource file, the build shall produce avscode-languageservernode scaffold under${outDir}/server/such that: (a) the emitter writes exactly one entry-point file at${outDir}/server/src/server.tsthat importscreateConnection, registers anonInitializehandler, and returns aServerCapabilitiesobject derived deterministically fromir.lspFeatures; (b) for eachIRLspFeaturein the IR, the emitter writes exactly one handler file at${outDir}/server/src/handlers/${feature.kind}.tswhose body imports an abstract base class from@frenchexdev/ide-runtimeand exports a concrete subclass parameterised only by IR-derived data (rule names, token names, snippet triggers); (c) the emitter writes no handler logic — no analysis, no ranking, no symbol-table manipulation appears in any emitted file, and a textual scan of${outDir}/server/src/handlers/*.tsmatching the identifiersanalyze|complete|resolveDefinition|computeReferences|planRenameshall find zero hits outside of import statements and class-bodysuper.*calls; (d) the emitted${outDir}/server/package.jsondeclares@frenchexdev/ide-runtimeas a runtime dependency andvscode-languageserveras a runtime dependency; (e) the LSP server emitter runs before the manifest emitter inEmitterRegistry.runAll(), so the extension-levelpackage.jsonthat the manifest emitter assembles can reference the server binary the LSP emitter has already written.Rationale: an LSP handler is the densest surface in an IDE extension. The diagnostics pipeline alone requires incremental parsing, a symbol index, a diagnostic deduplication window, and a change-debouncing policy — every one of those is independent of the language the server is serving. If the Ide.Dsl emitter inlined that logic into every generated server, a bug fix to the debouncing policy would require regenerating every
.vsixever shipped, and the language-levelspec.tssource of truth would silently accumulate LSP-version skew against VSCode's host. The runtime-boundary split solves three problems at once: it keeps the emitter small (the emitted wiring fits in one screen per handler), it keeps handler logic testable independently of any specific language (the@frenchexdev/ide-runtimeunit tests do not depend on aLanguageIRfixture), and it keeps the upgrade path linear (bump the runtime semver, republish, existing generated servers pick up the fix on their nextnpm install).Fit criteria: a
spec.tsfixture withir.lspFeatures = [{ id: 'diag', kind: 'diagnostics' }, { id: 'cmp', kind: 'completion' }, { id: 'hov', kind: 'hover' }]shall produce, on build, aserver/src/server.tswhoseonInitializereturns aServerCapabilitiesobject with exactlytextDocumentSync,completionProvider, andhoverProviderpopulated; three files inserver/src/handlers/nameddiagnostics.ts,completion.ts,hover.ts, each under 20 source lines; aserver/package.jsonwith@frenchexdev/ide-runtimeandvscode-languageserverindependencies; and zero lines of analysis logic in the emitted server tree. The identifieranalyzeshall appear only inimportstatements andsuper.analyze(...)calls in the emitted handler files.Verification: Test. Refines REQ-IDEDSL-IR-VERSIONED-CONTRACT and REQ-IDEDSL-EMITTER-REGISTRY-ORDERED.
Two notes before the article unfolds. First, the "wiring only" constraint is enforced by the fit criterion above as a textual scan, not as a structural analysis. A scan is cheap, deterministic, and unforgiving; a structural analysis would need its own test harness and would drift out of calibration with every @frenchexdev/ide-runtime refactor. The scan fails fast when someone is tempted to inline "just this one line of logic" into an emitted handler, and that is the pressure the boundary needs to stay intact over years.
Second, ordering — LSP before manifest — is the non-obvious half. The manifest emitter (article 05) writes the extension's top-level package.json, including the main field that points at the server binary. If the server binary does not exist when the manifest emitter runs, nothing fails at generation time, but a downstream vsce package invocation silently ships a broken .vsix. Ordering makes that failure noisy: the manifest emitter reads the server's package.json during assembly and fails if it is missing. The EmitterRegistry.runAll() ordering contract (article 04) is how that coordination is made explicit.
FEAT-IDEDSL-07 — the satisfying Feature
// packages/ide-dsl/requirements/features/lsp-server-emitter.ts
import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqIdeDslLspServerWiringOnlyRequirement } from '../requirements/req-idedsl-lsp-server-wiring-only.js';
@Satisfies(ReqIdeDslLspServerWiringOnlyRequirement)
export abstract class LspServerEmitterFeature extends Feature {
readonly id = 'FEAT-IDEDSL-07';
readonly title = 'LspServerEmitter emits a vscode-languageserver scaffold with one handler file per @LspFeature';
readonly priority = Priority.Critical;
// ── Server scaffold ──
abstract emitterWritesExactlyOneServerEntryPoint(): ACResult;
abstract serverEntryPointRegistersOnInitializeHandler(): ACResult;
abstract serverCapabilitiesDerivedDeterministicallyFromIrLspFeatures(): ACResult;
abstract emittedServerPackageJsonDeclaresIdeRuntimeAndLanguageserver(): ACResult;
// ── Handler wiring ──
abstract emitterWritesExactlyOneHandlerFilePerIrLspFeature(): ACResult;
abstract eachHandlerFileImportsFromIdeRuntimeAndInstantiatesConcreteSubclass(): ACResult;
abstract handlerDispatchRegisteredOnInitialize(): ACResult;
abstract newIrLspFeatureKindAddsNewHandlerFileWithoutRewritingTheEmitter(): ACResult;
// ── Runtime boundary ──
abstract handlerLogicLivesExclusivelyInIdeRuntime(): ACResult;
abstract emittedHandlerFilesContainNoAnalysisIdentifiers(): ACResult;
abstract lspServerEmitterRunsBeforeManifestEmitterInRegistry(): ACResult;
}// packages/ide-dsl/requirements/features/lsp-server-emitter.ts
import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqIdeDslLspServerWiringOnlyRequirement } from '../requirements/req-idedsl-lsp-server-wiring-only.js';
@Satisfies(ReqIdeDslLspServerWiringOnlyRequirement)
export abstract class LspServerEmitterFeature extends Feature {
readonly id = 'FEAT-IDEDSL-07';
readonly title = 'LspServerEmitter emits a vscode-languageserver scaffold with one handler file per @LspFeature';
readonly priority = Priority.Critical;
// ── Server scaffold ──
abstract emitterWritesExactlyOneServerEntryPoint(): ACResult;
abstract serverEntryPointRegistersOnInitializeHandler(): ACResult;
abstract serverCapabilitiesDerivedDeterministicallyFromIrLspFeatures(): ACResult;
abstract emittedServerPackageJsonDeclaresIdeRuntimeAndLanguageserver(): ACResult;
// ── Handler wiring ──
abstract emitterWritesExactlyOneHandlerFilePerIrLspFeature(): ACResult;
abstract eachHandlerFileImportsFromIdeRuntimeAndInstantiatesConcreteSubclass(): ACResult;
abstract handlerDispatchRegisteredOnInitialize(): ACResult;
abstract newIrLspFeatureKindAddsNewHandlerFileWithoutRewritingTheEmitter(): ACResult;
// ── Runtime boundary ──
abstract handlerLogicLivesExclusivelyInIdeRuntime(): ACResult;
abstract emittedHandlerFilesContainNoAnalysisIdentifiers(): ACResult;
abstract lspServerEmitterRunsBeforeManifestEmitterInRegistry(): ACResult;
}Eleven ACs in three clusters. The Server scaffold cluster nails the entry point shape and the capability derivation. The Handler wiring cluster nails the one-file-per-feature rule and the OCP extension point — adding a new IRLspFeature.kind (say, 'formatting') adds one handler file and one entry to the capability-mapping table, and touches zero other lines of the emitter. The Runtime boundary cluster is the DRY cluster — the one that says out loud what the design companion article argued in the abstract.
The emitted server scaffold
The vscode-languageserver node module is the canonical LSP server host for VSCode, maintained by Microsoft in the same vscode-languageserver-node monorepo that contains the client-side counterpart vscode-languageclient. It was open-sourced in 2016 shortly after the LSP specification itself went public; the protocol text lives at microsoft.github.io/language-server-protocol/. The module exports a handful of entry points — createConnection, TextDocuments, ProposedFeatures — and an event-handler registry reachable through the connection object. A minimal server is ten lines. A language-realistic server is a few hundred. The Ide.Dsl emitter targets the minimal side of that range, because everything realistic has been pulled into the runtime package.
The emitted server.ts entry point is the same shape for every generated extension — what varies is the handler imports and the capability object. Given an ir with diagnostics, completion, and hover features, the emitter writes:
// ${outDir}/server/src/server.ts — generated, do not edit
import {
createConnection,
ProposedFeatures,
TextDocuments,
TextDocumentSyncKind,
type InitializeParams,
type InitializeResult,
} from 'vscode-languageserver/node.js';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { DiagnosticsHandler } from './handlers/diagnostics.js';
import { CompletionHandler } from './handlers/completion.js';
import { HoverHandler } from './handlers/hover.js';
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
const diagnostics = new DiagnosticsHandler(connection, documents);
const completion = new CompletionHandler(connection, documents);
const hover = new HoverHandler(connection, documents);
connection.onInitialize((_params: InitializeParams): InitializeResult => ({
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: { resolveProvider: false, triggerCharacters: ['.'] },
hoverProvider: true,
},
}));
documents.onDidChangeContent((change) => diagnostics.analyze(change.document));
connection.onCompletion((params) => completion.complete(params));
connection.onHover((params) => hover.hover(params));
documents.listen(connection);
connection.listen();// ${outDir}/server/src/server.ts — generated, do not edit
import {
createConnection,
ProposedFeatures,
TextDocuments,
TextDocumentSyncKind,
type InitializeParams,
type InitializeResult,
} from 'vscode-languageserver/node.js';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { DiagnosticsHandler } from './handlers/diagnostics.js';
import { CompletionHandler } from './handlers/completion.js';
import { HoverHandler } from './handlers/hover.js';
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
const diagnostics = new DiagnosticsHandler(connection, documents);
const completion = new CompletionHandler(connection, documents);
const hover = new HoverHandler(connection, documents);
connection.onInitialize((_params: InitializeParams): InitializeResult => ({
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: { resolveProvider: false, triggerCharacters: ['.'] },
hoverProvider: true,
},
}));
documents.onDidChangeContent((change) => diagnostics.analyze(change.document));
connection.onCompletion((params) => completion.complete(params));
connection.onHover((params) => hover.hover(params));
documents.listen(connection);
connection.listen();Two things to notice. The handler imports are generated from ir.lspFeatures — one import statement per feature, derived from feature.kind. The handler instantiations are generated from the same list. The onInitialize body is generated from the same list, through the capability-mapping table that follows further down. The three connection.on* / documents.on* registrations are generated from the same list. Every line except the first five imports is a deterministic projection of the same input. That projection is small enough to template literally — no AST manipulation, no code-rewriting library — just string concatenation with careful newline hygiene, because the output is checked into the extension's source tree and must survive prettier unchanged.
The second thing to notice is what is not in the file. No parser. No symbol table. No diagnostic severity computation. No completion item ranking. Not even a console.log. The file is pure wiring — import, construct, register, listen. If a generated server.ts contains anything else, the emitter has failed its contract.
One handler file per @LspFeature
The per-handler files are even thinner than the entry point. For a diagnostics feature, the emitter writes:
// ${outDir}/server/src/handlers/diagnostics.ts — generated, do not edit
import { DiagnosticsHandlerBase } from '@frenchexdev/ide-runtime';
import { RULES, TOKENS } from '../ir-data.js';
export class DiagnosticsHandler extends DiagnosticsHandlerBase {
protected readonly rules = RULES;
protected readonly tokens = TOKENS;
}// ${outDir}/server/src/handlers/diagnostics.ts — generated, do not edit
import { DiagnosticsHandlerBase } from '@frenchexdev/ide-runtime';
import { RULES, TOKENS } from '../ir-data.js';
export class DiagnosticsHandler extends DiagnosticsHandlerBase {
protected readonly rules = RULES;
protected readonly tokens = TOKENS;
}That is the whole file. Six lines including the imports. The concrete subclass has no methods of its own — it inherits analyze(document), the incremental-parse hook, the debouncing policy, and the diagnostic publication path from DiagnosticsHandlerBase. The only thing that varies per language is the RULES and TOKENS constants, which the emitter writes to a sibling ir-data.ts file (the projection of ir.rules and ir.tokens into runtime-addressable JavaScript objects). Every other IRLspFeature.kind follows the same template:
// ${outDir}/server/src/handlers/completion.ts
import { CompletionHandlerBase } from '@frenchexdev/ide-runtime';
import { SNIPPETS, RULES } from '../ir-data.js';
export class CompletionHandler extends CompletionHandlerBase {
protected readonly snippets = SNIPPETS;
protected readonly rules = RULES;
}// ${outDir}/server/src/handlers/completion.ts
import { CompletionHandlerBase } from '@frenchexdev/ide-runtime';
import { SNIPPETS, RULES } from '../ir-data.js';
export class CompletionHandler extends CompletionHandlerBase {
protected readonly snippets = SNIPPETS;
protected readonly rules = RULES;
}// ${outDir}/server/src/handlers/hover.ts
import { HoverHandlerBase } from '@frenchexdev/ide-runtime';
import { RULES } from '../ir-data.js';
export class HoverHandler extends HoverHandlerBase {
protected readonly rules = RULES;
}// ${outDir}/server/src/handlers/hover.ts
import { HoverHandlerBase } from '@frenchexdev/ide-runtime';
import { RULES } from '../ir-data.js';
export class HoverHandler extends HoverHandlerBase {
protected readonly rules = RULES;
}The pattern generalises. For definition, the subclass provides rules and inherits DefinitionHandlerBase.resolveDefinition(). ReferencesHandlerBase.computeReferences() and RenameHandlerBase.planRename() follow the same shape. Every handler file is under twenty lines; most are under ten. The fit criterion in the opening Requirement — that handlers/*.ts contains zero analysis identifiers outside of imports and super.* calls — is enforceable because the files are small enough to grep by eye. Adding a seventh IRLspFeature.kind (say, 'formatting') is one new handler file, one new row in the capability-mapping table, and one new base class in the runtime. The existing six handler kinds are not touched.
The @frenchexdev/ide-runtime boundary
This is the section that earns the article. Everything the emitter does not emit lives in @frenchexdev/ide-runtime, a standalone npm package the emitted server imports at runtime:
DiagnosticsHandlerBase— abstract class. Owns the incremental-parse cache, the change-debouncing policy (default 150ms), the per-document symbol-index refresh, and theanalyze(document)method that materialises aDiagnostic[]by walking the IR rules against the document's current symbol table. Subclasses overriderulesandtokens; they do not overrideanalyze.CompletionHandlerBase— abstract class. Owns the completion-item ranking heuristic, the snippet-trigger match, and thecomplete(document, position)method that merges IR-declared snippets with rule-derived identifier completions. Subclasses overridesnippetsandrules.HoverHandlerBase,DefinitionHandlerBase,ReferencesHandlerBase,RenameHandlerBase— analogous shapes. Each owns its LSP-spec-side logic, delegates IR-side data to the subclass.SymbolIndex— concrete class, not abstract. Shared across all handlers of a single server. Maintains an incremental symbol table keyed by(documentUri, position), invalidated per-edit by theTextDocumentsevent stream. This is the single most expensive piece of state in an LSP server; it lives in runtime precisely because every handler reads it and no handler owns it exclusively.ParserPort— interface. The runtime package does not ship a parser; the emitted server provides a concrete implementation backed by the grammar emitter's ANTLR output (article 04's consumer). The port contract is stable; the adapter is generated.
The boundary is drawn at this line: anything that is the same across all languages lives in runtime; anything that is specific to one language lives in the emitted server. The symbol-index data structure is the same across languages — one concrete class in runtime. The symbol-index contents are specific to one language — populated by the emitted server's parser adapter against the IR's rules. That split is the DRY axis the companion design article named, and it is the thing the emitter's internal shape has to protect.
The upgrade argument follows. When the LSP specification publishes a new revision — say, semantic tokens get a protocol-level streaming extension — @frenchexdev/ide-runtime ships a new minor version with the upgraded handler bases. Every extension generated by every version of Ide.Dsl picks up the fix on its next npm install, without regenerating a single line of emitted code. The opposite direction is also useful: when the IR shape changes — say, IRRule gains a new field — the emitter bumps its major version, existing extensions regenerate, the runtime does not move. The two packages version independently because their contract is the generated ir-data.ts file, which is a compile-time thing the emitter writes and the runtime reads through a plain import.
This is the strongest practical expression of the DRY argument from the design series. Article 05 argued that the IR is the single shape every emitter consumes, and that anything forcing two emitters to agree on more than the IR is a DRY violation. The LSP handler case generalises the claim to a runtime: anything forcing the emitter and the emitted-at-runtime code to agree on more than a data-only contract is a DRY violation looking for a runtime package to move into. The @frenchexdev/ide-runtime boundary is how the architecture makes that move payable in practice.
Proposal (write-in-public). The
SymbolIndexclass currently invalidates per-edit by rebuilding the index for the edited document; for a 5000-line source file that is measurable latency on every keystroke. A finer-grained invalidation — node-level rather than document-level — would require the index to cooperate with the parser's incremental-parse API, which is available intree-sitterbut not in ANTLR4's current TypeScript runtime. Moving to tree-sitter for the grammar would unlock the finer invalidation but would rewrite the grammar emitter (article 04). I am sketching the trade-off in a separate article; for the current build, document-level invalidation at 150ms debounce is the pragmatic default.
Capability negotiation
The onInitialize body returns a ServerCapabilities object. The emitter builds it from ir.lspFeatures by iterating the list and, for each feature, adding one property. The mapping table is the contract:
IRLspFeature.kind |
ServerCapabilities key |
LSP notification / request |
|---|---|---|
diagnostics |
textDocumentSync (Incremental) |
textDocuments.onDidChangeContent |
completion |
completionProvider |
connection.onCompletion |
hover |
hoverProvider |
connection.onHover |
definition |
definitionProvider |
connection.onDefinition |
references |
referencesProvider |
connection.onReferences |
rename |
renameProvider |
connection.onRenameRequest |
All three columns are deterministic projections of the first; the emitter carries the table as a literal object and iterates it once per build. The diagnostics row is the outlier — textDocumentSync is not a capability in the additive sense; it says "send me didChange notifications, not just didSave". Setting it to TextDocumentSyncKind.Incremental is what makes diagnostics feel live; the emitter always picks Incremental because the Full sync mode ships a stale-diagnostics feeling no modern extension tolerates. The client side of the negotiation is handled by vscode-languageclient, which the extension host bundles separately; the emitted server talks only to its connection object.
The tree makes the boundary visible. Every arrow crossing from the emitted box to the runtime box is an import statement. There are no arrows going the other way — the runtime package has no knowledge of any specific emitted extension. The SymbolIndex node is drawn once, used by all handler bases; that is the dedup that makes the runtime package pay for itself.
The sequence tells the same story the tree told, in verbs. The emitted handler class contributes nothing to the call stack beyond a super call — the inheritance arrow in the tree becomes a single delegation frame in the sequence. Everything underneath is runtime code. If the runtime bumps its diagnostic-walk implementation to a faster algorithm, the sequence diagram does not change shape; the work under the Base->>Base self-message gets cheaper. If the emitter decides to add a seventh IRLspFeature.kind, a new parallel lane appears but the existing lanes are untouched.
Test snippet
// packages/ide-dsl/test/unit/emitters/lsp-server.emitter.test.ts
import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { LspServerEmitter } from '../../../src/emitters/lsp-server-emitter.js';
import { InMemoryFileSystem } from '../../helpers/in-memory-fs.js';
import { fixtureIrWithThreeLspFeatures } from '../../fixtures/lsp-features.js';
import { LspServerEmitterFeature } from '../../../requirements/features/lsp-server-emitter.js';
@FeatureTest(LspServerEmitterFeature)
export class LspServerEmitterTests {
@Verifies('serverCapabilitiesDerivedDeterministicallyFromIrLspFeatures')
async capabilitiesAreDeterministicProjectionOfIrLspFeatures(): Promise<void> {
const fs = new InMemoryFileSystem();
const emitter = new LspServerEmitter();
const ir = fixtureIrWithThreeLspFeatures(['diagnostics', 'completion', 'hover']);
await emitter.emit(ir, fs, '/out');
const serverTs = await fs.readFile('/out/server/src/server.ts');
expect(serverTs).toContain('textDocumentSync:');
expect(serverTs).toContain('completionProvider:');
expect(serverTs).toContain('hoverProvider:');
expect(serverTs).not.toContain('definitionProvider');
expect(serverTs).not.toContain('referencesProvider');
}
@Verifies('emitterWritesExactlyOneHandlerFilePerIrLspFeature')
async oneHandlerFilePerFeature(): Promise<void> {
const fs = new InMemoryFileSystem();
const emitter = new LspServerEmitter();
const ir = fixtureIrWithThreeLspFeatures(['diagnostics', 'completion', 'hover']);
await emitter.emit(ir, fs, '/out');
expect(await fs.exists('/out/server/src/handlers/diagnostics.ts')).toBe(true);
expect(await fs.exists('/out/server/src/handlers/completion.ts')).toBe(true);
expect(await fs.exists('/out/server/src/handlers/hover.ts')).toBe(true);
expect(await fs.exists('/out/server/src/handlers/definition.ts')).toBe(false);
expect(await fs.exists('/out/server/src/handlers/references.ts')).toBe(false);
expect(await fs.exists('/out/server/src/handlers/rename.ts')).toBe(false);
}
@Verifies('emittedHandlerFilesContainNoAnalysisIdentifiers')
async handlerFilesAreWiringOnly(): Promise<void> {
const fs = new InMemoryFileSystem();
const emitter = new LspServerEmitter();
const ir = fixtureIrWithThreeLspFeatures(['diagnostics']);
await emitter.emit(ir, fs, '/out');
const diag = await fs.readFile('/out/server/src/handlers/diagnostics.ts');
// The identifier 'analyze' may appear only in import statements or super.analyze calls.
const lines = diag.split('\n');
for (const line of lines) {
if (line.includes('analyze') && !line.startsWith('import ') && !line.includes('super.analyze')) {
expect.fail(`emitted diagnostics.ts contains analysis identifier outside of delegation: ${line}`);
}
}
}
@Verifies('emittedServerPackageJsonDeclaresIdeRuntimeAndLanguageserver')
async emittedServerTypeChecksDependencyShape(): Promise<void> {
const fs = new InMemoryFileSystem();
const emitter = new LspServerEmitter();
const ir = fixtureIrWithThreeLspFeatures(['diagnostics']);
await emitter.emit(ir, fs, '/out');
const pkg = JSON.parse(await fs.readFile('/out/server/package.json'));
expect(pkg.dependencies).toHaveProperty('@frenchexdev/ide-runtime');
expect(pkg.dependencies).toHaveProperty('vscode-languageserver');
}
}// packages/ide-dsl/test/unit/emitters/lsp-server.emitter.test.ts
import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { LspServerEmitter } from '../../../src/emitters/lsp-server-emitter.js';
import { InMemoryFileSystem } from '../../helpers/in-memory-fs.js';
import { fixtureIrWithThreeLspFeatures } from '../../fixtures/lsp-features.js';
import { LspServerEmitterFeature } from '../../../requirements/features/lsp-server-emitter.js';
@FeatureTest(LspServerEmitterFeature)
export class LspServerEmitterTests {
@Verifies('serverCapabilitiesDerivedDeterministicallyFromIrLspFeatures')
async capabilitiesAreDeterministicProjectionOfIrLspFeatures(): Promise<void> {
const fs = new InMemoryFileSystem();
const emitter = new LspServerEmitter();
const ir = fixtureIrWithThreeLspFeatures(['diagnostics', 'completion', 'hover']);
await emitter.emit(ir, fs, '/out');
const serverTs = await fs.readFile('/out/server/src/server.ts');
expect(serverTs).toContain('textDocumentSync:');
expect(serverTs).toContain('completionProvider:');
expect(serverTs).toContain('hoverProvider:');
expect(serverTs).not.toContain('definitionProvider');
expect(serverTs).not.toContain('referencesProvider');
}
@Verifies('emitterWritesExactlyOneHandlerFilePerIrLspFeature')
async oneHandlerFilePerFeature(): Promise<void> {
const fs = new InMemoryFileSystem();
const emitter = new LspServerEmitter();
const ir = fixtureIrWithThreeLspFeatures(['diagnostics', 'completion', 'hover']);
await emitter.emit(ir, fs, '/out');
expect(await fs.exists('/out/server/src/handlers/diagnostics.ts')).toBe(true);
expect(await fs.exists('/out/server/src/handlers/completion.ts')).toBe(true);
expect(await fs.exists('/out/server/src/handlers/hover.ts')).toBe(true);
expect(await fs.exists('/out/server/src/handlers/definition.ts')).toBe(false);
expect(await fs.exists('/out/server/src/handlers/references.ts')).toBe(false);
expect(await fs.exists('/out/server/src/handlers/rename.ts')).toBe(false);
}
@Verifies('emittedHandlerFilesContainNoAnalysisIdentifiers')
async handlerFilesAreWiringOnly(): Promise<void> {
const fs = new InMemoryFileSystem();
const emitter = new LspServerEmitter();
const ir = fixtureIrWithThreeLspFeatures(['diagnostics']);
await emitter.emit(ir, fs, '/out');
const diag = await fs.readFile('/out/server/src/handlers/diagnostics.ts');
// The identifier 'analyze' may appear only in import statements or super.analyze calls.
const lines = diag.split('\n');
for (const line of lines) {
if (line.includes('analyze') && !line.startsWith('import ') && !line.includes('super.analyze')) {
expect.fail(`emitted diagnostics.ts contains analysis identifier outside of delegation: ${line}`);
}
}
}
@Verifies('emittedServerPackageJsonDeclaresIdeRuntimeAndLanguageserver')
async emittedServerTypeChecksDependencyShape(): Promise<void> {
const fs = new InMemoryFileSystem();
const emitter = new LspServerEmitter();
const ir = fixtureIrWithThreeLspFeatures(['diagnostics']);
await emitter.emit(ir, fs, '/out');
const pkg = JSON.parse(await fs.readFile('/out/server/package.json'));
expect(pkg.dependencies).toHaveProperty('@frenchexdev/ide-runtime');
expect(pkg.dependencies).toHaveProperty('vscode-languageserver');
}
}Four verifications covering the three AC clusters. The first nails capability derivation as a deterministic projection. The second nails one-file-per-feature including the negative cases — kinds not in the IR do not produce orphan files. The third is the "wiring only" scan enforcing the Requirement's fit criterion literally: a grep for the identifier analyze in positions that are not imports or super-calls. The fourth confirms the emitted package.json declares the runtime dependency — the link that makes the runtime boundary shippable rather than aspirational. Type-checking the emitted server.ts against @types/vscode-languageserver happens in a separate integration test that spawns tsc --noEmit against the emitted tree; it is out of scope for this unit file.
SOLID lens
SRP. The emitter has one responsibility: project ir.lspFeatures into a server/ scaffold. It does not parse, analyze, or dispatch LSP messages at runtime. The runtime package is where analysis lives, the parser module (article 04) is where grammar lives, the manifest emitter (article 05) is where package.json assembly lives.
OCP. Adding a new IRLspFeature.kind — formatting, codeAction, semanticTokens — is exactly three edits: one new row in the capability-mapping table, one new file template in the emitter, one new handler base class in @frenchexdev/ide-runtime. The existing six kinds' emission paths are not touched; the mapping table is the extension point.
LSP (Liskov). DiagnosticsHandler extends DiagnosticsHandlerBase substitutes in every call site expecting a DiagnosticsHandlerBase. The subclass adds no overrides — it only populates protected rules and tokens fields the base reads.
ISP. The runtime exposes one handler-base class per LSP capability — six classes, six narrow interfaces. A language with only diagnostics pays no bundle-size cost for CompletionHandlerBase; unused bases are imported only by handler files the emitter never writes, and tree-shaking removes them.
DIP. The emitted server depends on @frenchexdev/ide-runtime abstractions, not on @frenchexdev/ide-forge internals. The emitter depends on the Emitter interface and FileSystem port (article 04), not on any concrete implementation. Every dependency arrow in the architecture points from concrete to abstract.
DRY lens
Handler logic lives in @frenchexdev/ide-runtime exactly once. Every emitted extension imports the same DiagnosticsHandlerBase, the same CompletionHandlerBase, the same SymbolIndex. A bug fix to the debouncing policy is a runtime semver bump; a hundred generated extensions pick it up at their next npm install. The fit-criterion textual scan in the opening Requirement is how that property is kept load-bearing against the temptation to inline "just this one line".
The IR-to-runtime data bridge is single-sourced: every handler reads RULES, TOKENS, SNIPPETS from the emitted ir-data.ts file, which the emitter writes once per build. No handler file redeclares a rule; no runtime base class hard-codes a language-specific identifier. Cross-link: 05 — DRY and the IR as contract. Article 05 named the IR as the single shape every emitter consumes; this article generalises the claim to a runtime — anything forcing the generator and the generated-at-runtime code to agree on more than a data-only contract is a DRY violation a runtime package should absorb.
Prior art and where this article sits in it
The LSP specification, hosted at microsoft.github.io/language-server-protocol/, was published by Microsoft in 2016 to factor out the N-times-M problem between editors and languages. Before LSP, supporting N editors for M languages required N × M integrations; after LSP, it requires N + M. The Ide.Dsl build takes the factoring one step further down: supporting N languages with M handler kinds required, pre-runtime-package, N × M handler implementations; post-runtime-package, it requires N × M wiring files (trivial) plus one copy of each of the M handler bases (non-trivial, written once). The factoring is the same shape at both levels — and it is why the runtime-boundary split is not an optimisation but an architectural debt repayment.
The vscode-languageserver-node module — Microsoft's canonical Node.js LSP implementation, originally authored by Dirk Bäumer — was the first production LSP server library and remains the reference. Its createConnection / onInitialize / TextDocuments shape has been stable across every LSP protocol revision since 3.0, and the 9.x line currently tracks LSP 3.17. The Ide.Dsl server emitter targets this module directly because every VSCode extension does; there is no alternative LSP runtime for the Node side of a VSCode extension with meaningful traction.
Langium, the TypeScript-first language framework maintained by TypeFox, sits in a similar phase space but makes different boundary choices. Langium is grammar-first — a single .langium file declares tokens, rules, and cross-references, and the framework generates an AST type, a parser, a LangiumServices DI container, and default handler implementations the user can override. The Ide.Dsl emitter is IR-first rather than grammar-first (article 03), and pushes all handler logic into a separately-published runtime rather than into per-extension generated code. The Langium choice optimises for a generated extension that is fully self-contained and buildable offline; the Ide.Dsl choice optimises for a runtime that can upgrade ahead of any single extension's regeneration. Neither is strictly better; the trade-off is visibility of the protocol surface versus ease of runtime upgrade.
Microsoft's own tsserver, the TypeScript language server that predates LSP, is a precedent worth naming for its architectural pattern rather than its protocol choice: tsserver is a shared runtime that every TypeScript-aware editor imports, and the editor-side code is wiring. The Ide.Dsl @frenchexdev/ide-runtime plays the same role one level higher — at the LSP-spec boundary rather than at a bespoke-protocol boundary.
Where the next article picks up
Article 08 picks up with the extension host and client — the activate/deactivate scaffold, the vscode-languageclient boot, and the IR-driven activation events that ensure the extension stays asleep until the user actually touches the language. Packaging (article 13), dog-fooding on packages/requirements/ (article 14), and the retrospective (article 15) close the series; the vsce-packaging emitter is what takes the output of the grammar, manifest, snippet, executor, LSP-server, and extension-host emitters and produces a single .vsix installable in VSCode.
Previous: 06 — Snippets, commands, and a task provider. Series index: Ide.Dsl — Build. Design companion: 05 — DRY and the IR as contract. Earlier: 01, 03, 04, 05.