WSO2 MI Language Server
/ Architecture
Deep Dive

Architecture

How the pieces fit, why the schema subsystem is shaped the way it is, and where the design tensions live.

The layers

The macro-structure is a clean, idiomatic LSP stack. Each layer only talks to the one beneath it, with two exceptions noted later (diagnostics and the WASM module).

LayerResponsibilityKey files
TransportOwn the LSP connection, lifecycle events, capability advertisement, and config.server.ts
Request routingTranslate each LSP request into a service call; cache the parsed document per version.requestHandlers.ts
Service façadeA single object exposing every capability; the seam the rest of the app depends on.xmlLanguageService.ts
ParsingTurn text into a concrete syntax tree (@xml-tools) then a friendly AST.parser/
FeaturesStateless functions, one per LSP feature, operating on the AST + a completion provider.services/
SchemaDecide which XSD applies, compile/cache validators, build completion models.schema/
DiagnosticsOrchestrate validation: resolve schema, load the schema graph, run Xerces, map errors.diagnosticsHandler.ts

Why this is good: feature providers are small and single-purpose, the façade is a real seam (you could swap the transport without touching features), and request handlers are thin. The bones are right.

The schema subsystem

This is where the interesting design decisions live. Three collaborators:

flowchart LR subgraph SP["SchemaProvider — the registry"] V["validators Map
xsdKey → XsdValidatorService"] D2S["documentToSchema Map
docUri → xsdKey"] CPS["completionProviders Map
uri/key → XsdCompletionProvider"] end SA["SchemaAssociator
findSchema(file, ns, path)"] CP["XsdCompletionProvider
parses XSD → element model"] XV["XsdValidatorService
wraps WASM validate()"] SP --> SA SP --> CP SP --> XV style SP fill:#161b22,stroke:#ff7300,color:#f0f6fc

SchemaAssociator — "which XSD?"

Maps a document to an XSD by two rules, in strict priority:

  1. User associations (from settings / init options) matched by glob pattern on the file path. Init options use bundled 430/440 schemas but are still registered as user associations.
  2. Built-in associations matched by XML namespace (the Synapse namespace → bundled synapse_config.xsd) only when no user pattern matched.

In the usual MI setup, path patterns choose the schema version. Namespace matching is a fallback/default route, not the primary 430/440 selection mechanism.

SchemaProvider — the registry

Holds three maps (see Caches & keys). It keeps one XsdValidatorService wrapper per unique XSD key and reuses it across every document that resolves to the same schema, and it builds the completion model from the fully-inlined XSD (via inlineIncludes()) so xs:included types are available. (The heavy grammar compilation actually happens later, inside the WASM ProjectValidator that DiagnosticsHandler owns — the wrapper itself just stores the XSD text.)

XsdCompletionProvider — the completion brain

A hand-written XSD reader (462 lines) that parses element/attribute declarations out of the XSD text and answers "what can go here?" for completion and hover.

The two-engine model important

This is the single most important thing to internalise. XSD semantics are interpreted twice, by two unrelated implementations, on two different paths:

flowchart TB DOC["📄 open .xml document"] DOC --> R["resolveSchemaForDocument()"] R --> PATHA R --> PATHB subgraph PATHA["1 Diagnostics path"] direction TB BFM["buildFilesMap()
walk whole folder,
grab every .xsd/.dtd
"] PV["ProjectValidator
(Xerces WASM)"] DG["diagnostics"] BFM --> PV --> DG end subgraph PATHB["2 Completion / hover path"] direction TB LRX["loadReferencedXsds()
follow schemaLocation
references recursively
"] XCP["XsdCompletionProvider
(hand-written reader)"] CO["completions / hover"] LRX --> XCP --> CO end style PATHA fill:#13241a,stroke:#3fb950,color:#f0f6fc style PATHB fill:#1a1726,stroke:#bc8cff,color:#f0f6fc style PV fill:#161b22,stroke:#3fb950 style XCP fill:#161b22,stroke:#bc8cff
Same document, two engines, two different file-gathering strategies.
DiagnosticsCompletion / hover
EngineXerces (WASM) ProjectValidatorhand-written XsdCompletionProvider
Schema files frombuildFilesMap() — whole folderloadReferencedXsds() — follows refs
Source of truth?yes, for correctnessindependent model

Consequence: the two engines can disagree — completion may offer an element Xerces then flags as invalid. The hover handler papers over this with a hasErrorAt() check that suppresses hover wherever a diagnostic already exists (requestHandlers.ts:72). That check is a symptom of the dual-engine split, not a feature.

Why it exists: Xerces gives authoritative validation but no ergonomic API for "what completions are valid here?". The hand-written reader fills that gap. The pragmatic split works — but it's the place future bugs will concentrate.

Caches & keys

Four caches exist, keyed differently. Knowing the keys explains most of the behaviour.

CacheKeyLives inCleared by
Parsed ASTuri + versionrequestHandlersdocument close
projectValidators (WASM)xsdPathDiagnosticsHandlerdispose()
validators (XsdValidatorService)xsdPath ?? uriSchemaProviderinvalidate / dispose
completionProvidersauto://uri or path|nsSchemaProviderinvalidateAutoSchemas

Concretely, for a single open document those caches hold:

documentToSchema"which schema does this document use?" (per-document)
"auto://…/test.xml""/resources/schemas/440/synapse_config.xsd"
validators"the compiled XsdValidatorService for this schema" (per-schema, fallback diagnostics path)
"/…/440/synapse_config.xsd"<compiled validator>
completionProviders"autocomplete / hover engine for this schema" (per-schema)
"/…/440/synapse_config.xsd"<completion provider>
projectValidators (in DiagnosticsHandler) — "the Xerces-WASM validator for this schema; primary diagnostics path" (per-schema)
"/…/440/synapse_config.xsd"<project validator>

Note: keyed by the XSD file path (xsdPath), not the schema folder. The folder ("/…/440") is only used to gather the files map for WASM, not as the cache key — so validators and projectValidators share the same per-schema granularity.

Fragility: the auto://<path> URI is a synthetic key invented by DiagnosticsHandler purely to register completion schemas inside SchemaProvider. The completion cache is keyed inconsistently (auto:// sometimes, path|ns other times), so the layers are coupled by a string convention rather than a typed contract.

Trade-offs & known smells

The architecture ships and all tests pass. These are the spots to watch, in rough priority:

  1. Dual engine & dual schema-loading (above) — one SchemaResolver feeding both paths would collapse this.
  2. DiagnosticsHandler does too much — ~half of its 453 lines (loadReferencedXsds, buildFilesMap, findSchemaLocations, resolveLocalReference) is schema-graph crawling that belongs in schema/.
  3. Dead-weight validator — an XsdValidatorService is instantiated for every registered schema in registerSchema, but its validate() is only reached on a fallback path that almost never fires (built-ins/user schemas always carry an xsdPath, so diagnostics go through the WASM ProjectValidator instead). Cheap to create, but it's an unused branch carrying its own re-parsing cost if ever hit.
  4. No-fallback resolution — if a user association matches but its XSD can't be read, findSchema returns null instead of falling through to the built-in schema, killing all validation for that file.
  5. Namespace fallback depends on parser support — diagnostics asks for getNamespace?.(), but the current XMLDocumentImpl does not expose that method. Pattern-based associations still work.
  6. Two config shapes{pattern, schema:"430"} (init options) and {pattern, xsdPath} (settings) take two code paths to the same destination.