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 (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:
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.