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 (496 lines) that parses element/attribute declarations out of the XSD text and answers "what can go here?" for completion and hover. It compiles XSD rules into a flat lookup registry (Map<string, ElementInfo>) using a three-phase pipeline inside XsdCstParser:

  1. Pre-Pass (prePass): Scans the top-level XSD nodes to extract and cache reusable groups (xs:attributeGroup and xs:group) in temporary maps. This allows the parser to instantly expand group contents when referenced later via ref="...".
  2. Main Walk (mainWalk): Recursively walks the XSD CST. For each xs:element, it registers an ElementInfo entry, parses its xs:documentation annotations, and recursively maps inline attributes. If the element specifies a type (e.g., type="LogMediator"), it records the reference for post-walk resolution. Named xs:complexType definitions are also parsed and cached during this walk.
  3. Reference Resolution (resolveReferences): Resolves type pointers by looking up the cached properties of the referenced xs:complexType definitions and merging them directly into the target elements.

Detailed Example: Let's trace how the parser processes this sample XSD snippet:

<!-- Reusable attribute group -->
<xs:attributeGroup name="commonAttrs">
  <xs:attribute name="description" type="xs:string" />
</xs:attributeGroup>

<!-- Target element definition -->
<xs:element name="log" type="LogMediator" />

<!-- Type declaration -->
<xs:complexType name="LogMediator">
  <xs:sequence>
    <xs:element ref="property" />
  </xs:sequence>
  <xs:attribute name="level" type="xs:string" />
  <xs:attributeGroup ref="commonAttrs" />
</xs:complexType>
Phase 1: Pre-Pass (Caching Groups)

The parser scans the XSD root tags and hits <xs:attributeGroup name="commonAttrs">. It registers its internal attributes and stores them in the rawGroups map:

rawGroups.set("commonAttrs", {
  attrs: [{ name: "description", type: "xs:string", required: false }],
  refs: []
});
Phase 2: Main Walk (Tree Traversal & Element Stubs)

The parser walks the XSD tree recursively:

  1. It registers the element log in the final output registry and records its type link in elementTypeRefs:
    data.elements.set("log", { name: "log", attributes: [], children: [] });
    elementTypeRefs.set("log", "LogMediator");
  2. It visits the complexType definition LogMediator. It extracts its nested child names (["property"]) and attributes (direct attribute level + expanded group attribute description from Phase 1), storing them in temporary maps:
    complexTypeChildren.set("LogMediator", ["property"]);
    complexTypeAttributes.set("LogMediator", [
      { name: "level", type: "xs:string", required: false },
      { name: "description", type: "xs:string", required: false }
    ]);
Phase 3: Reference Resolution (Merging & Compiling)

After the walk completes, the parser resolves the type link (logLogMediator) by pulling children and attributes from the complexType cache and merging them directly into the log element's record:

// Final compiled state in data.elements:
data.elements.get("log") == {
  name: "log",
  children: ["property"],
  attributes: [
    { name: "level", type: "xs:string", required: false },
    { name: "description", type: "xs:string", required: false }
  ]
};

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.