12 KiB
Authoring Volar Plugins & Language Services
Volar’s architecture is deliberately modular: language plugins transform source files into virtual code, while language-service plugins add editor-facing features such as completions, hovers, diagnostics, and code actions. This guide documents every hook, option, flag, and best practice so you can author custom functionality with confidence.
Mental Model
Source file ──▶ Language plugin (virtual code + source maps) ──▶ Language service plugin (capabilities) ──▶ Host (LSP, Monaco, CLI)
- Language plugins live in
@volar/language-core. They describe how a file decomposes into embedded scripts (e.g., templates, styles) and how generated code maps back to the original source. - Language service plugins live in
@volar/language-service. They consume the virtual code graph and implement hover/completion/diagnostics, etc. - TypeScript plugins (part of
@volar/typescript) bridge Volar’s virtual code into the TS language service so TypeScript-based tooling understands your files.
Language Plugin API (@volar/language-core)
import type { LanguagePlugin, VirtualCode, CodegenContext, IScriptSnapshot } from '@volar/language-core';
const myLanguagePlugin: LanguagePlugin = {
getLanguageId(scriptId) {
if (scriptId.endsWith('.foo')) return 'foo';
},
createVirtualCode(scriptId, languageId, snapshot, ctx) {
const code = transformFooToTs(snapshot.getText(0, snapshot.getLength()));
return {
id: scriptId + '.ts',
languageId: 'typescript',
snapshot: createSnapshot(code),
mappings: buildMappings(snapshot, code),
embeddedCodes: [],
};
},
updateVirtualCode(scriptId, previous, newSnapshot, ctx) {
// Optional incremental update. Return undefined to fallback to createVirtualCode.
},
disposeVirtualCode(scriptId, virtualCode) {
virtualCode.snapshot.dispose?.();
},
isAssociatedFileOnly(scriptId) {
// Return true if the file exists purely to feed generated code (rare).
},
};
Key Fields
id: unique identifier for the virtual code (commonly${sourceId}.${ext}).languageId: logical language (e.g.,typescript,html,json). Hosts use this to decide which plugins run.snapshot: anIScriptSnapshotdescribing the generated text. Usets.ScriptSnapshot.fromStringor a custom snapshot implementation.mappings: array ofCodeMappingentries describing how generated ranges map back to source ranges.embeddedCodes: nested virtual codes (e.g., template -> script -> CSS). Walk them recursively so downstream plugins see the full graph.linkedCodeMappings: optional cross-file relationships (e.g., macro expansions). Populate when features like rename need multi-file context.
Mapping Flags (CodeInformation)
Each mapping entry can enable/disable specific language features:
verification: allow diagnostics/code actions. Usefalsefor generated scaffolding you don’t want to expose.completion: mark sections as completion targets. UseonlyImportfor import-only completions.semantic: enable hover/inlay highlight generation.navigation: rename/reference/definition support.structure: drive folding/outline.format: allow formatting engines to reflow the generated region.
Best practice: map only the user-authored slices of generated code. If a block of your generated TS is purely synthetic, set verification: false and omit navigation so diagnostics and rename never escape into that region.
TypeScript Bridge (@volar/typescript)
If you need TypeScript to understand your virtual files:
import { createLanguageServiceHost } from '@volar/typescript';
const { languageServiceHost, getExtraServiceScript } = createLanguageServiceHost(
ts,
sys,
language,
(fileName) => fileName as URI,
projectHost,
);
- Extra service scripts: use
languagePlugin.typescript?.getServiceScriptto expose additional virtual files (e.g., template-extracted scripts) directly to the TS service. - Incremental programs:
createTypeScriptProject(from@volar/language-server/lib/project/typescriptProject) wraps the TS host with file watching and multi-config support.
TypeScript Plugin Options
Within a language plugin you can define typescript options:
const plugin: LanguagePlugin = {
typescript: {
extraFileExtensions: [{ extension: 'foo', isMixedContent: false, scriptKind: ts.ScriptKind.Deferred }],
getServiceScript(root) { /* return TypeScript service script */ },
getExtraServiceScripts(fileName, root) { /* return additional scripts (d.ts, .ts) */ },
resolveHiddenExtensions: true, // allow hidden files to be used by TS
},
};
- Use
extraFileExtensionsso TypeScript doesn’t treat.fooas plain text. resolveHiddenExtensions: set totrueif your virtual files live under hidden paths (e.g.,.volardirs) and still need TS features.
Language Service Plugin API (@volar/language-service)
import type { LanguageServicePlugin } from '@volar/language-service';
const myServicePlugin: LanguageServicePlugin = {
name: 'foo-diagnostics',
capabilities: {
diagnosticProvider: true,
completionProvider: { triggerCharacters: [':'] },
codeActionProvider: true,
},
create(context) {
return {
provideDiagnostics(document) {
return [{ message: 'Invalid foo', range: ... }];
},
provideCompletionItems(document, position) {
return { items: [{ label: 'fooOption' }] };
},
provideCodeActions(document, range, context) {
return [{ title: 'Fix foo', edit: ... }];
},
};
},
};
Available Hooks
provideDiagnostics(document)– return an array ofDiagnostic.provideCompletionItems(document, position, context)– returnCompletionList.resolveCompletionItem(item)– add lazy detail (e.g., docs).provideHover(document, position)– returnHover.provideDocumentSymbols,provideFoldingRanges,provideSelectionRanges.provideDefinition,provideTypeDefinition,provideImplementation.provideCodeActions,resolveCodeAction.provideCodeLens,resolveCodeLens.provideDocumentFormattingEdits/provideDocumentOnTypeFormattingEdits.provideLinkedEditingRanges,provideDocumentHighlights.provideDocumentLinks,resolveDocumentLink.provideInlayHints,resolveInlayHint.provideMoniker,provideInlineValues.provideSemanticTokens,provideWorkspaceSymbols.provideDocumentDropEdits,provideAutoInsert,provideCommand.
Capabilities Flag Reference:
Set the matching flag in capabilities to signal what hooks you implement:
diagnosticProvider: truecompletionProvider: { triggerCharacters, resolveProvider }hoverProvider: truecodeActionProvider: truedocumentFormattingProvider,documentOnTypeFormattingProviderdocumentSymbolProvider,workspaceSymbolProviderdocumentLinkProvidercodeLensProviderinlayHintProvidersemanticTokensProviderautoInsertionProviderdocumentDropProvidermonikerProviderinlineValueProvider
Best practice: Always set the minimal capability set, so hosts skip calling hooks you don’t implement.
Registering Plugins
Language-Core Plugins
When creating a language core:
import { createLanguage, createUriMap } from '@volar/language-core';
const language = createLanguage(
[myLanguagePlugin],
createUriMap(false),
(uri, includeFs, shouldRegister) => { /* sync hook */ },
);
Language-Service Plugins
When creating a language service:
import { createLanguageService } from '@volar/language-service';
const service = createLanguageService(language, [myServicePlugin], env, project);
Language Server Integration
In @volar/language-server, register plugins via the project factory:
createTypeScriptProject(ts, tsLocalized, async (context) => ({
languagePlugins: [myLanguagePlugin],
setup({ language, project }) {
project.languageServicePlugins.push(myServicePlugin);
},
}));
For non-TS projects, pass the plugins directly to createSimpleProject.
End-to-End Example: Custom .foo Language
- Language plugin: parse
.foofiles, translate them into.tsvirtual code with source maps. - Language service plugin: inspect the
.fooAST (via metadata stored in the snapshot) to provide diagnostics and completions. - TypeScript integration: register
.fooviaextraFileExtensionsso TS sees the generated.tsoutput. - Server: include the plugins in your Volar language server project and expose configuration toggles via
server.configurations.
// fooLanguagePlugin.ts
export const fooLanguagePlugin: LanguagePlugin = {
getLanguageId(scriptId) {
if (scriptId.endsWith('.foo')) return 'foo';
},
createVirtualCode(scriptId, languageId, snapshot) {
const { code, mappings } = compileFoo(snapshot.getText(0, snapshot.getLength()));
return {
id: scriptId + '.ts',
languageId: 'typescript',
snapshot: ts.ScriptSnapshot.fromString(code),
mappings,
};
},
typescript: {
extraFileExtensions: [{ extension: 'foo', isMixedContent: false, scriptKind: ts.ScriptKind.Deferred }],
},
};
// fooServicePlugin.ts
export const fooServicePlugin: LanguageServicePlugin = {
name: 'foo',
capabilities: {
diagnosticProvider: true,
completionProvider: { triggerCharacters: [':'] },
hoverProvider: true,
},
create({ language }) {
return {
provideDiagnostics(document) { /* ... */ },
provideCompletionItems(document, position) { /* ... */ },
provideHover(document, position) { /* ... */ },
};
},
};
// server setup
const project = createSimpleProject([fooLanguagePlugin]);
const languageServicePlugins = [fooServicePlugin];
Best Practices Checklist
- Type-safe snapshots – store metadata (ASTs, parse errors) on the snapshot instance to avoid reparsing in service plugins.
- Map-only relevant regions – keep mappings minimal to reduce LSP payload sizes and avoid duplicate diagnostics on synthetic sections.
- Handle incremental updates – implement
updateVirtualCodefor large files so you don’t recreate snapshots on every keystroke. - Watch configuration – expose settings via
server.configurationsand apply them inside your plugins (e.g., lint severity). - Test every hook – use the
vscode-languageservertest harness to simulate completions, hovers, diagnostics, etc., and assert on the results. - Profile plugin performance – wrap expensive hooks (diagnostics, completions) with timing logs in development to catch regressions early.
- Respect cancellation – if your plugin performs async work (e.g., fetch data), cancel promptly when the provided token fires.
- Document capabilities – consumers should know what your plugin does (e.g., “Provides hover + quick fixes for .foo files but not formatting”).
- Wire telemetry – emit
connection.telemetry.logEventfor significant plugin actions (cache misses, external request failures) to aid debugging. - Stay in sync with Volar releases – watch for changes in
@volar/language-*packages; run integration tests against new versions to catch API shifts.
By following these steps and filling in all hooks, flags, and options, your Volar plugins will integrate cleanly with any host—from VS Code to custom CLI tooling—while delivering full-fidelity language intelligence.