Codelet logoCodelet

Language features

Diagnostics and every provider in the languages namespace, from completion to code actions

Everything on this page is optional. The workbench draws whatever a registered provider answers and nothing more — a workbench with no language extensions still opens and edits every file, just without squiggles, hovers or a completion list.

Every registration takes a DocumentSelector saying which files it's for: a bare string is a language id, an object narrows further, and an array of either registers for the union of them.

let selector: vscode.DocumentSelector;
selector = "typescript"; // every TypeScript file
selector = { language: "typescript", scheme: "file" }; // only ones the tree holds
selector = { pattern: "**/*.test.ts" }; // a glob against the path, regardless of language
selector = ["typescript", "typescriptreact"]; // the union of either shape

#Diagnostics

const collection = vscode.languages.createDiagnosticCollection("no-console");

function lint(document: vscode.TextDocument) {
  const found: vscode.Diagnostic[] = [];
  for (let line = 0; line < document.lineCount; line++) {
    const text = document.lineAt(line).text;
    const at = text.indexOf("console.log");
    if (at < 0) continue;
    const range = new vscode.Range(line, at, line, at + "console.log".length);
    const diagnostic = new vscode.Diagnostic(
      range,
      "Remove console.log before committing",
      vscode.DiagnosticSeverity.Warning,
    );
    diagnostic.source = "no-console";
    diagnostic.tags = [vscode.DiagnosticTag.Unnecessary];
    found.push(diagnostic);
  }
  collection.set(document.uri, found);
}

context.subscriptions.push(
  collection,
  vscode.workspace.onDidOpenTextDocument(lint),
  vscode.workspace.onDidChangeTextDocument((event) => lint(event.document)),
);

A DiagnosticCollection is your extension's own publisher — set(uri, diagnostics) replaces everything it said about that file, delete/clear take it back. Diagnostic carries a range, a message and a severity (Error by default); tags fades a span as Unnecessary or strikes it as Deprecated, and relatedInformation points at other places that are part of the same problem — "first declared here" on a duplicate symbol. Diagnostics surface as squiggles under the range, rows in the Problems panel, and a count in the status bar. languages.getDiagnostics(uri) reads every publisher's answer for a file, including your own.

RegistrationWhat it answers
registerDefinitionProviderWhere a name is declared — go to definition.
registerTypeDefinitionProviderWhere a name's type is declared.
registerImplementationProviderWhat implements it.
registerDeclarationProviderIts declaration, for a language that separates the two.
registerReferenceProviderEvery place a name is used.

The first four are the same shape — provide…(document, position, token) answering a Location or Location[] — differing only in which command reaches them.

Here's one for a tiny wiki convention — [[Term]] jumps to the # Term heading in the same file:

context.subscriptions.push(
  vscode.languages.registerDefinitionProvider("markdown", {
    provideDefinition(document, position) {
      const range = document.getWordRangeAtPosition(position, /\[\[[^\]]+\]\]/);
      if (!range) return;
      const term = document.getText(range).slice(2, -2);
      for (let line = 0; line < document.lineCount; line++) {
        const text = document.lineAt(line).text;
        if (text.match(/^#+\s/) && text.includes(term)) {
          return new vscode.Location(document.uri, new vscode.Position(line, 0));
        }
      }
    },
  }),
);

registerReferenceProvider answers everywhere a name is used, all providers' answers at once — it's the one gesture here whose result doesn't go into the editor. Register codelet.window.registerLocationsView to draw the list yourself; codelet/extensions/search already does, so a reference search lands in the Search view's rows without any extra wiring — see Views.

#Information

RegistrationWhat it drawsGesture
registerHoverProviderA tooltip over a span.Pointer rests.
registerDocumentSymbolProviderThe names in this file.Palette @.
registerWorkspaceSymbolProviderThe same across every file (no selector).Palette #.
registerInlayHintsProviderText written into the line — a type, a parameter name.Drawn in the viewport.
registerCodeLensProviderA command drawn above a line — "3 references".Click.
registerDocumentLinkProviderAn underlined span that leads somewhere.Modified click.
registerDocumentHighlightProviderEvery other occurrence of the name under the caret.Caret rests.
registerColorProviderA swatch beside a colour literal.Click opens the picker.

Continuing the wiki example, a hover that previews what a [[Term]] link points at:

context.subscriptions.push(
  vscode.languages.registerHoverProvider("markdown", {
    provideHover(document, position) {
      const range = document.getWordRangeAtPosition(position, /\[\[[^\]]+\]\]/);
      if (!range) return;
      const term = document.getText(range).slice(2, -2);
      return new vscode.Hover(new vscode.MarkdownString(`Jump to **${term}**`), range);
    },
  }),
);

