Codelet logoCodelet

Language servers

Diagnostics, hover, completion and go-to-definition for TypeScript, CSS, HTML, JSON and Markdown, or a server of your own.

Diagnostics, hover, completion and go-to-definition come from a language server, not from the editor itself. codelet/extensions/lsp is the client that talks to one, and five ready-made servers ship beside it: drop one into extensions and it answers for the languages it covers.

import { Workbench, FileSystem } from "codelet/workbench";
import { typescript } from "codelet/extensions/lsp/typescript";
import { json } from "codelet/extensions/lsp/json";

const workbench = new Workbench({
  parent: document.getElementById("app")!,
  fs: new FileSystem({ "/index.ts": "const x: number = 'oops';\n" }),
  extensions: [typescript(), json()],
});

Note

Nothing here is bundled into your app. Each server runs in a Web Worker built the first time you open a file it answers for, and that worker fetches its language service from a CDN at that moment — a page that never opens a stylesheet never fetches the CSS service.

#What you get

The client turns on only what a server actually advertises, so what you get depends on which server you're using — see the table below. Across the five that ship, that adds up to: diagnostics (squiggles, a count in the status bar, rows in Problems), go to definition (and, where a server tells the two apart, type definition and implementation), find all references and in-file highlighting, hover, completion with auto-import edits where a server offers one, signature help, an outline plus workspace-wide symbol search, rename, quick fixes attached to a diagnostic, and formatting the document or a selection.

FeatureTypeScriptCSSHTMLJSONMarkdown
Diagnosticsyesyesyesyes
Hoveryesyesyesyes
Completionyesyesyesyesyes
Signature helpyes
Go to definitionyesin file$refsheadings & links
Find referencesyesin fileworkspace-wide
Renameyesyesyes (no prepare)workspace-wide
Quick fixesyes
Outlineyesyesyesyesyes
Workspace symbolsyesyes
Formatyesyesyesyes

CSS and JSON also show a colour swatch next to a colour literal. There's no code lens and no document links from any of the five — a server you bring yourself can still offer both.

#The servers that ship

ServerImportLanguages
typescriptcodelet/extensions/lsp/typescript.ts, .tsx, .js, .jsx, a .vue's <script>
csscodelet/extensions/lsp/css.css, .scss, .less, a .vue's <style>
htmlcodelet/extensions/lsp/html.html, a .vue's <template>
jsoncodelet/extensions/lsp/json.json, .jsonc
markdowncodelet/extensions/lsp/markdown.md

Every one of these is a one-line drop-in — call it with no arguments, as typescript() is above, and it's running. Each also takes cdn and version, so you can point at a mirror or pin a release; the rest of what each takes is covered in its own section below.

#TypeScript

import { typescript } from "codelet/extensions/lsp/typescript";

const extensions = [typescript()];
OptionTypeDefaultDescription
versionstring"6.0.3"Which TypeScript release to run.
cdnstring"https://esm.sh"Where to fetch the compiler from.
typesbooleantrueFetch the types of packages your files import, off the same CDN.
typesLimitnumber50How many packages one session may fetch types for.
compilerOptionsRecord<string, unknown>{}Merged over the built-in compiler options the service runs with.
inlayHintsRecord<string, unknown>{}Merged over the built-in inlay hint preferences (parameter names, enum values).

typescript() covers .ts, .tsx, .js, .jsx, and the <script> (or <script setup>) block of a .vue file — the rest of a .vue file is invisible to it. It automatically acquires types: when a file imports a package, the server fetches that package's declaration files off the same CDN, at the version pinned in the nearest package.json. @types/node comes along too, at whatever version is newest, so node:fs, process and the rest of node's globals resolve without any file importing them by name. Set types: false to turn this off — every bare import then stays typed as any, and node's globals with them.

A definition landing inside an acquired package, or inside the compiler's own lib.*.d.ts, opens as a read-only tab rather than nowhere — typescript() reads those in as it needs them.

Warning

Both the compiler and every package's types are fetched from the CDN at runtime. Offline, or behind a CSP that blocks esm.sh, TypeScript never starts: the worker's own import fails and the status bar shows unavailable. Point cdn at a mirror you control if the public one isn't reachable from where your app runs.

