Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 20 — The LSP host: composing micro-DSLs server-side

Part IV opens with the first of three composition surfaces. Part III gave the suite fourteen micro-DSLs, each owning one VSCode capability. Most of them produce server-side outputs — LSP handler registrations, contribution declarations, classifier callbacks — that need an LSP server to be useful. The LSP host is that server: a single Node process that imports every relevant micro-DSL package, queries each for its contributions, and routes incoming LSP requests to the right contributions in the right order.

The host depends on no concrete micro-DSL by name in its public types. It depends on the contribution interfaces declared in the kernel — MicroDslContribution, LspCapabilityContribution, LspHandler<T> — and discovers the concrete contributions at startup through standard Node module resolution. Adding a fifteenth micro-DSL to a consumer's project is adding the package to package.json and re-running the host's contribution discovery; no host code changes. This is the article 03 Dependency Inversion Principle held strictly, and it is what makes the suite composable rather than just modular.

What the host does

The host's responsibilities, in order of execution:

  1. Discover contributions. At startup, walk the consumer's package.json for @frenchexdev/ide-dsl-* dependencies, dynamically import each, collect the exported contribution value, validate it against the kernel's contribution schema. Build a typed registry indexed by LSP capability.
  2. Negotiate capabilities. Construct the ServerCapabilities object from the union of every contribution's declared capabilities — completionProvider, hoverProvider, definitionProvider, documentSymbolProvider, the rest. Send to the client as the initialize response.
  3. Dispatch requests. For each incoming LSP request — textDocument/completion, textDocument/hover, textDocument/publishDiagnostics, and so on — look up matching contributions in the registry, invoke them in registration order, merge results according to the per-capability merge rule.
  4. Maintain the kernel state. On textDocument/didOpen, didChange, didSave, didClose, update the kernel's document store and the workspace index. The micro-DSL contributions are pure functions over kernel state; the host owns the state.

The four responsibilities are deliberately small. The host is the router; the micro-DSLs are the handlers. Replacing the host with an alternative implementation (one that targets a different LSP version, one that runs in a browser-based language server) is a host change; the micro-DSLs do not move.

Dispatch in detail

Each LSP capability has a fixed merge rule. The merge rules:

  • Completion (textDocument/completion) — concatenate items from every contribution; sort by the sortText field; deduplicate by label; truncate at the client's reported maximum.
  • Hover (textDocument/hover) — first non-null result wins; contributions are queried in registration order.
  • Diagnostics (textDocument/publishDiagnostics) — concatenate diagnostics from every contribution; deduplicate by (range, code).
  • CodeLens (textDocument/codeLens) — concatenate from every contribution; the LSP host adds no merge logic beyond union.
  • DocumentSymbol (textDocument/documentSymbol) — first non-null result wins, unless multiple contributions claim symbol authority for the same language (in which case the host raises a build-time error rather than silently picking one).
  • Formatting (textDocument/formatting) — exactly one contribution per language must claim formatting authority; the host enforces this.
  • Rename (textDocument/rename) — same: exactly one contribution per language must claim rename authority.
  • CodeAction (textDocument/codeAction) — concatenate from every contribution; the client selects.

The merge rules are stable; they live in the kernel as part of the contribution schema. A new capability requires a kernel change; an existing capability's merge rule does not change without a major kernel version.

A worked example: completion request

