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).
| Layer | Responsibility | Key files |
|---|---|---|
| Transport | Own the LSP connection, lifecycle events, capability advertisement, and config. | server.ts |
| Request routing | Translate each LSP request into a service call; cache the parsed document per version. | requestHandlers.ts |
| Service façade | A single object exposing every capability; the seam the rest of the app depends on. | xmlLanguageService.ts |
| Parsing | Turn text into a concrete syntax tree (@xml-tools) then a friendly AST. | parser/ |
| Features | Stateless functions, one per LSP feature, operating on the AST + a completion provider. | services/ |
| Schema | Decide which XSD applies, compile/cache validators, build completion models. | schema/ |
| Diagnostics | Orchestrate 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:
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:
- 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.
- 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:
-
Pre-Pass (
prePass): Scans the top-level XSD nodes to extract and cache reusable groups (xs:attributeGroupandxs:group) in temporary maps. This allows the parser to instantly expand group contents when referenced later viaref="...". -
Main Walk (
mainWalk): Recursively walks the XSD CST. For eachxs:element, it registers anElementInfoentry, parses itsxs:documentationannotations, and recursively maps inline attributes. If the element specifies a type (e.g.,type="LogMediator"), it records the reference for post-walk resolution. Namedxs:complexTypedefinitions are also parsed and cached during this walk. -
Reference Resolution (
resolveReferences): Resolves type pointers by looking up the cached properties of the referencedxs:complexTypedefinitions 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:
- It registers the element
login the final output registry and records its type link inelementTypeRefs:data.elements.set("log", { name: "log", attributes: [], children: [] }); elementTypeRefs.set("log", "LogMediator"); - It visits the complexType definition
LogMediator. It extracts its nested child names (["property"]) and attributes (direct attributelevel+ expanded group attributedescriptionfrom 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 (log → LogMediator) 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:
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
| Diagnostics | Completion / hover | |
|---|---|---|
| Engine | Xerces (WASM) ProjectValidator | hand-written XsdCompletionProvider |
| Schema files from | buildFilesMap() — whole folder | loadReferencedXsds() — follows refs |
| Source of truth? | yes, for correctness | independent 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.
| Cache | Key | Lives in | Cleared by |
|---|---|---|---|
| Parsed AST | uri + version | requestHandlers | document close |
projectValidators (WASM) | xsdPath | DiagnosticsHandler | dispose() |
validators (XsdValidatorService) | xsdPath ?? uri | SchemaProvider | invalidate / dispose |
completionProviders | auto://uri or path|ns | SchemaProvider | invalidateAutoSchemas |
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 compiledXsdValidatorServicefor 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(inDiagnosticsHandler) — "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 — sovalidatorsandprojectValidatorsshare 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:
- Dual engine & dual schema-loading (above) — one
SchemaResolverfeeding both paths would collapse this. - DiagnosticsHandler does too much — ~half of its 453 lines (
loadReferencedXsds,buildFilesMap,findSchemaLocations,resolveLocalReference) is schema-graph crawling that belongs inschema/. - Dead-weight validator — an
XsdValidatorServiceis instantiated for every registered schema inregisterSchema, but itsvalidate()is only reached on a fallback path that almost never fires (built-ins/user schemas always carry anxsdPath, so diagnostics go through the WASMProjectValidatorinstead). Cheap to create, but it's an unused branch carrying its own re-parsing cost if ever hit. - No-fallback resolution — if a user association matches but its XSD can't be read,
findSchemareturnsnullinstead of falling through to the built-in schema, killing all validation for that file. - Namespace fallback depends on parser support — diagnostics asks for
getNamespace?.(), but the currentXMLDocumentImpldoes not expose that method. Pattern-based associations still work. - Two config shapes —
{pattern, schema:"430"}(init options) and{pattern, xsdPath}(settings) take two code paths to the same destination.