#CSS

import { css } from "codelet/extensions/lsp/css";

const extensions = [css()];
OptionTypeDefaultDescription
versionstring"6.3.10"Which vscode-css-languageservice release to run.
cdnstring"https://esm.sh"Where to fetch the service from.

css() covers .css, .scss and .less, and every <style> block of a .vue file — its lang attribute picks which of the three parsers a block gets. Go to definition and references stay inside one file: a variable's declaration, a mixin's, what @extend names.

#Try it

colr is squiggled as the typo it is. Hover border-radius for what the property takes, and Mod-click --brand to jump to where it's declared. card.scss is the same server under another parser: $radius and @mixin are errors in a .css file and the point of a .scss one.

#HTML

import { html } from "codelet/extensions/lsp/html";

const extensions = [html()];
OptionTypeDefaultDescription
versionstring"5.6.2"Which vscode-html-languageservice release to run.
cdnstring"https://esm.sh"Where to fetch the service from.

html() gives you hover, completion and an outline for tags, attributes and the values they take. It validates nothing — HTML has no errors to report — and it doesn't offer go to definition. Rename works, matching a tag with its closing one, but with no prepare step: an invalid caret just finds nothing to rename. Inside a .vue file it only answers for the <template> block.

#Try it

Put your cursor between the quotes of autocomplete="" and press Ctrl-Space for the sixty-odd values it takes; type < on a line of your own inside the <form> for the tags. Hover <label> or the for attribute for what each one is, MDN link and all.

#JSON

import { json } from "codelet/extensions/lsp/json";

const extensions = [
  json({
    schemas: [
      {
        uri: "https://cdn.jsdelivr.net/gh/SchemaStore/schemastore@master/src/schemas/json/package.json",
        fileMatch: ["**/package.json"],
      },
    ],
  }),
];
OptionTypeDefaultDescription
versionstring"5.7.2"Which vscode-json-languageservice release to run.
cdnstring"https://esm.sh"Where to fetch the service from.
schemasJsonSchema[][]Schemas to associate with files that don't name their own.
remotebooleantrueFetch schemas that are named by URL.