Hover.contents takes a MarkdownString.appendText(), .appendMarkdown(), .appendCodeblock() build one up — or a plain string.

#Authoring

RegistrationWhat it does
registerCompletionItemProviderSuggestions as the reader types, or on trigger characters.
registerSignatureHelpProviderParameter hints while a call is being typed.
registerCodeActionsProviderQuick fixes and refactors offered at the caret.
registerRenameProviderF2 — a WorkspaceEdit across every file that uses the name.
registerDocumentFormattingEditProviderReformat the whole file.
registerDocumentRangeFormattingEditProviderReformat a selection only.
registerFoldingRangeProviderNarrows the editor's own indentation-based folding.
registerDocumentDropEditProviderRewrite what a drop inserts — an image dropped as an embed.
registerDocumentPasteEditProviderRewrite what a paste inserts — a URL pasted as a link.

Finishing the wiki extension, completion after [[ suggests the file's own headings:

context.subscriptions.push(
  vscode.languages.registerCompletionItemProvider(
    "markdown",
    {
      provideCompletionItems(document) {
        const items: vscode.CompletionItem[] = [];
        for (let line = 0; line < document.lineCount; line++) {
          const match = document.lineAt(line).text.match(/^#+\s+(.+)/);
          if (match)
            items.push(new vscode.CompletionItem(match[1], vscode.CompletionItemKind.Reference));
        }
        return items;
      },
    },
    "[",
  ),
);

The last argument to registerCompletionItemProvider (and registerSignatureHelpProvider) is one or more trigger characters that open the list unprompted — typing a word opens one either way. CompletionItem takes a label and an optional kind; set insertText where it differs from the label, range for what it replaces, and additionalTextEdits for edits that should land alongside it, like adding an import.

Back to the no-console linter for a quick fix that removes the offending line:

context.subscriptions.push(
  vscode.languages.registerCodeActionsProvider("typescript", {
    provideCodeActions(document, range, actionContext) {
      return actionContext.diagnostics
        .filter((diagnostic) => diagnostic.source === "no-console")
        .map((diagnostic) => {
          const fix = new vscode.CodeAction("Remove console.log", vscode.CodeActionKind.QuickFix);
          fix.edit = new vscode.WorkspaceEdit();
          fix.edit.delete(
            document.uri,
            document.lineAt(diagnostic.range.start.line).rangeIncludingLineBreak,
          );
          fix.diagnostics = [diagnostic];
          fix.isPreferred = true;
          return fix;
        });
    },
  }),
);

CodeActionContext.diagnostics is already filtered to the caret's range, so there's no need to re-scan the file. A CodeAction needs an edit, a command, or both — a bare Command is also accepted where an action is only something to run. CodeActionKind (QuickFix, Refactor, RefactorExtract, Source, SourceOrganizeImports, …) decides row order, preferred and quick-fix rows first.

#Language ids

console.log(await vscode.languages.getLanguages()); // every id codelet knows

const document = await vscode.workspace.openTextDocument(vscode.Uri.file("/.eslintrc.json"));
await vscode.languages.setTextDocumentLanguage(document, "jsonc");

getLanguages() lists every language id; setTextDocumentLanguage overrides what a file is treated as regardless of its name, and re-reads the document under the new id. A manifest can add languages codelet doesn't already know:

contributes: {
  languages: [{ id: "gleam", extensions: [".gleam"], aliases: ["Gleam"] }],
},

This buys a languageId for selectors, a label in pickers and the footer — not highlighting, which is a separate grammar codelet either has or doesn't. A handful of free functions answer the same table without a document in hand, exported directly from codelet/extensions:

import { languageIdOf, hasLanguage, labelOf, languageIds } from "codelet/extensions";

languageIdOf("tsconfig.json"); // "jsonc" — whole names beat extensions
hasLanguage("gleam"); // false unless a manifest contributed it
labelOf("typescript"); // "TypeScript"
languageIds(); // every built-in id

#Language status items

const status = vscode.languages.createLanguageStatusItem("myLang.status", "typescript");
status.text = "$(check) MyLang 2.0";
status.detail = "Project loaded";

A LanguageStatusItem shows in the status bar only while the active file matches its selector — nearer a StatusBarItem than a provider, since there's nothing to ask, only something to say. It shows from the moment it's created; take it away with dispose() or by narrowing the selector to nothing.

For the full member list and everything narrowed or left out, see Full reference.

#Have a language server already?

Every provider above can also be answered by a real language server over LSP instead of hand-written functions. codelet/extensions/lsp is the client; see Lsp for wiring one up.