The user types @Satisfies( in a .req.ts file. The completion request flow:

  1. VSCode sends textDocument/completion with the cursor position.
  2. The LSP host looks up registered Completion contributions: it finds the Completion micro-DSL (article 08) plus the Snippets micro-DSL (article 09) — both register completion handlers, each handles a different concern.
  3. The host invokes both, in parallel:
    • The Completion micro-DSL's handler walks the kernel context, recognises the @Satisfies( trigger pattern, queries the workspace index for Requirement subclasses, returns one item per match.
    • The Snippets micro-DSL's handler checks if any of its prefixes match the partial text before the cursor; in this case none does (the partial text is empty after the trigger character), so it returns an empty array.
  4. The host concatenates the two arrays, sorts by sortText, returns to VSCode.
  5. VSCode renders.

Three properties of the trace deserve naming:

  • The host invoked exactly the two micro-DSLs that registered for the capability, with no per-request configuration. The registry is built at startup; dispatch is a registry lookup.
  • Each micro-DSL ran independently, with no awareness of the other. The Completion micro-DSL did not know Snippets existed; Snippets did not know Completion existed. The merge happened in the host.
  • The host added no completion logic of its own. It owns the dispatch and the merge; it does not author items.

Performance characterisation

The fan-out cost — how many micro-DSLs the host invokes per request — is bounded by the number of registered contributions per capability. For the Requirements IDE composition (article 22), that number is small per capability (one or two per LSP capability). For a richly composed IDE (a hypothetical CMF IDE bundling Requirements + Workflow + Pages + Admin DSLs), the number grows but is still bounded by the capability count.

The latency budget per LSP request is conventional: 100ms median for textDocument/completion, 200ms for textDocument/hover, 500ms for textDocument/publishDiagnostics (because diagnostics walk the entire document). Each contribution is allocated a fraction of this budget proportional to the registered count; contributions that exceed their budget are reported through the host's diagnostic channel as performance warnings (not LSP diagnostics — out-of-band logging).

The kernel's workspace index, populated by Symbols (article 16) and consumed by Hover, CodeLens, Refactoring, and the workspace-symbol handler, is the most performance-sensitive shared resource. It is incrementally maintained through EditLog subscription; full rebuilds happen only on first activation or on workspace-trust changes.

The DIP guarantee in code

The host's import surface, distilled:

// host/src/main.ts (sketch)
import {
  contributionRegistry,
  type LspCapabilityContribution,
} from '@frenchexdev/ide-dsl-kernel';
import { connection } from 'vscode-languageserver/node';

async function activate() {
  const contributions = await contributionRegistry.discoverFromPackageJson();
  const capabilities = mergeCapabilities(contributions);

  connection.onInitialize(() => ({ capabilities }));

  connection.onCompletion(async (params) => {
    const matching = contributionRegistry.completionContributions(params);
    const items = await Promise.all(matching.map(c => c.handle(params)));
    return mergeCompletion(items.flat());
  });

  // ... same shape for the other capabilities ...
}

The host imports two packages by name: the kernel and vscode-languageserver. It does not import any micro-DSL by name — it discovers them through discoverFromPackageJson, which walks the consumer's installed packages for the @frenchexdev/ide-dsl-* namespace. Adding a new micro-DSL is adding it to the consumer's package.json; the host code is unchanged.

This is the only place in the entire suite where dynamic dependency discovery happens. Every other layer has explicit static imports; the host's discovery is the architectural seam where consumers compose.

What this article verifies

This article verifies the four acceptance criteria of FEAT-MICRODSL-20 declared in assets/features.ts:

  • microDslAggregationContractStated — the What the host does and Dispatch in detail sections name the contribution registry, the per-capability merge rules, the dispatch flow.
  • noConcreteMicroDslDependencyShown — the DIP guarantee in code section shows the host's two-package import surface and the discovery mechanism.
  • handlerRoutingDerivedFromMicroDslRegistry — the worked completion example traces a request from VSCode to two micro-DSLs to merged response.
  • performanceCharacterisationOfFanOutStated — the Performance characterisation section names the latency budgets, the fan-out bound, and the index as the shared performance-sensitive resource.

What article 21 picks up

Article 21 takes the same composition discipline to the client side: the Custom Editor host that mounts WebViews, bridges the PatchBus across the WebView boundary, and composes Projection micro-DSL contributions into a multi-notation editor for any DSL. The architectural shape — discover contributions, dispatch, merge, no concrete dependency — is identical; the rendering technology (Vite + React in a WebView) is what differs.

⬇ Download