Files
volar-docs/docs/building-lsp-json-yaml.md
2025-11-09 22:22:52 -06:00

458 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Building a JSON/YAML-Aware LSP with VolarJS
[TOC](#table-of-contents) • [Getting Started](getting-started.md) • [Plugin Authoring](plugin-authoring.md) • [Performance](performance-and-debugging.md)
## Table of Contents
1. [Architecture Snapshot](#architecture-snapshot)
2. [Required Packages](#required-packages)
3. [Step 1 Bootstrap a Volar Server Shell](#step-1--bootstrap-a-volar-server-shell)
4. [Step 2 Wire JSON and YAML Services](#step-2--wire-json-and-yaml-services)
5. [Step 3 Dispatch Requests Inside the LSP Server](#step-3--dispatch-requests-inside-the-lsp-server)
6. [Step 4 Capabilities and Initialization](#step-4--capabilities-and-initialization)
7. [Step 5 LSP-Friendly Performance & Cancellation](#step-5--lsp-friendly-performance--cancellation)
8. [Diagnostics, Quick Fixes, and `$ref` Definitions](#diagnostics-quick-fixes-and-ref-definitions)
9. [Testing the Server](#testing-the-server)
10. [Operational Considerations](#operational-considerations)
11. [Packaging & Distribution](#packaging--distribution)
12. [Next Steps](#next-steps)
This guide walks through composing a custom language server that combines Volars connection/runtime utilities with the existing VS Code JSON and Red Hat YAML services. The goal: a single LSP binary that understands structured config files while leaving room for your own Volar plugins.
## Architecture Snapshot
```
Client (VS Code / Neovim / Monaco LSP) ─▶ @volar/language-server
├─▶ Volar language service (custom plugins)
├─▶ vscode-json-languageservice
└─▶ yaml-language-server (service API)
```
- `@volar/language-server` supplies the transport, file watching, and initialization plumbing.
- The Volar service handles any custom documents you plug in later (left empty in this starter).
- JSON and YAML requests are delegated to their respective language service implementations. You route incoming LSP requests to the right backend based on document URI / languageId.
## Required Packages
```bash
npm install --save-dev \
@volar/language-server \
vscode-json-languageservice \
yaml-language-server
```
Optional helpers:
- `@volar/kit` if you prefer a batteries-included bootstrapper for config + watch.
- `volar-service-<feature>` plugins for any custom diagnostics or editor behaviors you plan to add later.
## Step 1 Bootstrap a Volar Server Shell
```ts
// server/host.ts
import { createConnection, createServer } from '@volar/language-server/node';
import { createSimpleProject } from '@volar/language-server/lib/project/simpleProject';
export const connection = createConnection();
export const server = createServer(connection);
const project = createSimpleProject([]);
const languageServicePlugins = [];
connection.onInitialize((params) => server.initialize(params, project, languageServicePlugins));
connection.onInitialized(() => server.initialized());
connection.onShutdown(() => server.shutdown());
```
- `createSimpleProject([])` wires Volars document tracking and transport without custom language plugins. Swap in your own `LanguagePlugin` array whenever you add domain-specific logic.
- `languageServicePlugins` is where you register Volar service plugins (`volar-service-*`) should you need additional completions, hovers, or refactors for your target language.
## Step 2 Wire JSON and YAML Services
```ts
// server/jsonYaml.ts
import { getLanguageService as createJsonService } from 'vscode-json-languageservice';
import { getLanguageService as createYamlService } from 'yaml-language-server';
export function createJsonYamlServices() {
const json = createJsonService({
schemaRequestService: async (uri) => fetchSchema(uri),
workspaceContext: {
resolveRelativePath(relativePath, resource) {
try {
const base = new URL(resource);
return new URL(relativePath, base).toString();
} catch {
return relativePath;
}
},
},
contributions: [],
});
const yaml = createYamlService({
schemaRequestService: async (uri) => fetchSchema(uri),
workspaceContext: {
resolveRelativePath(relativePath, resource) {
try {
const base = new URL(resource);
return new URL(relativePath, base).toString();
} catch {
return relativePath;
}
},
},
telemetry: { send: () => {}, sendError: () => {}, sendTrack: () => {} },
clientCapabilities: {},
});
return { json, yaml };
}
```
Tips:
- Implement `fetchSchema` to load schemas from HTTP, file system, or an internal registry.
- Cache schemas aggressively; both services expect memoized responses for performance.
- Use `server.workspaceFolders` (see the example) to resolve relative schema paths so `foo/schema.json` can be fetched without hard-coding a root.
## Step 3 Dispatch Requests Inside the LSP Server
```ts
// server/main.ts
import { URI, Utils } from 'vscode-uri';
import { connection, server } from './host';
import { createJsonYamlServices } from './jsonYaml';
import type { TextDocument } from 'vscode-languageserver-textdocument';
const structured = createJsonYamlServices({
resolveWorkspaceUri(relativePath) {
const [root] = server.workspaceFolders.all;
return root ? Utils.joinPath(root, relativePath).toString() : undefined;
},
});
const pendingValidation = new Map<string, ReturnType<typeof setTimeout>>();
const VALIDATION_DELAY = 200;
connection.onInitialized(async () => {
server.initialized();
await applyConfiguration();
server.configurations.onDidChange(applyConfiguration);
server.documents.all().forEach((document) => queueValidation(document.uri));
});
server.documents.onDidOpen(({ document }) => queueValidation(document.uri));
server.documents.onDidChangeContent(({ document }) => queueValidation(document.uri));
server.documents.onDidClose(({ document }) => {
cancelQueuedValidation(document.uri);
connection.sendDiagnostics({ uri: document.uri, diagnostics: [] });
});
// Workspace diagnostics: editors can fetch full workspace snapshots when supported
connection.languages.diagnostics.onWorkspace(async (_params, token) => {
const items = [];
for (const doc of server.documents.all()) {
if (token.isCancellationRequested) break;
items.push({
uri: doc.uri,
kind: DocumentDiagnosticReportKind.Full,
version: doc.version ?? null,
items: await collectDiagnostics(doc),
});
}
return { items };
});
connection.onCompletion(async (params, token) => {
if (token.isCancellationRequested) return null;
const doc = getDocument(params.textDocument.uri);
if (!doc) return null;
const backend = pickBackend(doc);
if (backend === 'json') {
const result = await structured.completeJson(doc, params.position);
return token.isCancellationRequested ? null : result;
}
if (backend === 'yaml') {
const result = await structured.completeYaml(doc, params.position);
return token.isCancellationRequested ? null : result;
}
return null;
});
connection.onHover(async (params, token) => {
if (token.isCancellationRequested) return null;
const doc = getDocument(params.textDocument.uri);
if (!doc) return null;
const backend = pickBackend(doc);
if (backend === 'json') {
const result = await structured.hoverJson(doc, params.position);
return token.isCancellationRequested ? null : result;
}
if (backend === 'yaml') {
const result = await structured.hoverYaml(doc, params.position);
return token.isCancellationRequested ? null : result;
}
return null;
});
async function validate(document: TextDocument) {
const diagnostics = await collectDiagnostics(document);
const latest = getDocument(document.uri);
if (!latest || latest.version !== document.version) {
return;
}
connection.sendDiagnostics({ uri: document.uri, diagnostics });
}
function queueValidation(uri: string) {
cancelQueuedValidation(uri);
const timer = setTimeout(() => {
pendingValidation.delete(uri);
const doc = getDocument(uri);
if (doc) {
validate(doc);
}
}, VALIDATION_DELAY);
pendingValidation.set(uri, timer);
}
function cancelQueuedValidation(uri: string) {
const handle = pendingValidation.get(uri);
if (handle) {
clearTimeout(handle);
pendingValidation.delete(uri);
}
}
async function collectDiagnostics(document: TextDocument) {
const backend = pickBackend(document);
if (backend === 'json') {
return structured.validateJson(document);
}
if (backend === 'yaml') {
return structured.validateYaml(document);
}
return [];
}
function getDocument(uri: string): TextDocument | undefined {
return server.documents.get(URI.parse(uri)) as TextDocument | undefined;
}
```
> Tip: This snippet imports `DocumentDiagnosticReportKind` from `vscode-languageserver/node` to mark each workspace diagnostic report as `Full`.
Routing Strategy:
1. Check `textDocument.languageId` (if the client sets it) and fall back to file extension.
2. If neither JSON nor YAML match, forward the request to Volar.
3. For JSON/YAML services, you must manage document snapshots—use `connection.workspace.getTextDocument(uri)` or maintain a `TextDocuments` instance to keep their APIs synchronized.
## Step 4 Capabilities and Initialization
Make sure the combined server advertises the correct capabilities:
```ts
connection.onInitialize(() => ({
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: { triggerCharacters: ['.', '"', '/', '<', ':'] },
hoverProvider: true,
documentFormattingProvider: true,
schemaRequestService: true,
},
}));
```
- JSON/YAML features may have narrower trigger characters; merge the superset so clients activate correctly.
- Expose custom `initializationOptions` to toggle schemas, plugin sets, or Take Over Mode.
> **Example project:** `examples/json-yaml-lsp/` contains a runnable implementation that wires the same services using the bare `@volar/language-server` utilities so you can attach any editor client immediately.
## Step 5 LSP-Friendly Performance & Cancellation
To stay responsive on large workspaces:
```ts
const pendingValidation = new Map<string, ReturnType<typeof setTimeout>>();
const VALIDATION_DELAY = 200;
function queueValidation(uri: string) {
cancelQueuedValidation(uri);
const timer = setTimeout(async () => {
pendingValidation.delete(uri);
const document = getDocument(uri);
if (document) {
await validateDocument(document);
}
}, VALIDATION_DELAY);
pendingValidation.set(uri, timer);
}
connection.onCompletion(async (params, token) => {
if (token.isCancellationRequested) return null;
const document = getDocument(params.textDocument.uri);
if (!document) return null;
const result = await structured.completeJson(document, params.position);
return token.isCancellationRequested ? null : result;
});
server.documents.onDidClose(({ document }) => {
cancelQueuedValidation(document.uri);
connection.sendDiagnostics({ uri: document.uri, diagnostics: [] });
});
async function applyConfiguration() {
const config =
(await server.configurations.get<JsonYamlConfiguration>('volarJsonYaml', undefined)) ?? {};
structured.configure(config);
await server.languageFeatures.requestRefresh(false);
server.documents.all().forEach((document) => queueValidation(document.uri));
}
```
- **Debounce validations** so rapid edits dont trigger repeated schema checks.
- **Gate diagnostics by version** (after awaiting async work, re-fetch the document and compare `version` before sending).
- **Honor cancellation tokens** on hover/completion so abandoned requests dont waste CPU.
- **Clear diagnostics on close** to avoid stale warnings when a document leaves the workspace.
- **Reflect configuration changes** via `server.configurations.get/onDidChange` and call `server.languageFeatures.requestRefresh` so clients re-query capabilities when settings toggle.
- The sample exports a `JsonYamlConfiguration` helper type so the configuration payload is strongly typed on both server and JSON/YAML helper layers.
- **Workspace diagnostics**: register `connection.languages.diagnostics.onWorkspace` so editors that support the capability (VS Code Insiders, Neovim, etc.) can request batched diagnostics across every open file.
- Clients can provide that payload via normal LSP configuration plumbing (e.g., VS Code settings):
```json
{
"volarJsonYaml": {
"json": {
"schemas": [{ "fileMatch": ["foo.json"], "url": "./schemas/foo.schema.json" }]
},
"yaml": {
"customTags": ["!secret scalar"]
}
}
}
```
The sample project implements the same helpers so you can copy the exact patterns.
## Diagnostics, Quick Fixes, and `$ref` Definitions
With the wiring above you can progressively light up richer language features:
```ts
connection.onCodeAction(async (params, token) => {
if (token.isCancellationRequested) return null;
const doc = getDocument(params.textDocument.uri);
if (!doc) return null;
if (pickBackend(doc) === 'json') {
// Offers “Add $schema reference” quick fix whenever the root object lacks $schema.
return await structured.codeActionsJson(doc, params);
}
if (pickBackend(doc) === 'yaml') {
return await structured.codeActionsYaml(doc, params);
}
return null;
});
connection.onDefinition(async (params, token) => {
if (token.isCancellationRequested) return null;
const doc = getDocument(params.textDocument.uri);
if (!doc) return null;
return pickBackend(doc) === 'json'
? structured.definitionJson(doc, params.position) // navigates $ref targets, component refs, etc.
: structured.definitionYaml(doc, params.position);
});
```
- **Diagnostics events** still flow through `validateDocument`, but now the handlers above can surface follow-up quick fixes (e.g., add a `$schema`, insert missing YAML keys) tied to the same document.
- **Auto-fix suggestions** leverage the YAML services built-in `getCodeAction` plus the JSON helpers custom fix builder so editors can surface actionable “Quick Fix…” entries.
- **Hover context** already goes through the JSON/YAML services, so schema descriptions and enum docs populate tooltips automatically.
- **Definition requests** reuse `findDefinition` / `doDefinition`, which means `$ref` values in JSON or anchors/aliases in YAML jump straight to their targets.
## Testing the Server
1. Use the `vscode-languageclient` sample extension to spin up the server and ensure `.json` and `.yaml` each get the expected diagnostics.
2. Add fixtures under `examples/fixtures/` (one per file type) and run automated LSP probes via `vscode-languageserver/node`s `createConnection` in tests.
3. Validate schema fetching offline by stubbing the request service.
## Best Practices Checklist (Apply All of Them)
1. **Single source of truth for documents** always read file content through Volars `server.documents` API so completions, hovers, diagnostics, and code actions see the same in-memory snapshot.
2. **Cache aggressively** schema downloads, JSON document parses, and YAML ASTs should be re-used whenever possible. The samples `cacheSchemas` + `getJsonDocument` are patterns to copy.
3. **Respect cancellation tokens everywhere** every `connection.on*` handler should short-circuit when `token.isCancellationRequested` is true, even after `await`.
4. **Debounce diagnostics** never bombard the client with validation spam. Use the `queueValidation` helper in the sample as a template for other expensive tasks.
5. **Version-gate publishDiagnostics** after running async work, re-fetch `server.documents.get()` and compare `version` before calling `sendDiagnostics`.
6. **Clear diagnostics on close** send `[]` when a document is closed so stale warnings vanish from the Problems panel.
7. **Implement workspace diagnostics** if the client supports `workspace/diagnostic`, return a `WorkspaceFullDocumentDiagnosticReport` for each document; otherwise rely on standard document-level `textDocument/publishDiagnostics`.
8. **Single queue for configuration** keep a shared `applyConfiguration` function that reconfigures your JSON/YAML helpers, requests a capability refresh, and re-validates open documents. Guard it with throttling if your editor emits frequent configuration updates.
9. **Expose strongly typed settings** the `JsonYamlConfiguration` interface keeps JSON/YAML configuration honest; use it end-to-end so TypeScript catches mismatched schema settings.
10. **Honor schema-relative resolution** combine `server.workspaceFolders` with `Utils.joinPath` so workspace-relative schema references resolve consistently between CLI, VS Code, and other editors.
### Diagnostics Lifecycle
- **Collect once, reuse everywhere** the `collectDiagnostics` helper powers both `publishDiagnostics` and the workspace handler, so the behavior is identical regardless of how the client asks for diagnostics.
- **Workspace diagnostics** always return `WorkspaceDocumentDiagnosticReport` objects with `kind: DocumentDiagnosticReportKind.Full` (or `Unchanged` if you implement result IDs). Include `version` so incremental clients know which snapshot produced the report.
- **Auto-fixes** pair every diagnostic family with at least one actionable code action. The sample shows how to generate a “Add $schema” quick fix for JSON while delegating YAML fixes to the upstream service.
### Hover/Completion Detail
- Let schema-driven services provide the content, but normalize the results if needed (e.g., strip fenced code blocks, deduplicate markdown). Volar encourages leaving formatting to the service so you get free upgrades as those services improve.
- Remember to include trigger characters that match your schemas syntax (`"`, `<`, `/`, etc.) so completions fire exactly when the client expects them.
### Definitions & `$ref` Intellisense
- Forward `textDocument/definition` to both JSON and YAML services—those implementations already understand `$ref`, component references, and YAML anchors.
- Always return `DefinitionLink[]` rather than raw `Location[]`, so editors that support peek views can highlight the exact range of the definition.
### Code Actions + Quick Fixes
- Respect the clients `context.only` filter when building actions. The sample JSON quick fix bails out unless `CodeActionKind.QuickFix` is requested.
- Provide descriptive titles that match the editor UX. “Add $schema reference” reads better than “Insert snippet”.
### Schema Strategy
- Treat schema URIs the same way the CLI would: handle `http(s)://`, `file://`, and workspace-relative paths. The `resolveWorkspaceUri` callback plus `normalizeUri` implementation show how to cover every case.
- Expose per-folder overrides with globbing (e.g., `config/*.json`) and keep defaults in `volarJsonYaml.json.defaultSchemaUri`.
### Testing & Observability
- Add an integration test harness that spins up the LSP server with `vscode-languageserver/node` and issues representative LSP requests (hover, completion, diagnostics, workspace/diagnostic). Compare the responses to snapshots.
- Log schema fetches and large diagnostics only when `process.env.VOLAR_DIAGNOSTICS_DEBUG` (or similar) is enabled to avoid spamming the client in normal operation.
- Implement `connection.telemetry.logEvent` for significant events (schema download failures, YAML validation errors) so consuming editors can surface them in their telemetry pipelines if desired.
### Performance Knobs to Keep Handy
- `VALIDATION_DELAY` tune this constant per project; increase it for massive monorepos, decrease it for tiny repos.
- `pendingValidation` map always cancel old timers when new edits arrive to avoid stacking multiple validations for the same document.
- `applyConfiguration` wrap the internals in a try/catch that logs configuration errors and falls back to the previous known-good settings so a malformed config doesnt brick the server mid-session.
## Operational Considerations
- **Performance**: JSON and YAML services are CPU-light compared to Vue. Keep them stateless and reuse schema caches to avoid blocking Volars TypeScript workers.
- **Telemetry**: The YAML service expects a telemetry sink; provide a no-op logger or hook into your analytics pipeline.
- **Take Over Mode**: If your editor uses Take Over Mode (Volar replacing the default TS server), ensure the JSON/YAML portions do not register duplicate document selectors.
- **Schema Distribution**: For enterprise deployments, serve schemas from a local file share or CDN and pass that base URL through `initializationOptions`.
## Packaging & Distribution
1. **Editor extension** bundle the server script with a VS Code/Neovim/Sublime extension and launch it over stdio (`node dist/server.js`). The sample projects `npm run build` output is ready for this flow.
2. **Single binary** tools like `pkg`, `nexe`, or `esbuild --platform=node` can squash the server (and schemas) into an executable for locked-down environments. Keep schema fetching pluggable so air-gapped installs can point to `file://` URIs.
3. **Hosted worker** for web editors (Monaco, CodeSandbox) run the server logic inside a worker using `@volar/monaco` plus the same JSON/YAML services, forwarding messages over `postMessage`.
4. **CLI utility** expose a CLI command (e.g., `volar-json-yaml check path/to/workspace`) that spins up the same JSON/YAML helpers without the LSP transport for CI validation; reuse the shared connection setup from the sample.
Whichever path you choose, keep the example project as a reference implementation so contributors can run `npm install && npm run dev` to replicate issues or experiment with new plugins.
## Next Steps
1. Package the server with `pkg`/`nexe` or ship it via an editor extension.
2. Layer additional `volar-service-*` plugins (e.g., Tailwind, Prettier) as needed.
3. Add integration tests that open mixed-workspace folders to confirm routing stays correct.
With this pattern you can keep leveraging Volars Vue expertise while borrowing the battle-tested JSON/YAML implementations that already exist—no need to reinvent highlight/completion logic for those file types.