Files
volar-docs/docs/plugin-authoring.md
2025-11-09 22:22:52 -06:00

12 KiB
Raw Permalink Blame History

Authoring Volar Plugins & Language Services

Docs IndexRepo READMECode Gen GuideTesting & CI

Volars 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 Volars 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: an IScriptSnapshot describing the generated text. Use ts.ScriptSnapshot.fromString or a custom snapshot implementation.
  • mappings: array of CodeMapping entries 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. Use false for generated scaffolding you dont want to expose.
  • completion: mark sections as completion targets. Use onlyImport for 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?.getServiceScript to 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 extraFileExtensions so TypeScript doesnt treat .foo as plain text.
  • resolveHiddenExtensions: set to true if your virtual files live under hidden paths (e.g., .volar dirs) 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 of Diagnostic.
  • provideCompletionItems(document, position, context) return CompletionList.
  • resolveCompletionItem(item) add lazy detail (e.g., docs).
  • provideHover(document, position) return Hover.
  • 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: true
  • completionProvider: { triggerCharacters, resolveProvider }
  • hoverProvider: true
  • codeActionProvider: true
  • documentFormattingProvider, documentOnTypeFormattingProvider
  • documentSymbolProvider, workspaceSymbolProvider
  • documentLinkProvider
  • codeLensProvider
  • inlayHintProvider
  • semanticTokensProvider
  • autoInsertionProvider
  • documentDropProvider
  • monikerProvider
  • inlineValueProvider

Best practice: Always set the minimal capability set, so hosts skip calling hooks you dont 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

  1. Language plugin: parse .foo files, translate them into .ts virtual code with source maps.
  2. Language service plugin: inspect the .foo AST (via metadata stored in the snapshot) to provide diagnostics and completions.
  3. TypeScript integration: register .foo via extraFileExtensions so TS sees the generated .ts output.
  4. 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

  1. Type-safe snapshots store metadata (ASTs, parse errors) on the snapshot instance to avoid reparsing in service plugins.
  2. Map-only relevant regions keep mappings minimal to reduce LSP payload sizes and avoid duplicate diagnostics on synthetic sections.
  3. Handle incremental updates implement updateVirtualCode for large files so you dont recreate snapshots on every keystroke.
  4. Watch configuration expose settings via server.configurations and apply them inside your plugins (e.g., lint severity).
  5. Test every hook use the vscode-languageserver test harness to simulate completions, hovers, diagnostics, etc., and assert on the results.
  6. Profile plugin performance wrap expensive hooks (diagnostics, completions) with timing logs in development to catch regressions early.
  7. Respect cancellation if your plugin performs async work (e.g., fetch data), cancel promptly when the provided token fires.
  8. Document capabilities consumers should know what your plugin does (e.g., “Provides hover + quick fixes for .foo files but not formatting”).
  9. Wire telemetry emit connection.telemetry.logEvent for significant plugin actions (cache misses, external request failures) to aid debugging.
  10. 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.