
# Language servers

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.

```ts
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.

| Feature           | TypeScript | CSS     | HTML             | JSON    | Markdown         |
| ----------------- | ---------- | ------- | ---------------- | ------- | ---------------- |
| Diagnostics       | yes        | yes     | –                | yes     | yes              |
| Hover             | yes        | yes     | yes              | yes     | –                |
| Completion        | yes        | yes     | yes              | yes     | yes              |
| Signature help    | yes        | –       | –                | –       | –                |
| Go to definition  | yes        | in file | –                | `$ref`s | headings & links |
| Find references   | yes        | in file | –                | –       | workspace-wide   |
| Rename            | yes        | yes     | yes (no prepare) | –       | workspace-wide   |
| Quick fixes       | yes        | –       | –                | –       | –                |
| Outline           | yes        | yes     | yes              | yes     | yes              |
| Workspace symbols | yes        | –       | –                | –       | yes              |
| Format            | yes        | yes     | yes              | yes     | –                |

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

| Server       | Import                              | Languages                                           |
| ------------ | ----------------------------------- | --------------------------------------------------- |
| `typescript` | `codelet/extensions/lsp/typescript` | `.ts`, `.tsx`, `.js`, `.jsx`, a `.vue`'s `<script>` |
| `css`        | `codelet/extensions/lsp/css`        | `.css`, `.scss`, `.less`, a `.vue`'s `<style>`      |
| `html`       | `codelet/extensions/lsp/html`       | `.html`, a `.vue`'s `<template>`                    |
| `json`       | `codelet/extensions/lsp/json`       | `.json`, `.jsonc`                                   |
| `markdown`   | `codelet/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

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

const extensions = [typescript()];
```

| Option            | Type                      | Default            | Description                                                                     |
| ----------------- | ------------------------- | ------------------ | ------------------------------------------------------------------------------- |
| `version`         | `string`                  | `"6.0.3"`          | Which TypeScript release to run.                                                |
| `cdn`             | `string`                  | `"https://esm.sh"` | Where to fetch the compiler from.                                               |
| `types`           | `boolean`                 | `true`             | Fetch the types of packages your files import, off the same CDN.                |
| `typesLimit`      | `number`                  | `50`               | How many packages one session may fetch types for.                              |
| `compilerOptions` | `Record<string, unknown>` | `{}`               | Merged over the built-in compiler options the service runs with.                |
| `inlayHints`      | `Record<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

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

const extensions = [css()];
```

| Option    | Type     | Default            | Description                                        |
| --------- | -------- | ------------------ | -------------------------------------------------- |
| `version` | `string` | `"6.3.10"`         | Which `vscode-css-languageservice` release to run. |
| `cdn`     | `string` | `"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.

::codelet-playground{mode="workbench" extensions="lsp-css" active="theme.css" height="420"}

```scss [card.scss]
$radius: 6px;

@mixin raised {
  box-shadow: 0 1px 2px rgb(0 0 0 / 20%);
}

.card {
  @include raised;
  border-radius: $radius;
}
```

```css [theme.css]
:root {
  --brand: #4f46e5;
}

.button {
  background: var(--brand);
  border-radius: 6px;
  colr: white;
}
```

::

## HTML

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

const extensions = [html()];
```

| Option    | Type     | Default            | Description                                         |
| --------- | -------- | ------------------ | --------------------------------------------------- |
| `version` | `string` | `"5.6.2"`          | Which `vscode-html-languageservice` release to run. |
| `cdn`     | `string` | `"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.

::codelet-playground{mode="workbench" extensions="lsp-html" height="420"}

```html [index.html]
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>codelet</title>
  </head>
  <body>
    <form>
      <label for="email">Your email</label>
      <input id="email" type="email" autocomplete="" />
    </form>
  </body>
</html>
```

::

## JSON

```ts
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"],
      },
    ],
  }),
];
```

| Option    | Type           | Default            | Description                                                |
| --------- | -------------- | ------------------ | ---------------------------------------------------------- |
| `version` | `string`       | `"5.7.2"`          | Which `vscode-json-languageservice` release to run.        |
| `cdn`     | `string`       | `"https://esm.sh"` | Where to fetch the service from.                           |
| `schemas` | `JsonSchema[]` | `[]`               | Schemas to associate with files that don't name their own. |
| `remote`  | `boolean`      | `true`             | Fetch 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.

::codelet-playground{mode="workbench" extensions="lsp-json" height="360"}

```json [schema.json]
{
  "type": "object",
  "properties": {
    "name": { "type": "string", "description": "The package name." },
    "private": { "type": "boolean", "description": "Blocks accidental publishing." }
  },
  "required": ["name"]
}
```

```json [config.json]
{
  "$schema": "./schema.json",
  "name": "codelet"
}
```

::

## Markdown

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

const extensions = [markdown()];
```

| Option    | Type     | Default            | Description                                             |
| --------- | -------- | ------------------ | ------------------------------------------------------- |
| `version` | `string` | `"0.4.0"`          | Which `vscode-markdown-languageservice` release to run. |
| `cdn`     | `string` | `"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.

::codelet-playground{mode="workbench" extensions="lsp-markdown" height="420"}

```md [api.md]
# API

## createEditor

Makes an editor.
```

```md [guide.md]
# Guide

## Installing

Read the [API notes](./api.md) first, then the [changelog][changes].

Skip ahead to [installing](#instaling).

[old]: https://example.com/old
```

::

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{to="/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.

::codelet-playground{mode="workbench" extensions="lsp-typescript lsp-css lsp-html" height="460"}

```vue [App.vue]
<script setup lang="ts">
const items: string[] = ["one", "two", "three"];
const label = items.jion(", ");
</script>

<template>
  <ul class="list">
    <li v-for="item in items" :key="item">{{ item }}</li>
  </ul>
</template>

<style scoped>
.list {
  display: grid;
  gap: 4px;
  colr: red;
}
</style>
```

::

## 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.

```ts
import { lsp } from "codelet/extensions/lsp";
```

| Option                  | Type                         | Default                | Description                                                                |
| ----------------------- | ---------------------------- | ---------------------- | -------------------------------------------------------------------------- |
| `channel`               | `Channel \| (() => Channel)` | — (required)           | Where the server is.                                                       |
| `name`                  | `string`                     | `"lsp"`                | Names the diagnostics, the output channel and the extension.               |
| `displayName`           | `string`                     | `name`                 | What the Extensions view calls it.                                         |
| `icon`                  | `string`                     | a generic symbol icon  | An image URL for the Extensions view.                                      |
| `languages`             | `readonly string[]`          | every file             | LSP language ids this server answers for.                                  |
| `files`                 | `readonly string[]`          | none                   | Glob patterns for files the server needs to read but doesn't answer about. |
| `serves`                | `readonly string[]`          | none                   | Path prefixes outside the workspace the server can read.                   |
| `rootUri`               | `string`                     | root of the filesystem | What the server is told its workspace is.                                  |
| `initializationOptions` | `unknown`                    | `undefined`            | Passed through in the server's `initialize` request.                       |
| `debounce`              | `number`                     | `200`                  | How 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:

```ts
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:

```ts
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](/api/languages) 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.

::codelet-playground{mode="workbench" extensions="lsp-typescript" height="420"}

```ts [user.ts]
export interface User {
  name: string;
  age: number;
}

export function formatUser(user: User) {
  return `${user.name} (${user.age})`;
}
```

```ts [index.ts]
import { formatUser, type User } from "./user";

const user: User = { name: "Ada", age: "36" };

console.log(formatUser(user));
```

::

:read-more{to="/extensions"}