A JsonSchema entry is { uri, fileMatch?, schema? }: uri names the schema, fileMatch is a list of globs (package.json, src/*.json) it applies to, and schema is the schema object itself, where you already have it rather than somewhere to fetch it from. json() covers .json and .jsonc. It has no rename and no find-references — no two names in a document mean each other — but it does show a colour swatch next to a value a schema calls a colour.

A document can also name its own schema with a top-level $schema. A relative $schema (or a relative uri in fileMatch) resolves to a file already in the workspace and is read from there, not fetched. An absolute one is fetched over the network when remote is true.

Warning

Fetching a schema happens from the page itself, so the schema's host has to allow requests from your origin. json.schemastore.org does not send CORS headers that allow this, so a $schema or schemas entry pointing there fails silently. jsDelivr's mirror of the same files (cdn.jsdelivr.net/gh/SchemaStore/schemastore@master/...) does allow it, which is why the snippet above uses it instead.

#Try it

Put your cursor on a new line inside the braces of config.json and press Ctrl-Space. private shows up as a suggestion, read straight out of schema.json next to it — no network schema needed for this one. Give name a number instead of a string and it's an error — and so is config.json the moment you add "private" to the schema's required list, since editing a schema re-checks every file that names it.

#Markdown

import { markdown } from "codelet/extensions/lsp/markdown";

const extensions = [markdown()];
OptionTypeDefaultDescription
versionstring"0.4.0"Which vscode-markdown-languageservice release to run.
cdnstring"https://esm.sh"Where to fetch the service from.

This server is about links: whether a reference-style link's definition exists, whether a #fragment lands on a real heading in the same file, and which reference definitions nothing in the file uses or duplicates one. It reads every Markdown file in the workspace, not just the open one, so completion, definition, references and rename can all reach across documents — a heading rename updates every link into it, wherever it was written. It doesn't check whether a link to another file resolves, since only Markdown files are synced here.

Note

Markdown is the one server here with no hover. The release that added it needs a newer vscode-uri that a browser can't import as a module, so this server stays pinned below it.

#Try it

Three things are wrong with guide.md and each is squiggled in what it's worth: [changes] is a reference nothing defines, #instaling is a heading that doesn't exist, and [old] is a definition nothing uses. Fix the typo to #installing and Mod-click it to jump to the heading; type [](# on a line of your own to complete the headings, or [](./ to complete the files beside it — api.md is in the list because every Markdown file in the tree is read, not just the open one.

This is a different extension from the Markdown preview, which renders a file rather than checking it. Use them together: one shows the file, the other checks its links as you type.

Read more in Extensions > Markdown.

#Vue files

A .vue file needs all three of typescript(), css() and html() to be fully covered, since each answers for a different part of the file and none reads outside its own block. Each server blanks out the rest of the file to whitespace before it looks at it, so a position inside its own block still lines up with what's on screen, and a position outside that block simply gets no answer — hover the line between </script> and <template> and nothing opens.

#Bringing your own server

lsp() is the client underneath all five built-ins, and you can point it at any language server that speaks LSP over JSON messages. It never decides where the server runs — that's the channel.

import { lsp } from "codelet/extensions/lsp";
OptionTypeDefaultDescription
channelChannel \| (() => Channel)— (required)Where the server is.
namestring"lsp"Names the diagnostics, the output channel and the extension.
displayNamestringnameWhat the Extensions view calls it.
iconstringa generic symbol iconAn image URL for the Extensions view.
languagesreadonly string[]every fileLSP language ids this server answers for.
filesreadonly string[]noneGlob patterns for files the server needs to read but doesn't answer about.
servesreadonly string[]nonePath prefixes outside the workspace the server can read.
rootUristringroot of the filesystemWhat the server is told its workspace is.
initializationOptionsunknownundefinedPassed through in the server's initialize request.
debouncenumber200How long an edit waits, in ms, before it's sent to the server.

#The channel

A Channel is however you talk to the server — a Worker, a WebSocket, anything that can carry a JSON-RPC message both ways:

interface Channel {
  send(message: unknown): void;
  onMessage(handler: (message: any) => void): (() => void) | void;
  onError?(handler: (error: unknown) => void): (() => void) | void;
  dispose?(): void;
}

onError is what tells the client a connection died — a socket that closed, a worker that never loaded — so it can show "unavailable" instead of waiting forever. dispose is called when the extension stops, to close the socket or terminate the worker.

Here's a Channel over a WebSocket, connecting to a language server running elsewhere:

import { lsp, type Channel } from "codelet/extensions/lsp";

function websocketChannel(url: string): Channel {
  const socket = new WebSocket(url);
  return {
    send: (message) => socket.send(JSON.stringify(message)),
    onMessage: (handler) => {
      const listener = (event: MessageEvent) => handler(JSON.parse(event.data));
      socket.addEventListener("message", listener);
      return () => socket.removeEventListener("message", listener);
    },
    onError: (handler) => {
      socket.addEventListener("close", () => handler(new Error("the socket closed")));
    },
    dispose: () => socket.close(),
  };
}

const python = lsp({
  name: "pyright",
  languages: ["python"],
  channel: () => websocketChannel("wss://example.com/pyright"),
});

channel can be the Channel itself, or a function that returns one. The function form is called only when the client actually starts, the first time you open a file its languages covers — which is what lets the same extension be handed to renderWorkbench() on a server: the shell renders without ever calling channel or opening a socket. Passing a Channel value directly connects it once and for good; passing a function also gets you a "Restart server" command, since the client can make a new channel to restart with.

serves names path prefixes the server can read that aren't in the workbench's own tree — a compiler's own lib.*.d.ts files, or a dependency's declarations fetched on the fly. Without it, a go-to-definition landing outside the workspace opens nothing; with the prefix listed, it opens a read-only tab instead, filled in by asking the server to read that path.

See Language features for the provider-level API this client is built on.

#Try it

Hover the squiggle under age for the error, hover formatUser to see its inferred type, then Mod-click formatUser (or press F12 with the cursor on it) to jump to where it's defined. The compiler is fetched from a CDN the first time a .ts file opens, so diagnostics appear a moment after the editor does.

Read more in Extensions.