├── .dockerignore
├── demo
├── .gitignore
├── src
│ ├── index.css
│ ├── vite-env.d.ts
│ ├── main.tsx
│ ├── components
│ │ ├── LSWindow.tsx
│ │ ├── LSInlayHint.tsx
│ │ ├── LSContextMenu.tsx
│ │ ├── LSRename.tsx
│ │ ├── LSSignatureHelp.tsx
│ │ ├── LSGoTo.tsx
│ │ └── LSContents.tsx
│ ├── App.tsx
│ └── useLsCodemirror.tsx
├── deploy
│ ├── server
│ │ ├── Dockerfile
│ │ ├── deno.jsonc
│ │ ├── ls-server.ts
│ │ └── ls-proxy.ts
│ └── main.ts
├── vite.config.ts
├── index.html
├── tsconfig.json
├── wrangler.jsonc
├── package.json
└── README.md
├── mise.toml
├── ls-ws-server
├── src
│ ├── LSWSServer
│ │ ├── index.ts
│ │ ├── WSStream.test.ts
│ │ ├── procs
│ │ │ ├── LSProc.ts
│ │ │ └── LSProcManager.ts
│ │ ├── WSStream.ts
│ │ └── LSTransform.ts
│ ├── LSProxy
│ │ ├── index.ts
│ │ ├── codes.ts
│ │ ├── utils.test.ts
│ │ ├── utils.ts
│ │ ├── types.ts
│ │ └── types.lsp.ts
│ ├── index.ts
│ └── logger.ts
├── CHANGELOG.md
├── tsconfig.json
├── LICENSE.txt
├── package.json
└── README.md
├── .gitignore
├── codemirror-ls
├── src
│ ├── transport
│ │ ├── index.ts
│ │ ├── LSITransport.ts
│ │ └── LSMockTransport.ts
│ ├── index.ts
│ ├── extensions
│ │ ├── types.ts
│ │ ├── index.ts
│ │ ├── contextMenu.test.ts
│ │ ├── inlayHints.test.ts
│ │ ├── window.ts
│ │ ├── hovers.ts
│ │ ├── inlayHints.ts
│ │ ├── contextMenu.ts
│ │ └── renames.ts
│ ├── utils.test.ts
│ ├── errors.ts
│ ├── LSPlugin.test.ts
│ ├── utils.ts
│ ├── setup.ts
│ ├── types.lsp.ts
│ └── LSClient.ts
├── tsconfig.json
├── LICENSE.txt
├── CHANGELOG.md
├── package.json
└── README.md
├── CONTRIBUTING.md
├── .vscode
└── settings.json
├── .changeset
├── config.json
└── README.md
├── vitest.config.ts
├── .github
└── workflows
│ ├── demo.yml
│ ├── test.yml
│ └── release.yml
├── package.json
├── biome.json
└── README.md
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .git
--------------------------------------------------------------------------------
/demo/.gitignore:
--------------------------------------------------------------------------------
1 | .deno_dir
2 | .wrangler
--------------------------------------------------------------------------------
/demo/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
--------------------------------------------------------------------------------
/mise.toml:
--------------------------------------------------------------------------------
1 | [tools]
2 | node = "22"
3 | deno = "2.3.6"
--------------------------------------------------------------------------------
/demo/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSWSServer/index.ts:
--------------------------------------------------------------------------------
1 | export { LSWSServer, type LSWSServerOptions } from "./LSWSServer.js";
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | src/**.js
4 | scripts/**.js
5 | dist
6 | .DS_Store
7 | *.tsbuildinfo
8 | *.log
9 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSProxy/index.ts:
--------------------------------------------------------------------------------
1 | export { LSProxy } from "./LSProxy.js";
2 | export type * from "./types.js";
3 | export * as utils from "./utils.js";
4 |
--------------------------------------------------------------------------------
/codemirror-ls/src/transport/index.ts:
--------------------------------------------------------------------------------
1 | export type { LSITransport } from "./LSITransport.js";
2 | export { LSWebSocketTransport } from "./LSWebSocketTransport.js";
3 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | This project uses changesets, so each PR should include a changeset. You can
4 | create one by running:
5 |
6 | ```
7 | npx @changesets/cli
8 | ```
9 |
--------------------------------------------------------------------------------
/ls-ws-server/src/index.ts:
--------------------------------------------------------------------------------
1 | export { LSProxy } from "~/LSProxy/LSProxy.js";
2 | export { LSWSServer, type LSWSServerOptions } from "~/LSWSServer/LSWSServer.js";
3 | export { Logger } from "~/logger.js";
4 |
--------------------------------------------------------------------------------
/demo/deploy/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM denoland/deno:2.3.6
2 |
3 | COPY . /app
4 | WORKDIR /app/deploy/server
5 | RUN deno cache . --frozen=true
6 |
7 | EXPOSE 5002
8 |
9 | CMD ["deno", "run", "-A", "ls-server.ts"]
10 |
--------------------------------------------------------------------------------
/demo/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import "./index.css";
4 | import App from "./App.tsx";
5 |
6 | createRoot(document.getElementById("root")!).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/codemirror-ls/src/index.ts:
--------------------------------------------------------------------------------
1 | export { LSClient } from "./LSClient.js";
2 |
3 | export { LSCore, LSPlugin } from "./LSPlugin.js";
4 | export {
5 | type LanguageServerFeatures,
6 | type LanguageServerOptions,
7 | languageServerWithClient,
8 | } from "./setup.js";
9 |
10 | export * from "./utils.js";
11 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "deno.enablePaths": ["./demo/deploy/server"],
3 | "[typescript]": {
4 | "editor.defaultFormatter": "biomejs.biome"
5 | },
6 | "editor.tabSize": 2,
7 | "editor.insertSpaces": true,
8 | "[json]": {
9 | "editor.defaultFormatter": "biomejs.biome"
10 | },
11 | }
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/demo/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from "node:path";
2 | import tailwindcss from "@tailwindcss/vite";
3 | import react from "@vitejs/plugin-react";
4 | import { defineConfig } from "vite";
5 |
6 | export default defineConfig({
7 | plugins: [react(), tailwindcss()],
8 | resolve: {
9 | alias: {
10 | "@valtown/codemirror-ls": resolve(__dirname, "../codemirror-ls/src"),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Codemirror Cf-container Demo
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config"
2 | import * as path from "node:path"
3 |
4 | export default defineConfig({
5 | test: {
6 | include: [
7 | "./codemirror-ls/**/*.test.ts",
8 | "./ls-ws-server/**/*.test.ts",
9 | ],
10 | environment: "happy-dom",
11 | },
12 | resolve: {
13 | alias: {
14 | '~': path.resolve(__dirname, "ls-ws-server", "src"),
15 | },
16 | },
17 | })
18 |
--------------------------------------------------------------------------------
/ls-ws-server/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @valtown/ls-ws-server
2 |
3 | ## 0.0.26
4 |
5 | ### Patch Changes
6 |
7 | - e0fef62: Adopt OIDC for publishing
8 |
9 | ## 0.0.25
10 |
11 | ### Patch Changes
12 |
13 | - 9b75f2a: Remove unused pino and pino-pretty dependencies
14 | - b242ac1: Remove unused es-toolkit dependency
15 | - 6d70aac: Only use p-timeout and p-queue dependencies when necessary
16 |
17 | ## 0.0.24
18 |
19 | ### Patch Changes
20 |
21 | - b371642: Switch to changesets
22 |
--------------------------------------------------------------------------------
/demo/deploy/server/deno.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "tasks": {
3 | "start": "deno run -A ./ls-server.ts"
4 | },
5 | "imports": {
6 | "@hono/zod-validator": "npm:@hono/zod-validator@^0.7.2",
7 | "@valtown/ls-ws-server": "npm:@valtown/ls-ws-server@^0.0.21",
8 | "hono": "npm:hono@^4.9.2",
9 | "vscode-languageserver-protocol": "npm:vscode-languageserver-protocol@^3.17.5",
10 | "zod": "npm:zod@^4.0.17"
11 | },
12 | "nodeModulesDir": "auto"
13 | // "links": ["../../../ls-ws-server/"] // (for development)
14 | }
15 |
--------------------------------------------------------------------------------
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/types.ts:
--------------------------------------------------------------------------------
1 | import type { Extension } from "@codemirror/state";
2 |
3 | /**
4 | * A renderer is a function that takes an HTML element and additional arguments,
5 | * and maybe applies some rendering logic to the element.
6 | *
7 | * This is useful for using external rendering libraries like React to render
8 | * onto codemirror elements.
9 | */
10 | export type Renderer = (
11 | element: HTMLElement,
12 | ...args: T
13 | ) => Promise;
14 |
15 | /**
16 | * An extension getter is a function that yields LSP codemirror extensions.
17 | */
18 | export type LSExtensionGetter = (params: T) => Extension[];
19 |
--------------------------------------------------------------------------------
/codemirror-ls/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": true,
4 | "declarationMap": true,
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "inlineSources": true,
8 | "jsx": "react",
9 | "module": "nodenext",
10 | "moduleResolution": "nodenext",
11 | "noUncheckedIndexedAccess": true,
12 | "resolveJsonModule": true,
13 | "skipLibCheck": true,
14 | "sourceMap": true,
15 | "strict": true,
16 | "target": "es2022",
17 | "rootDir": "./src",
18 | "outDir": "./dist"
19 | },
20 | "include": ["./src"],
21 | "exclude": ["./src/**/*.test.ts", "./**/*Mock*.ts"]
22 | }
23 |
--------------------------------------------------------------------------------
/ls-ws-server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": true,
4 | "declarationMap": true,
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "inlineSources": true,
8 | "jsx": "react",
9 | "module": "nodenext",
10 | "moduleResolution": "nodenext",
11 | "noUncheckedIndexedAccess": true,
12 | "resolveJsonModule": true,
13 | "skipLibCheck": true,
14 | "sourceMap": true,
15 | "strict": true,
16 | "paths": {
17 | "~/*": ["./src/*"]
18 | },
19 | "target": "es2022",
20 | "rootDir": "./src",
21 | "outDir": "./dist"
22 | },
23 | "include": ["./src"],
24 | "exclude": ["./src/**/*.test.ts"]
25 | }
26 |
--------------------------------------------------------------------------------
/demo/src/components/LSWindow.tsx:
--------------------------------------------------------------------------------
1 | import type * as LSP from "vscode-languageserver-protocol";
2 |
3 | interface LSWindowProps {
4 | message: LSP.ShowMessageParams;
5 | onDismiss: () => void;
6 | }
7 |
8 | export function LSWindow({ message, onDismiss }: LSWindowProps) {
9 | return (
10 |
11 |
12 |
{message.message}
13 |
18 | ×
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module index
3 | * @description Entry point for all editor extensions.
4 | *
5 | * Every extension export provides a "get"-extensions function that returns
6 | * an array of extensions that can be used with codemirror.
7 | */
8 |
9 | export * as completions from "./completions.js";
10 | export * as contextMenu from "./contextMenu.js";
11 | export * as hovers from "./hovers.js";
12 | export * as inlayHints from "./inlayHints.js";
13 | export * as linting from "./linting.js";
14 | export * as references from "./references.js";
15 | export * as renames from "./renames.js";
16 | export * as signatures from "./signatures.js";
17 | export type { Renderer } from "./types.js";
18 | export * as window from "./window.js";
19 |
--------------------------------------------------------------------------------
/.github/workflows/demo.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - main
5 |
6 | jobs:
7 | deploy:
8 | defaults:
9 | run:
10 | working-directory: demo
11 |
12 | runs-on: ubuntu-latest
13 | # Cloudflare has [worker builds](https://developers.cloudflare.com/workers/wrangler/) but
14 | # we also need to deploy a container to Cloudflare Containers and a durable object.
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: jdx/mise-action@v2
18 | - run: npm ci
19 | - run: npm run build
20 | - name: Wrangler deploy
21 | run: npx wrangler@latest deploy # TODO: properly pin
22 | env:
23 | NODE_ENV: production
24 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
25 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSProxy/codes.ts:
--------------------------------------------------------------------------------
1 | import type { LSProxyCode } from "./types.js";
2 |
3 | /**
4 | * Codes used in the `ls_proxy_code` field of responses from the LS Proxy.
5 | *
6 | * Used for "meta" responses that indicate special conditions, such as a
7 | * request being cancelled.
8 | */
9 | export const codes = {
10 | cancel_response: "cancel_response",
11 | } as const;
12 |
13 | export function hasALsProxyCode(
14 | value: unknown,
15 | ): value is { ls_proxy_code: LSProxyCode } {
16 | if (
17 | typeof value === "object" &&
18 | value !== null &&
19 | "ls_proxy_code" in value &&
20 | typeof value.ls_proxy_code === "string"
21 | ) {
22 | return Object.values(codes).includes(value.ls_proxy_code as LSProxyCode);
23 | }
24 | return false;
25 | }
26 |
--------------------------------------------------------------------------------
/codemirror-ls/LICENSE.txt:
--------------------------------------------------------------------------------
1 | ISC License
2 |
3 | Copyright 2025 Val Town
4 |
5 | Permission to use, copy, modify, and/or distribute this software for any purpose
6 | with or without fee is hereby granted, provided that the above copyright notice
7 | and this permission notice appear in all copies.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
11 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
13 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
14 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
15 | THIS SOFTWARE.
--------------------------------------------------------------------------------
/ls-ws-server/LICENSE.txt:
--------------------------------------------------------------------------------
1 | ISC License
2 |
3 | Copyright 2025 Val Town
4 |
5 | Permission to use, copy, modify, and/or distribute this software for any purpose
6 | with or without fee is hereby granted, provided that the above copyright notice
7 | and this permission notice appear in all copies.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
11 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
13 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
14 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
15 | THIS SOFTWARE.
--------------------------------------------------------------------------------
/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "moduleResolution": "bundler",
6 | "allowSyntheticDefaultImports": true,
7 | "esModuleInterop": true,
8 | "allowJs": true,
9 | "checkJs": false,
10 | "strict": true,
11 | "noEmit": true,
12 | "skipLibCheck": true,
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "lib": ["ES2022"],
16 | "types": ["./worker-configuration.d.ts", "@types/node"],
17 | "jsx": "react-jsx",
18 | "allowImportingTsExtensions": true,
19 | "paths": {
20 | "codemirror-ls": ["../codemirror-ls/src"],
21 | "codemirror-ls/*": ["../codemirror-ls/src/*"]
22 | }
23 | },
24 | "include": ["deploy/**/*", "src/**/*", "*.ts", "*.js"],
25 | "exclude": ["node_modules", "dist", ".wrangler"]
26 | }
27 |
--------------------------------------------------------------------------------
/demo/src/components/LSInlayHint.tsx:
--------------------------------------------------------------------------------
1 | import * as LSP from "vscode-languageserver-protocol";
2 |
3 | interface LSInlayHintProps {
4 | hint: LSP.InlayHint;
5 | }
6 |
7 | export function LSInlayHint({ hint }: LSInlayHintProps) {
8 | if (!hint) {
9 | return null;
10 | }
11 |
12 | const label =
13 | typeof hint.label === "string"
14 | ? hint.label
15 | : hint.label.map((part) => part.value).join("");
16 | const paddingLeft = hint.paddingLeft ? "1px" : "0px";
17 | const paddingRight = hint.paddingRight ? "1px" : "0px";
18 |
19 | const color =
20 | hint.kind === LSP.InlayHintKind.Type ? "text-stone-400" : "text-gray-500";
21 |
22 | return (
23 |
27 | {label}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test All Packages
2 |
3 | on:
4 | push:
5 |
6 | jobs:
7 | test:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout code
11 | uses: actions/checkout@v4
12 |
13 | - name: Install mise
14 | uses: jdx/mise-action@v2
15 |
16 | - name: Install dependencies
17 | run: npm ci
18 |
19 | - name: Lint code
20 | run: npm run lint
21 |
22 | - name: Run tests
23 | run: npm test
24 | tsc:
25 | runs-on: ubuntu-latest
26 | steps:
27 | - name: Checkout code
28 | uses: actions/checkout@v4
29 |
30 | - name: Install mise
31 | uses: jdx/mise-action@v2
32 |
33 | - name: Install dependencies
34 | run: npm ci
35 |
36 | - name: TypeScript check
37 | run: npm run ci:tsc
--------------------------------------------------------------------------------
/demo/deploy/main.ts:
--------------------------------------------------------------------------------
1 | import { Container, getContainer } from "@cloudflare/containers";
2 | import { Hono } from "hono";
3 | import { logger as honoLogger } from "hono/logger";
4 |
5 | export class VTLSPDemoContainer extends Container {
6 | public override sleepAfter = 90; // seconds
7 | public override defaultPort = 5002;
8 | }
9 |
10 | export default {
11 | fetch: new Hono<{ Bindings: Env }>()
12 | .use(honoLogger())
13 | .get("/demo", (c) => c.redirect(`/?id=${crypto.randomUUID()}`))
14 | .all("/lsp/*", async (c) => {
15 | const container = getContainer(
16 | c.env.VTLSP_DEMO_CONTAINER,
17 | c.req.query("id") || "default",
18 | );
19 | const url = new URL("/", c.req.url);
20 | url.search = c.req.url.split("?")[1] || "";
21 | const req = new Request(url, c.req.raw);
22 | return container.fetch(req);
23 | }).fetch,
24 | } satisfies ExportedHandler;
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | permissions:
11 | contents: write
12 | pull-requests: write
13 | id-token: write
14 |
15 | jobs:
16 | release:
17 | name: Release
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: Checkout Repo
21 | uses: actions/checkout@v5
22 |
23 | - name: Setup Node.js 22
24 | uses: actions/setup-node@v5
25 | with:
26 | node-version: 22
27 |
28 | - name: update npm
29 | run: npm update -g npm
30 |
31 | - name: Install Dependencies
32 | run: npm install
33 |
34 | - name: Create Release Pull Request or Publish to npm
35 | id: changesets
36 | uses: changesets/action@v1
37 | with:
38 | publish: npx @changesets/cli publish
39 | env:
40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41 |
--------------------------------------------------------------------------------
/codemirror-ls/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @valtown/codemirror-ls
2 |
3 | ## 0.2.4
4 |
5 | ### Patch Changes
6 |
7 | - e0fef62: Adopt OIDC for publishing
8 |
9 | ## 0.2.3
10 |
11 | ### Patch Changes
12 |
13 | - 2adf7bc: Instantly apply diagnostics, then lazily query for code actions
14 |
15 | ## 0.2.2
16 |
17 | ### Patch Changes
18 |
19 | - d9fa1c3: Add a global error handler callback (generally for showing error UIs)
20 |
21 | ## 0.2.1
22 |
23 | ### Patch Changes
24 |
25 | - 6d70aac: Only use p-timeout and p-queue dependencies when necessary
26 |
27 | ## 0.2.0
28 |
29 | ### Minor Changes
30 |
31 | - 6645eea: Add support for textDocument/inlayHints and fix concurrent diagnostic rendering with lazy textDocument/codeActions evaluation
32 |
33 | ### Patch Changes
34 |
35 | - 3bed294: Fix rename showing up in context menu even if it is disabled
36 |
37 | ## 0.1.0
38 |
39 | ### Minor Changes
40 |
41 | - fc7f221: Use a global callback for "external" changes
42 |
43 | ## 0.0.26
44 |
45 | ### Patch Changes
46 |
47 | - b371642: Switch to changesets
48 |
--------------------------------------------------------------------------------
/demo/wrangler.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/wrangler/config-schema.json",
3 | "account_id": "0c98b133158df53222b825aafd746596", // replace with your cloudflare account ID
4 | "compatibility_date": "2025-05-19",
5 | "name": "cf-vtlsp-demo",
6 | "main": "deploy/main.ts",
7 | "compatibility_flags": ["nodejs_compat"],
8 | "observability": {
9 | "enabled": true
10 | },
11 | "assets": {
12 | "directory": "./dist",
13 | "binding": "ASSETS"
14 | },
15 | "durable_objects": {
16 | "bindings": [
17 | {
18 | "class_name": "VTLSPDemoContainer",
19 | "name": "VTLSP_DEMO_CONTAINER"
20 | }
21 | ]
22 | },
23 | "containers": [
24 | {
25 | "max_instances": 5,
26 | "name": "vtlsp-demo-container",
27 | "class_name": "VTLSPDemoContainer",
28 | "instance_type": "standard",
29 | "image": "./deploy/server/Dockerfile",
30 | "image_build_context": "."
31 | }
32 | ],
33 | "migrations": [
34 | {
35 | "tag": "v1",
36 | "new_sqlite_classes": ["VTLSPDemoContainer"]
37 | }
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/codemirror-ls/src/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { EditorState } from "@codemirror/state";
2 | import { EditorView } from "@codemirror/view";
3 | import { describe, expect, it } from "vitest";
4 | import { isInCurrentDocumentBounds } from "./utils.js";
5 |
6 | describe("isInCurrentDocumentBounds", () => {
7 | it("should return true for a range within the document", () => {
8 | const range = {
9 | start: { line: 0, character: 0 },
10 | end: { line: 1, character: 0 },
11 | };
12 | const view = new EditorView({
13 | state: EditorState.create({
14 | doc: "Hello\nWorld",
15 | }),
16 | });
17 | expect(isInCurrentDocumentBounds(range, view)).toBe(true);
18 | });
19 |
20 | it("should return false for a range outside the document", () => {
21 | const range = {
22 | start: { line: 0, character: 0 },
23 | end: { line: 2, character: 0 },
24 | };
25 | const view = new EditorView({
26 | state: EditorState.create({
27 | doc: "Hello\nWorld",
28 | }),
29 | });
30 | expect(isInCurrentDocumentBounds(range, view)).toBe(false);
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vtlsp",
3 | "version": "0.0.1",
4 | "private": true,
5 | "description": "VTLSP provides a complete solution to running a language server on a server, and stream messages to an editor in your browser. This is a monorepo of the core components that make this possible, including a codemirror client library, language server WebSocket server, and language server \"proxy.\"",
6 | "workspaces": [
7 | "./codemirror-ls",
8 | "./ls-ws-server",
9 | "./demo"
10 | ],
11 | "scripts": {
12 | "test": "vitest",
13 | "lint": "biome check",
14 | "format": "biome format --write .",
15 | "ci:tsc": "tsc -p ./ls-ws-server/tsconfig.json --noEmit && tsc -p ./codemirror-ls/tsconfig.json --noEmit"
16 | },
17 | "author": "Val Town",
18 | "license": "ISC",
19 | "devDependencies": {
20 | "@biomejs/biome": "2.1.4",
21 | "@changesets/cli": "^2.29.6",
22 | "happy-dom": "^18.0.1",
23 | "typescript": "^5.9.2",
24 | "vitest": "^3.2.4",
25 | "vitest-websocket-mock": "^0.5.0"
26 | },
27 | "dependencies": {
28 | "@types/ws": "^8.18.1",
29 | "json-rpc-2.0": "^1.7.1"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/ls-ws-server/src/logger.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Logger module for the language server.
3 | *
4 | * Uses stderr since the language server operates via stdout.
5 | */
6 | export const defaultLogger: Logger = {
7 | // (note the language server operates via stdout so we log to stderr)
8 | info: (...args: unknown[]) => {
9 | process.stderr.write(`[INFO] ${args.join(" ")}\n`);
10 | },
11 | warn: (...args: unknown[]) => {
12 | process.stderr.write(`[WARN] ${args.join(" ")}\n`);
13 | },
14 | error: (...args: unknown[]) => {
15 | process.stderr.write(`[ERROR] ${args.join(" ")}\n`);
16 | },
17 | debug: (...args: unknown[]) => {
18 | process.stderr.write(`[DEBUG] ${args.join(" ")}\n`);
19 | },
20 | trace: (...args: unknown[]) => {
21 | process.stderr.write(`[TRACE] ${args.join(" ")}\n`);
22 | },
23 | };
24 |
25 | /**
26 | * A no-operation logger that doesn't log.
27 | */
28 | export const noopLogger: Logger = {
29 | info: () => {},
30 | warn: () => {},
31 | error: () => {},
32 | debug: () => {},
33 | trace: () => {},
34 | };
35 |
36 | /**
37 | * Generic logger interface.
38 | */
39 | export interface Logger {
40 | info: (...args: unknown[]) => void;
41 | warn: (...args: unknown[]) => void;
42 | error: (...args: unknown[]) => void;
43 | debug: (...args: unknown[]) => void;
44 | trace: (...args: unknown[]) => void;
45 | }
46 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
3 | "vcs": {
4 | "enabled": true,
5 | "clientKind": "git",
6 | "useIgnoreFile": true
7 | },
8 | "files": {
9 | "includes": [
10 | "./demo/**/*{ts,tsx,js,jsx,json,html,css}",
11 | "./ls-ws-server/**/*{ts,tsx,js,jsx,json,html,css}",
12 | "./codemirror-ls/**/*{ts,tsx,js,jsx,json,html,css}",
13 | "!**/worker-configuration.d.ts"
14 | ]
15 | },
16 | "formatter": {
17 | "enabled": true,
18 | "indentStyle": "space"
19 | },
20 | "linter": {
21 | "enabled": true,
22 | "rules": {
23 | "recommended": true,
24 | "style": {
25 | "noNonNullAssertion": "off",
26 | "noParameterAssign": "off",
27 | "useAsConstAssertion": "error",
28 | "useDefaultParameterLast": "error",
29 | "useEnumInitializers": "error",
30 | "useSelfClosingElements": "error",
31 | "useSingleVarDeclarator": "error",
32 | "noUnusedTemplateLiteral": "error",
33 | "useNumberNamespace": "error",
34 | "noInferrableTypes": "error",
35 | "noUselessElse": "error"
36 | },
37 | "suspicious": {
38 | "noConsole": "error",
39 | "noTemplateCurlyInString": "off"
40 | }
41 | }
42 | },
43 | "javascript": {
44 | "formatter": {
45 | "quoteStyle": "double"
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/codemirror-ls/src/errors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generic language server error. Could be client or server side.
3 | */
4 |
5 | import {
6 | REFERENCE_KIND_LABELS,
7 | type ReferenceKind,
8 | } from "./extensions/references.js";
9 |
10 | export class LSError extends Error {
11 | constructor(
12 | message: string,
13 | public code?: number,
14 | ) {
15 | super(message);
16 | this.name = "LSPError";
17 | }
18 | }
19 |
20 | /**
21 | * Error thrown when a requested feature is not supported by the language server.
22 | *
23 | * Usually it should be impossible to request features that are not supported.
24 | * It may happen if you use externally exported functions like
25 | * handleFindReferences directly when they are not supported.
26 | */
27 | export class LSNotSupportedError extends LSError {
28 | constructor(message: string) {
29 | super(message);
30 | this.name = "LSNotSupportedError";
31 | }
32 | }
33 |
34 | /**
35 | * Error thrown when a lock could not be acquired within a certain timeout.
36 | */
37 | export class LSLockTimeoutError extends LSError {
38 | constructor(message: string) {
39 | super(message);
40 | this.name = "LSLockTimeoutError";
41 | }
42 | }
43 |
44 | /**
45 | * Error thrown when no references of a certain kind could be found.
46 | */
47 | export class NoReferencesError extends Error {
48 | constructor(message: ReferenceKind) {
49 | super(message);
50 | this.name = "NoReferencesError";
51 | this.message = `No ${REFERENCE_KIND_LABELS[message]} found`;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/codemirror-ls/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/package.json",
3 | "name": "@valtown/codemirror-ls",
4 | "version": "0.2.4",
5 | "description": "Val Town editor Codemirror LSP client",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/val-town/vtlsp.git"
9 | },
10 | "homepage": "https://github.com/val-town/vtlsp",
11 | "scripts": {
12 | "format": "biome format --write .",
13 | "test": "vitest",
14 | "build": "tsc --build",
15 | "prepublishOnly": "npm run build",
16 | "bump": "npm version patch"
17 | },
18 | "files": [
19 | "dist"
20 | ],
21 | "keywords": [
22 | "codemirror",
23 | "typescript",
24 | "ts",
25 | "lsp"
26 | ],
27 | "author": "Val Town",
28 | "license": "ISC",
29 | "peerDependencies": {
30 | "@codemirror/autocomplete": "^6",
31 | "@codemirror/lint": "^6",
32 | "@codemirror/state": "^6",
33 | "@codemirror/view": "^6.0.0",
34 | "codemirror": "^6.0.0"
35 | },
36 | "exports": {
37 | ".": {
38 | "types": "./dist/index.d.ts",
39 | "default": "./dist/index.js"
40 | },
41 | "./extensions": {
42 | "types": "./dist/extensions/index.d.ts",
43 | "default": "./dist/extensions/index.js"
44 | },
45 | "./transport": {
46 | "types": "./dist/transport/index.d.ts",
47 | "default": "./dist/transport/index.js"
48 | }
49 | },
50 | "types": "./dist/index.d.ts",
51 | "type": "module",
52 | "main": "./dist/index.js",
53 | "dependencies": {
54 | "p-queue": "^8.1.0",
55 | "p-timeout": "^7.0.0",
56 | "vscode-jsonrpc": "^8.2.1",
57 | "vscode-languageserver-protocol": "^3.17.5"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/demo/deploy/server/ls-server.ts:
--------------------------------------------------------------------------------
1 | /** biome-ignore-all lint/suspicious/noConsole: debug logging */
2 |
3 | import { zValidator } from "@hono/zod-validator";
4 | import { LSWSServer } from "@valtown/ls-ws-server";
5 | import { Hono } from "hono";
6 | import { z } from "zod";
7 |
8 | const PORT = 5002;
9 | const HOSTNAME = "0.0.0.0";
10 | const SHUTDOWN_AFTER = 60 * 5; // 5 minutes
11 | const MAX_PROCS = 1;
12 | const LS_COMMAND = "deno";
13 | const LS_ARGS: string[] = ["run", "-A", "./ls-proxy.ts"];
14 |
15 | const lsWsServer = new LSWSServer({
16 | lsArgs: LS_ARGS,
17 | lsCommand: LS_COMMAND,
18 | maxProcs: MAX_PROCS,
19 | shutdownAfter: SHUTDOWN_AFTER,
20 | lsStdoutLogPath: "vtlsp-procs-stdout.log",
21 | lsStderrLogPath: "vtlsp-procs-stderr.log",
22 | });
23 |
24 | const gracefulShutdown = (signal: string, code: number) => async () => {
25 | console.log(`Received ${signal}, shutting down`);
26 | await lsWsServer.shutdown(1012, `Server received ${signal}`);
27 | Deno.exit(code);
28 | };
29 |
30 | Deno.addSignalListener("SIGINT", gracefulShutdown("SIGINT", 130));
31 | Deno.addSignalListener("SIGTERM", gracefulShutdown("SIGTERM", 143));
32 |
33 | function getApp(lsWsServer: LSWSServer) {
34 | return new Hono().get(
35 | "/",
36 | zValidator("query", z.object({ session: z.string() })),
37 | (c) => {
38 | const { socket, response } = Deno.upgradeWebSocket(c.req.raw);
39 | console.log("Received request:", c.req.raw.method, c.req.raw.url);
40 | try {
41 | return response;
42 | } finally {
43 | lsWsServer.handleNewWebsocket(socket, c.req.valid("query").session);
44 | }
45 | },
46 | );
47 | }
48 |
49 | const app = getApp(lsWsServer);
50 |
51 | Deno.serve({ port: PORT, hostname: HOSTNAME }, app.fetch);
52 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo",
3 | "repository": {
4 | "type": "git",
5 | "url": "git+https://github.com/val-town/vtlsp.git"
6 | },
7 | "homepage": "https://github.com/val-town/vtlsp",
8 | "private": true,
9 | "version": "0.0.2",
10 | "license": "ISC",
11 | "type": "module",
12 | "scripts": {
13 | "dev": "vite",
14 | "deploy": "wrangler deploy",
15 | "build": "vite build",
16 | "docker:build": "docker build -t codemirror-cf-lsp-demo -f ./deploy/server/Dockerfile .",
17 | "docker:run": "docker run -p 5002:5002 codemirror-cf-lsp-demo",
18 | "gen-types": "wrangler types"
19 | },
20 | "dependencies": {
21 | "@cloudflare/containers": "^0.0.25",
22 | "@codemirror/lang-javascript": "^6.2.4",
23 | "@codemirror/state": "^6.5.2",
24 | "@hono/zod-validator": "^0.7.2",
25 | "@tailwindcss/vite": "^4.1.11",
26 | "@uiw/react-codemirror": "^4.24.2",
27 | "@valtown/codemirror-ls": "^0.0.18",
28 | "codemirror": "^6.0.2",
29 | "hast-util-to-jsx-runtime": "^2.3.6",
30 | "hono": "^4.9.0",
31 | "lowlight": "^3.3.0",
32 | "lucide-react": "^0.539.0",
33 | "react": "^19.1.1",
34 | "react-dom": "^19.1.1",
35 | "react-markdown": "^10.1.0",
36 | "rehype-raw": "^7.0.0",
37 | "tailwindcss": "^4.1.11",
38 | "vscode-languageserver-protocol": "^3.17.5",
39 | "vscode-uri": "^3.1.0",
40 | "wrangler": "^4.29.1",
41 | "ws": "^8.18.3",
42 | "zod": "^4.0.17"
43 | },
44 | "devDependencies": {
45 | "@cloudflare/workers-types": "^4.20250809.0",
46 | "@eslint/js": "^9.32.0",
47 | "@types/node": "^24.2.1",
48 | "@types/react": "^19.1.9",
49 | "@types/react-dom": "^19.1.7",
50 | "@vitejs/plugin-react": "^4.7.0",
51 | "eslint-plugin-react-hooks": "^5.2.0",
52 | "eslint-plugin-react-refresh": "^0.4.20",
53 | "globals": "^16.3.0",
54 | "typescript": "~5.8.3",
55 | "vite": "^7.1.0"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/contextMenu.test.ts:
--------------------------------------------------------------------------------
1 | import { EditorView } from "@codemirror/view";
2 | import {
3 | beforeEach,
4 | describe,
5 | expect,
6 | it,
7 | type MockedFunction,
8 | vi,
9 | } from "vitest";
10 | import { LSClient } from "../LSClient";
11 | import { LSPlugin } from "../LSPlugin";
12 | import { LSMockTransport } from "../transport/LSMockTransport.js";
13 | import {
14 | type ContextMenuRenderer,
15 | contextMenuActivated,
16 | getContextMenuExtensions,
17 | } from "./contextMenu";
18 |
19 | describe("contextMenu", () => {
20 | let renderer: MockedFunction;
21 | let mockTransport: LSMockTransport;
22 | let view: EditorView;
23 |
24 | beforeEach(() => {
25 | renderer = vi.fn();
26 | mockTransport = new LSMockTransport({
27 | definitionProvider: true,
28 | referencesProvider: true,
29 | });
30 |
31 | view = new EditorView({
32 | doc: "Test document",
33 | extensions: [
34 | LSPlugin.of({
35 | documentUri: "file:///test.txt",
36 | languageId: "plaintext",
37 | client: new LSClient({
38 | transport: mockTransport,
39 | workspaceFolders: null,
40 | }),
41 | }),
42 | getContextMenuExtensions({
43 | render: renderer,
44 | disableFindAllReferences: true,
45 | }),
46 | ],
47 | });
48 | });
49 |
50 | it("renders on annotation event", () => {
51 | view.dispatch({
52 | annotations: [
53 | contextMenuActivated.of({
54 | event: new MouseEvent("contextmenu", { clientX: 10, clientY: 10 }),
55 | pos: 5,
56 | }),
57 | ],
58 | });
59 |
60 | expect(renderer).toHaveBeenCalled();
61 | const callbacks = renderer.mock.calls[0][1];
62 |
63 | expect(callbacks.goToDefinition).not.toBe(null);
64 | expect(callbacks.goToTypeDefinition).toBeNull();
65 | expect(callbacks.goToImplementation).toBeNull();
66 | expect(callbacks.findAllReferences).toBeNull();
67 | expect(callbacks.rename).toBeNull();
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/inlayHints.test.ts:
--------------------------------------------------------------------------------
1 | import { EditorView } from "@codemirror/view";
2 | import {
3 | beforeEach,
4 | describe,
5 | expect,
6 | it,
7 | type MockedFunction,
8 | vi,
9 | } from "vitest";
10 | import type * as LSP from "vscode-languageserver-protocol";
11 | import { LSClient } from "../LSClient";
12 | import { LSPlugin } from "../LSPlugin";
13 | import { LSMockTransport } from "../transport/LSMockTransport";
14 | import { getInlayHintExtensions, type InlayHintsRenderer } from "./inlayHints";
15 |
16 | describe("inlayHints", () => {
17 | let renderer: MockedFunction;
18 | let mockTransport: LSMockTransport;
19 |
20 | beforeEach(() => {
21 | renderer = vi.fn();
22 | mockTransport = new LSMockTransport({
23 | inlayHintProvider: true,
24 | });
25 |
26 | new EditorView({
27 | doc: "function test(param: string) { return param; }",
28 | extensions: [
29 | LSPlugin.of({
30 | documentUri: "file:///test.ts",
31 | languageId: "typescript",
32 | client: new LSClient({
33 | transport: mockTransport,
34 | workspaceFolders: null,
35 | }),
36 | }),
37 | getInlayHintExtensions({
38 | render: renderer,
39 | debounceTime: 1000,
40 | clearOnEdit: false,
41 | }),
42 | ],
43 | });
44 | });
45 |
46 | it("requests inlay hints and renders them", async () => {
47 | const mockInlayHints: LSP.InlayHint[] = [
48 | {
49 | position: { line: 0, character: 14 },
50 | label: ": string",
51 | kind: 1, // Type hint
52 | },
53 | ];
54 |
55 | mockTransport.sendRequest.mockResolvedValueOnce(mockInlayHints);
56 |
57 | await new Promise((r) => setTimeout(r, 500));
58 |
59 | expect(renderer).not.toHaveBeenCalled();
60 |
61 | await new Promise((r) => setTimeout(r, 500));
62 |
63 | expect(mockTransport.sendRequest).toHaveBeenCalledWith(
64 | "textDocument/inlayHint",
65 | expect.objectContaining({
66 | textDocument: { uri: "file:///test.ts" },
67 | range: expect.any(Object),
68 | }),
69 | );
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSWSServer/WSStream.test.ts:
--------------------------------------------------------------------------------
1 | import { afterEach, beforeEach, describe, expect, it } from "vitest";
2 | import WSS from "vitest-websocket-mock";
3 | import { chunkByteArray, createWebSocketStreams } from "./WSStream.js";
4 |
5 | describe("chunkByteArray", () => {
6 | it("provides evenly divisible chunks", () => {
7 | // Create a test array [0, 1, 2, 3, 4, 5]
8 | const testArray = new Uint8Array([0, 1, 2, 3, 4, 5]);
9 | const chunkSize = 2;
10 |
11 | const chunks = Array.from(chunkByteArray(testArray, chunkSize));
12 |
13 | // Should result in 3 chunks: [0,1], [2,3], [4,5]
14 | expect(chunks.length).toBe(3);
15 | expect(chunks[0]).toEqual(new Uint8Array([0, 1]));
16 | expect(chunks[1]).toEqual(new Uint8Array([2, 3]));
17 | expect(chunks[2]).toEqual(new Uint8Array([4, 5]));
18 | });
19 |
20 | it("can handle non-evenly divisible chunks", () => {
21 | // Create a test array [0, 1, 2, 3, 4, 5, 6]
22 | const testArray = new Uint8Array([0, 1, 2, 3, 4, 5, 6]);
23 | const chunkSize = 3;
24 |
25 | const chunks = Array.from(chunkByteArray(testArray, chunkSize));
26 |
27 | // Should result in 3 chunks: [0,1,2], [3,4,5], [6]
28 | expect(chunks.length).toBe(3);
29 | expect(chunks[0]).toEqual(new Uint8Array([0, 1, 2]));
30 | expect(chunks[1]).toEqual(new Uint8Array([3, 4, 5]));
31 | expect(chunks[2]).toEqual(new Uint8Array([6]));
32 | });
33 | });
34 |
35 | describe("WSStream", () => {
36 | let server: WSS;
37 | let client: WebSocket;
38 |
39 | beforeEach(async () => {
40 | server = new WSS("ws://localhost:1234");
41 | client = new WebSocket("ws://localhost:1234");
42 | await new Promise((res) => {
43 | client.onopen = res;
44 | });
45 | });
46 |
47 | afterEach(() => {
48 | WSS.clean();
49 | });
50 |
51 | it("can ping and pong", async () => {
52 | const { readable, writable } = createWebSocketStreams(client);
53 |
54 | readable.on("data", (data) => {
55 | expect(data).toEqual("pong");
56 | });
57 |
58 | server.on("message", (client) => {
59 | expect(client).toEqual("ping");
60 | server.send("pong");
61 | });
62 |
63 | const expectPromise = expect(server).toReceiveMessage(Buffer.from("ping"));
64 | writable.write("ping");
65 | await expectPromise;
66 |
67 | await server.connected;
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/codemirror-ls/src/transport/LSITransport.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Interface for a JSON-RPC client that provides methods for handling communication
3 | * with a JSON-RPC server using notifications and requests.
4 | */
5 | export interface LSITransport {
6 | /**
7 | * Registers a handler for incoming notifications with the specified method name.
8 | * Use "*" as the method to handle all notifications.
9 | *
10 | * @param method - The name of the notification method to listen for, or "*" for all
11 | * @param handler - The function to call when a notification with the specified method is received
12 | * @return A function to unregister the handler
13 | */
14 | onNotification: (
15 | handler: (method: string, params: unknown) => void,
16 | ) => () => void;
17 |
18 | /**
19 | * Sends a notification to the server with the specified method and optional parameters.
20 | * Notifications do not expect a response.
21 | *
22 | * @param method - The name of the notification method to send
23 | * @param params - Optional parameters to include with the notification
24 | */
25 | sendNotification: (method: string, params?: unknown) => void;
26 |
27 | /**
28 | * Sends a request to the server and returns a promise that resolves with the response.
29 | *
30 | * @template T - The expected type of the response
31 | * @param method - The name of the request method to send
32 | * @param params - Optional parameters to include with the request
33 | * @returns A promise that resolves with the server's response
34 | */
35 | sendRequest: (method: string, params?: unknown) => Promise;
36 |
37 | /**
38 | * Registers a handler for incoming requests
39 | *
40 | * @param handler The function to call when a request is made
41 | * @return A function to unregister the handler
42 | */
43 | onRequest: (
44 | handler: (method: string, params: unknown) => unknown,
45 | ) => () => void;
46 |
47 | /**
48 | * Registers an error handler that will be called when JSON-RPC errors occur.
49 | *
50 | * @param handler - The function to call when an error occurs
51 | * @return A function to unregister the error handler
52 | */
53 | onError: (handler: (error: unknown) => void) => () => void;
54 |
55 | /**
56 | * Closes the transport connection and cleans up resources.
57 | */
58 | close?: () => void;
59 | }
60 |
--------------------------------------------------------------------------------
/ls-ws-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/package.json",
3 | "name": "@valtown/ls-ws-server",
4 | "version": "0.0.26",
5 | "description": "Language server WebSocket server and proxy",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/val-town/vtlsp.git"
9 | },
10 | "homepage": "https://github.com/val-town/vtlsp",
11 | "scripts": {
12 | "format": "biome format --write .",
13 | "test": "vitest",
14 | "build": "tsc --build && tsc-alias",
15 | "prepublishOnly": "npm run build",
16 | "bump": "npm version patch"
17 | },
18 | "files": [
19 | "dist"
20 | ],
21 | "keywords": [
22 | "codemirror",
23 | "typescript",
24 | "ts",
25 | "lsp"
26 | ],
27 | "author": "Val Town",
28 | "license": "ISC",
29 | "peerDependencies": {
30 | "@codemirror/autocomplete": "^6",
31 | "@codemirror/lint": "^6",
32 | "@codemirror/state": "^6",
33 | "@codemirror/view": "^6.0.0",
34 | "codemirror": "^6.0.0"
35 | },
36 | "devDependencies": {
37 | "@types/node": "^24.2.1",
38 | "@types/ws": "^8.18.1",
39 | "happy-dom": "^18.0.1",
40 | "tsc-alias": "^1.8.16",
41 | "typescript": "^5.9.2",
42 | "vitest": "^3.2.4"
43 | },
44 | "exports": {
45 | ".": {
46 | "types": "./dist/index.d.ts",
47 | "import": "./dist/index.js",
48 | "default": "./dist/index.js"
49 | },
50 | "./logger": {
51 | "types": "./dist/logger.d.ts",
52 | "import": "./dist/logger.js",
53 | "default": "./dist/logger.js"
54 | },
55 | "./proxy": {
56 | "types": "./dist/LSProxy/index.d.ts",
57 | "import": "./dist/LSProxy/index.js",
58 | "default": "./dist/LSProxy/index.js"
59 | },
60 | "./server": {
61 | "types": "./dist/LSWSServer/index.d.ts",
62 | "import": "./dist/LSWSServer/index.js",
63 | "default": "./dist/LSWSServer/index.js"
64 | },
65 | "./server/procs": {
66 | "types": "./dist/LSWSServer/procs/index.d.ts",
67 | "import": "./dist/LSWSServer/procs/index.js",
68 | "default": "./dist/LSWSServer/procs/index.js"
69 | }
70 | },
71 | "types": "./dist/index.d.ts",
72 | "main": "./dist/index.js",
73 | "type": "module",
74 | "dependencies": {
75 | "execa": "^9.6.0",
76 | "isows": "^1.0.7",
77 | "json-rpc-2.0": "^1.7.1",
78 | "vscode-jsonrpc": "^8.2.1",
79 | "vscode-languageserver-protocol": "^3.17.5",
80 | "vscode-uri": "^3.1.0"
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/demo/deploy/server/ls-proxy.ts:
--------------------------------------------------------------------------------
1 | import { LSProxy } from "@valtown/ls-ws-server";
2 | import { utils } from "@valtown/ls-ws-server/proxy";
3 |
4 | const TEMP_DIR = await Deno.makeTempDir({ prefix: "vtlsp-proxy" });
5 |
6 | const onExit = async () => await Deno.remove(TEMP_DIR, { recursive: true });
7 | Deno.addSignalListener("SIGINT", onExit);
8 | Deno.addSignalListener("SIGTERM", onExit);
9 |
10 | const proxy = new LSProxy({
11 | name: "lsp-server",
12 | cwd: TEMP_DIR,
13 | exec: {
14 | command: "deno",
15 | args: ["lsp", "-q"],
16 | },
17 | clientToProcMiddlewares: {
18 | initialize: async (params) => {
19 | await Deno.writeTextFile(`${TEMP_DIR}/deno.json`, JSON.stringify({})); // Create a deno.json in the temp dir
20 | return params;
21 | },
22 | "textDocument/didOpen": async (params) => {
23 | // Write file to temp directory when opened
24 | const tempFilePath = utils.virtualUriToTempDirUri(
25 | params.textDocument.uri,
26 | TEMP_DIR,
27 | );
28 | if (tempFilePath) {
29 | const filePath = new URL(tempFilePath).pathname;
30 | await Deno.mkdir(filePath.substring(0, filePath.lastIndexOf("/")), {
31 | recursive: true,
32 | });
33 | await Deno.writeTextFile(filePath, params.textDocument.text);
34 | }
35 | return params;
36 | },
37 | "textDocument/didChange": async (params) => {
38 | // Update file content when changed
39 | const tempFilePath = utils.virtualUriToTempDirUri(
40 | params.textDocument.uri,
41 | TEMP_DIR,
42 | );
43 | if (tempFilePath) {
44 | const filePath = new URL(tempFilePath).pathname;
45 | // Apply content changes to get the full text
46 | const existingContent = await Deno.readTextFile(filePath).catch(
47 | () => "",
48 | );
49 | let newContent = existingContent;
50 |
51 | for (const change of params.contentChanges) {
52 | if ("text" in change && !("range" in change)) {
53 | // Full document change
54 | newContent = change.text;
55 | }
56 | }
57 |
58 | await Deno.writeTextFile(filePath, newContent);
59 | }
60 | return params;
61 | },
62 | },
63 | uriConverters: {
64 | fromProcUri: (uriString: string) => {
65 | return utils.tempDirUriToVirtualUri(uriString, TEMP_DIR);
66 | },
67 | toProcUri: (uriString: string) => {
68 | return utils.virtualUriToTempDirUri(uriString, TEMP_DIR)!;
69 | },
70 | },
71 | });
72 |
73 | proxy.listen();
74 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/window.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module window
3 | * @description Extensions for handling window/showMessage notifications from the LSP server.
4 | *
5 | * These notifications are used to display messages to the user, such as errors,
6 | * warnings, or informational messages.
7 | *
8 | * @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#window_showMessage
9 | */
10 |
11 | import type { EditorView } from "@codemirror/view";
12 | import { ViewPlugin } from "@codemirror/view";
13 | import * as LSP from "vscode-languageserver-protocol";
14 | import { LSCore } from "../LSPlugin.js";
15 | import type { LSExtensionGetter, Renderer } from "./types.js";
16 |
17 | export interface WindowExtensionArgs {
18 | render: WindowRenderer;
19 | /** Minimum message level to render/display */
20 | minLevel?: LSP.MessageType;
21 | /** Predicate for whether a message should be ignored */
22 | shouldIgnore?: (message: LSP.ShowMessageParams) => boolean;
23 | }
24 |
25 | export type WindowRenderer = Renderer<
26 | [message: LSP.ShowMessageParams, onDismiss: () => void]
27 | >;
28 |
29 | export const getWindowExtensions: LSExtensionGetter = ({
30 | render,
31 | minLevel = LSP.MessageType.Warning,
32 | shouldIgnore,
33 | }) => {
34 | return [
35 | ViewPlugin.fromClass(
36 | class WindowPlugin {
37 | #disposeHandler: (() => void) | null = null;
38 |
39 | constructor(view: EditorView) {
40 | const lsPlugin = LSCore.ofOrThrow(view);
41 |
42 | this.#disposeHandler = lsPlugin.client.onNotification(
43 | async (method, params) => {
44 | if (method === "window/showMessage") {
45 | const messageParams = params as LSP.ShowMessageParams;
46 | if (messageParams.type < minLevel) return;
47 | if (shouldIgnore?.(messageParams)) return;
48 |
49 | this.#showMessage(messageParams);
50 | }
51 | },
52 | );
53 | }
54 |
55 | destroy() {
56 | if (this.#disposeHandler) {
57 | this.#disposeHandler();
58 | this.#disposeHandler = null;
59 | }
60 | }
61 |
62 | #showMessage(params: LSP.ShowMessageParams) {
63 | const container = document.createElement("div");
64 | const onDismiss = () => container.remove();
65 |
66 | render(container, params, onDismiss);
67 | document.body.appendChild(container);
68 | }
69 | },
70 | ),
71 | ];
72 | };
73 |
--------------------------------------------------------------------------------
/demo/src/components/LSContextMenu.tsx:
--------------------------------------------------------------------------------
1 | import type { contextMenu } from "@valtown/codemirror-ls/extensions";
2 | import { Code2, Edit3, MousePointer2, Search, Type } from "lucide-react";
3 |
4 | export function LSContextMenu({
5 | goToDefinition,
6 | goToTypeDefinition,
7 | goToImplementation,
8 | findAllReferences,
9 | rename,
10 | onDismiss,
11 | }: contextMenu.ContextMenuCallbacks & { onDismiss: () => void }) {
12 | const dropdown = (
13 |
14 | {goToDefinition && (
15 | }
18 | >
19 | Go to Definition
20 |
21 | )}
22 | {goToTypeDefinition && (
23 | }
26 | >
27 | Go to Type Definition
28 |
29 | )}
30 | {goToImplementation && (
31 | }
34 | >
35 | Go to Implementation
36 |
37 | )}
38 | {findAllReferences && (
39 | }
42 | >
43 | Find All References
44 |
45 | )}
46 | {rename && (
47 | }>
48 | Rename Symbol
49 |
50 | )}
51 |
52 | );
53 |
54 | // In reality you should use a component library that handles these for you
55 | setTimeout(() => {
56 | window.addEventListener("click", onDismiss, { once: true });
57 | window.addEventListener("contextmenu", onDismiss, { once: true });
58 | window.addEventListener("keydown", (e) => {
59 | if (e.key === "Escape") {
60 | onDismiss();
61 | }
62 | });
63 | }, 0);
64 |
65 | return dropdown;
66 | }
67 |
68 | function LSContextMenuButton({
69 | onClick,
70 | children,
71 | icon,
72 | }: {
73 | onClick: () => void;
74 | children: React.ReactNode;
75 | icon?: React.ReactNode;
76 | }) {
77 | return (
78 |
83 | {icon}
84 | {children}
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/demo/README.md:
--------------------------------------------------------------------------------
1 | # VTLSP Demo Deployment
2 |
3 | 
4 |
5 | This is a simple demo of all the components of this repo: A language server WebSocket server with a basic proxy to run the Deno language server.
6 |
7 | There are two components, the language server client editor, and the WebSocket server.
8 |
9 | The client is a React app that uses our codemirror client library, built with Vite, and running as a Cloudflare Worker with an associated [Cloudflare container](https://developers.cloudflare.com/containers/) to actually run the language server. Cloudflare containers make it very easy to dynamically prevision sandboxed instances to individual users, and routing is very easy and is done with arbitrary keys.
10 |
11 | This demo is deployed on a [Container enabled Cloudflare durable object](./deploy/main.ts) that includes an export for a tiny Hono app that proxies to the language server. The Container class is a utility class Cloudflare provides to make it easy to route requests into ephemeral containers. The fetch export is a definition for a Cloudflare worker, which is just a "javascript runner." We arbitrarily in this demo choose to route requests by the `?id=` query parameter, but you could decide to route to unique containers however you want. At Val Town, we choose to route users to containers using users' user ids as the literal ID of the container that we route them to. We also have an associated `wrangler.json` which sets up the necessary "Bindings" to deploy the Cloudflare container.
12 |
13 | Since we're already using Cloudflare containers, for simplicity of the demo we also deploy the frontend, a tiny React + Vite app, as a Cloudflare worker. We build the app (with Vite) as a static website, and then specify in the `wrangler.json` to upload the build outputs.
14 |
15 | The Deno language server has some quirks when running it in a virtual environment. In some areas it expects physical files to exist on disc. Additionally, it will only "wake up" if there is a `deno.json` in the directory that the process is spawned from. We have a very minimal usage of our `LSProxy` that takes care of these quirks. On Val Town, we also make additional modifications to the language server via the LSProxy, like [custom env variable suggestions](https://filedumpthing.val.run/blob/blob_file_1755106837620_1fd7a65c-4a8d-437d-a0c6-1b61e1ef71da.gif), which is implemented by "mocking" a file that augments Deno.env.get.
16 |
17 | For the actual server, we're using the WebSocket server in this repo to host the language server for us. This launches the proxy LSP, and then exposes it to the internet via an HTTP endpoint on a WebSocket server.
18 |
--------------------------------------------------------------------------------
/demo/src/components/LSRename.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 |
3 | interface LSRenameProps {
4 | onDismiss: () => void;
5 | onComplete: (newName: string) => void;
6 | placeholder: string;
7 | }
8 |
9 | export function LSRename({
10 | onDismiss,
11 | onComplete,
12 | placeholder,
13 | }: LSRenameProps) {
14 | const [newName, setNewName] = useState(placeholder);
15 | const inputRef = useRef(null);
16 | const containerRef = useRef(null);
17 |
18 | // Note that usually you'd want to use a component library to handle these
19 | // sorts of events for you
20 |
21 | useEffect(() => {
22 | // Focus and select the input when the component mounts
23 | if (inputRef.current) {
24 | inputRef.current.focus();
25 | inputRef.current.select();
26 | }
27 |
28 | // Handle clicks outside the component
29 | const handleClickOutside = (event: MouseEvent) => {
30 | if (
31 | containerRef.current &&
32 | !containerRef.current.contains(event.target as Node)
33 | ) {
34 | onDismiss();
35 | }
36 | };
37 |
38 | document.addEventListener("mousedown", handleClickOutside);
39 | return () => {
40 | document.removeEventListener("mousedown", handleClickOutside);
41 | };
42 | }, [onDismiss]);
43 |
44 | const handleSubmit = (e: React.FormEvent) => {
45 | e.preventDefault();
46 | if (newName.trim()) {
47 | onComplete(newName.trim());
48 | }
49 | };
50 |
51 | const handleKeyDown = (e: React.KeyboardEvent) => {
52 | if (e.key === "Escape") {
53 | onDismiss();
54 | } else if (e.key === "Enter") {
55 | if (newName.trim()) {
56 | onComplete(newName.trim());
57 | }
58 | }
59 | };
60 |
61 | return (
62 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/demo/src/components/LSSignatureHelp.tsx:
--------------------------------------------------------------------------------
1 | import type { JSX } from "react";
2 | import type * as LSP from "vscode-languageserver-protocol";
3 | import { LowLightCodeBlock, LSContents } from "./LSContents";
4 |
5 | interface LSPContentsProps {
6 | data: LSP.SignatureHelp;
7 | activeSignature: number;
8 | activeParameter?: number;
9 | }
10 |
11 | export function LSSignatureHelp({
12 | data,
13 | activeParameter,
14 | }: LSPContentsProps): JSX.Element {
15 | return (
16 |
17 | {data.signatures.map((line) => (
18 |
23 | ))}
24 |
25 | );
26 | }
27 |
28 | function LSSignatureHelpLine({
29 | line,
30 | activeParameterIndex,
31 | }: {
32 | line: LSP.SignatureInformation;
33 | activeParameterIndex?: number;
34 | }): JSX.Element {
35 | const activeParameter =
36 | activeParameterIndex !== undefined
37 | ? line.parameters?.[activeParameterIndex]
38 | : undefined;
39 | const activeParameterStr =
40 | typeof activeParameter?.label === "string" ? activeParameter.label : "";
41 |
42 | const before = line.label.split(activeParameterStr).at(0);
43 | const after = line.label.split(activeParameterStr).at(1);
44 |
45 | if (activeParameterStr) {
46 | return (
47 |
48 | {before && after && (
49 |
50 |
55 |
60 |
65 |
66 | )}
67 | {line.documentation &&
}
68 | {activeParameter?.documentation && (
69 |
70 |
74 |
75 | )}
76 |
77 | );
78 | }
79 | return (
80 |
81 |
86 | {line.documentation && }
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/codemirror-ls/src/LSPlugin.test.ts:
--------------------------------------------------------------------------------
1 | import { EditorState } from "@codemirror/state";
2 | import { EditorView } from "@codemirror/view";
3 | import { beforeEach, describe, expect, it, vi } from "vitest";
4 | import { LSClient } from "./LSClient.js";
5 | import { LSCore } from "./LSPlugin.js";
6 | import { LSMockTransport } from "./transport/LSMockTransport.js";
7 |
8 | describe("LSPlugin", () => {
9 | let mockTransport: LSMockTransport;
10 | let client: LSClient;
11 | let view: EditorView;
12 | let lsCore: LSCore;
13 |
14 | beforeEach(() => {
15 | mockTransport = new LSMockTransport();
16 | client = new LSClient({ transport: mockTransport, workspaceFolders: null });
17 |
18 | view = new EditorView({
19 | state: EditorState.create({
20 | doc: "initial document content",
21 | }),
22 | parent: document.body,
23 | });
24 |
25 | lsCore = new LSCore(view, {
26 | client,
27 | documentUri: "file:///test.ts",
28 | languageId: "typescript",
29 | });
30 | });
31 |
32 | it("should execute doWithLock and provide current document", async () => {
33 | const mockCallback = vi.fn((doc) => {
34 | expect(doc.toString()).toBe("initial document content");
35 | return "callback result";
36 | });
37 |
38 | const result = await lsCore.doWithLock(mockCallback);
39 |
40 | expect(result).toBe("callback result");
41 | expect(mockCallback).toHaveBeenCalledOnce();
42 | });
43 |
44 | it("should timeout doWithLock after specified duration", async () => {
45 | const slowCallback = vi.fn(async () => {
46 | await new Promise((resolve) => setTimeout(resolve, 100));
47 | return "should not complete";
48 | });
49 |
50 | await expect(lsCore.doWithLock(slowCallback, 50)).rejects.toThrow(
51 | "Lock timed out",
52 | );
53 | });
54 |
55 | it("should prevent changes from being sent during doWithLock", async () => {
56 | let lockActive = false;
57 | let changesSentDuringLock = false;
58 |
59 | mockTransport.sendNotification.mockImplementation((method) => {
60 | if (method === "textDocument/didChange" && lockActive) {
61 | changesSentDuringLock = true;
62 | }
63 | });
64 |
65 | const mockCallback = vi.fn(async () => {
66 | lockActive = true;
67 |
68 | // Make changes during the lock
69 | view.dispatch({
70 | changes: { from: 0, to: 0, insert: "changed during lock" },
71 | });
72 |
73 | await new Promise((resolve) => setTimeout(resolve, 50));
74 | lockActive = false;
75 | return "callback result";
76 | });
77 |
78 | const result = await lsCore.doWithLock(mockCallback);
79 |
80 | expect(result).toBe("callback result");
81 | expect(changesSentDuringLock).toBe(false);
82 |
83 | expect(mockTransport.sendNotification).toHaveBeenCalledWith(
84 | "textDocument/didChange",
85 | expect.anything(),
86 | );
87 | });
88 | });
89 |
--------------------------------------------------------------------------------
/demo/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { javascript } from "@codemirror/lang-javascript";
2 | import { EditorState } from "@codemirror/state";
3 | import { basicSetup, EditorView } from "codemirror";
4 | import { useEffect, useRef, useState } from "react";
5 | import { useLsCodemirror } from "./useLsCodemirror";
6 |
7 | const DEFAULT_URL_BASE = () =>
8 | window.location.hostname === "localhost"
9 | ? `ws://${window.location.hostname}:5002/ws?session=${crypto.randomUUID()}`
10 | : `wss://${window.location.hostname}/lsp?session=${crypto.randomUUID()}`;
11 |
12 | export default function App() {
13 | const editor = useRef(null);
14 | const view = useRef(null);
15 | const [url, setUrl] = useState(DEFAULT_URL_BASE());
16 | const [isConnecting, setIsConnecting] = useState(false);
17 | const [error, setError] = useState(null);
18 |
19 | const {
20 | extensions: lsExtensions,
21 | connect,
22 | isConnected,
23 | } = useLsCodemirror({
24 | path: "/demo.ts",
25 | });
26 |
27 | useEffect(() => {
28 | if (editor.current && !view.current && lsExtensions) {
29 | const state = EditorState.create({
30 | doc: "export function add(a: number, b: number) {\n return a + b;\n }\n\n add(12, 14)\n\n\n",
31 | extensions: [
32 | basicSetup,
33 | javascript({ jsx: true, typescript: true }),
34 | lsExtensions,
35 | ],
36 | });
37 |
38 | view.current = new EditorView({
39 | state,
40 | parent: editor.current,
41 | });
42 | }
43 |
44 | return () => {
45 | if (view.current) {
46 | view.current.destroy();
47 | view.current = null;
48 | }
49 | };
50 | }, [lsExtensions]);
51 |
52 | const handleConnect = async () => {
53 | setIsConnecting(true);
54 | setError(null);
55 | try {
56 | await connect(url);
57 | } catch (error) {
58 | setError(
59 | `Connection failed ${error instanceof Error ? `: ${error.message}` : ""}`,
60 | );
61 | } finally {
62 | setIsConnecting(false);
63 | }
64 | };
65 |
66 | return (
67 |
68 |
69 | setUrl(e.target.value)}
73 | placeholder="WebSocket URL"
74 | className="flex-1 px-2 py-1 border rounded text-sm"
75 | disabled={isConnecting}
76 | />
77 |
83 | {isConnecting ? "..." : isConnected ? "Reconnect" : "Connect"}
84 |
85 | {error && {error} }
86 |
87 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/codemirror-ls/src/transport/LSMockTransport.ts:
--------------------------------------------------------------------------------
1 | import { type MockedFunction, vi } from "vitest";
2 | import type * as LSP from "vscode-languageserver-protocol";
3 | import type { LSITransport } from "./LSITransport.js";
4 |
5 | /**
6 | * Mock implementation of LSITransport for testing.
7 | */
8 | export class LSMockTransport implements LSITransport {
9 | public notificationHandlers: Array<
10 | (method: string, params: unknown) => void
11 | > = [];
12 | public requestHandlers: Array<(method: string, params: unknown) => unknown> =
13 | [];
14 | public errorHandlers: Array<(error: unknown) => void> = [];
15 |
16 | public sendNotification: MockedFunction<
17 | (method: string, params?: unknown) => void
18 | >;
19 | public sendRequest: MockedFunction<
20 | (method: string, params?: unknown) => Promise
21 | >;
22 | public close: MockedFunction<() => void>;
23 |
24 | constructor(capabilities: LSP.ServerCapabilities = {}) {
25 | this.sendNotification = vi.fn();
26 | this.sendRequest = vi.fn();
27 | this.close = vi.fn();
28 |
29 | this.sendRequest.mockResolvedValueOnce({
30 | capabilities,
31 | serverInfo: {
32 | name: "language-server",
33 | version: "1.0.0",
34 | },
35 | });
36 | }
37 |
38 | public reset(): void {
39 | this.sendNotification.mockClear();
40 | this.sendRequest.mockClear();
41 | this.close.mockClear();
42 | this.notificationHandlers = [];
43 | this.requestHandlers = [];
44 | this.errorHandlers = [];
45 | }
46 |
47 | onNotification(
48 | handler: (method: string, params: unknown) => void,
49 | ): () => void {
50 | this.notificationHandlers.push(handler);
51 | return () => {
52 | const index = this.notificationHandlers.indexOf(handler);
53 | if (index > -1) {
54 | this.notificationHandlers.splice(index, 1);
55 | }
56 | };
57 | }
58 |
59 | onRequest(handler: (method: string, params: unknown) => unknown): () => void {
60 | this.requestHandlers.push(handler);
61 | return () => {
62 | const index = this.requestHandlers.indexOf(handler);
63 | if (index > -1) {
64 | this.requestHandlers.splice(index, 1);
65 | }
66 | };
67 | }
68 |
69 | onError(handler: (error: unknown) => void): () => void {
70 | this.errorHandlers.push(handler);
71 | return () => {
72 | const index = this.errorHandlers.indexOf(handler);
73 | if (index > -1) {
74 | this.errorHandlers.splice(index, 1);
75 | }
76 | };
77 | }
78 |
79 | simulateNotification(method: string, params?: unknown): void {
80 | this.notificationHandlers.forEach((handler) => handler(method, params));
81 | }
82 |
83 | simulateRequest(method: string, params?: unknown): unknown {
84 | if (this.requestHandlers.length > 0) {
85 | return this.requestHandlers[0]?.(method, params);
86 | }
87 | return undefined;
88 | }
89 |
90 | simulateError(error: unknown): void {
91 | this.errorHandlers.forEach((handler) => handler(error));
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/hovers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module hovers
3 | * @description Extensions for handling hover tooltips in the editor.
4 | *
5 | * Hover tooltips provide additional context and information about code elements
6 | * when users hover over them. This can include documentation, type information,
7 | * and other relevant details.
8 | *
9 | * @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover
10 | */
11 |
12 | import type { EditorView, Tooltip } from "@codemirror/view";
13 | import { hoverTooltip } from "@codemirror/view";
14 | import type * as LSP from "vscode-languageserver-protocol";
15 | import { LSCore } from "../LSPlugin.js";
16 | import { isEmptyDocumentation, offsetToPos, posToOffset } from "../utils.js";
17 | import type { LSExtensionGetter, Renderer } from "./types.js";
18 |
19 | export type HoversRenderer = Renderer<
20 | [contents: string | LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]]
21 | >;
22 |
23 | export interface HoverExtensionArgs {
24 | render: HoversRenderer;
25 | /** The time in milliseconds to wait before showing the hover tooltip. */
26 | hoverTime?: number;
27 | /** Whether to hide the tooltip on document changes. */
28 | hideOnChange?: boolean;
29 | }
30 |
31 | export const getHoversExtensions: LSExtensionGetter = ({
32 | render,
33 | hoverTime,
34 | hideOnChange = false,
35 | }) => {
36 | return [
37 | hoverTooltip(
38 | async (view, pos, _side) => {
39 | const position = offsetToPos(view.state.doc, pos);
40 | return await requestHoverTooltip({
41 | view,
42 | line: position.line,
43 | character: position.character,
44 | render,
45 | });
46 | },
47 | { hoverTime, hideOnChange },
48 | ),
49 | ];
50 | };
51 |
52 | async function requestHoverTooltip({
53 | view,
54 | line,
55 | character,
56 | render,
57 | }: {
58 | view: EditorView;
59 | line: number;
60 | character: number;
61 | render: HoversRenderer;
62 | }): Promise {
63 | const lsClient = LSCore.ofOrThrow(view);
64 |
65 | if (!lsClient.client.capabilities?.hoverProvider) {
66 | return null;
67 | }
68 |
69 | const result = await lsClient.requestWithLock("textDocument/hover", {
70 | textDocument: { uri: lsClient.documentUri },
71 | position: { line, character },
72 | });
73 |
74 | if (!result) {
75 | return null;
76 | }
77 |
78 | const { contents, range } = result;
79 | let pos = posToOffset(view.state.doc, { line, character });
80 | let end: number | undefined;
81 |
82 | if (range) {
83 | pos = posToOffset(view.state.doc, range.start);
84 | end = posToOffset(view.state.doc, range.end);
85 | }
86 |
87 | if (pos == null) {
88 | return null;
89 | }
90 |
91 | if (isEmptyDocumentation(contents)) {
92 | return null;
93 | }
94 |
95 | const dom = document.createElement("div");
96 | dom.classList.add("cm-lsp-hover");
97 | await render(dom, contents);
98 |
99 | return {
100 | pos,
101 | end,
102 | create: (_view) => ({ dom }),
103 | above: true,
104 | };
105 | }
106 |
--------------------------------------------------------------------------------
/demo/src/components/LSGoTo.tsx:
--------------------------------------------------------------------------------
1 | import { references } from "@valtown/codemirror-ls/extensions";
2 | import { File, X } from "lucide-react";
3 | import type * as LSP from "vscode-languageserver-protocol";
4 |
5 | interface LSGoToProps {
6 | locations: LSP.Location[];
7 | kind: references.ReferenceKind;
8 | goTo: (ref: LSP.Location) => void;
9 | onClose: () => void;
10 | }
11 |
12 | export function LSGoTo({ locations, kind, goTo, onClose }: LSGoToProps) {
13 | const label = references.REFERENCE_KIND_LABELS[kind] || "Locations";
14 |
15 | const groupedReferences = locations.reduce(
16 | (acc, ref) => {
17 | if (!acc[ref.uri]) {
18 | acc[ref.uri] = [];
19 | }
20 | acc[ref.uri].push(ref);
21 | return acc;
22 | },
23 | {} as Record,
24 | );
25 |
26 | const getFileName = (uri: string) => {
27 | const parts = uri.split("/");
28 | return parts[parts.length - 1] || uri;
29 | };
30 |
31 | return (
32 |
33 |
34 |
35 | {label} ({locations.length})
36 |
37 |
43 |
44 |
45 |
46 |
47 |
48 | {Object.entries(groupedReferences).map(([uri, refs]) => (
49 |
50 |
51 |
52 | {getFileName(uri)}
53 |
54 | ({refs.length})
55 |
56 |
57 |
58 |
59 | {refs.map((ref) => (
60 | goTo(ref)}
65 | />
66 | ))}
67 |
68 |
69 | ))}
70 |
71 |
72 | );
73 | }
74 |
75 | function ReferenceItem({
76 | reference,
77 | isSelected,
78 | onClick,
79 | }: {
80 | reference: LSP.Location;
81 | isSelected: boolean;
82 | onClick: () => void;
83 | }) {
84 | return (
85 |
94 |
95 | {reference.range.start.line + 1}:
96 |
97 |
98 | {reference.range.start.character}-{reference.range.end.character}
99 |
100 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VTLSP: Val Town's LSP Powered Editor
2 |
3 | 
4 |
5 | VTLSP provides a complete solution to running a language server on a server, and stream messages to an editor in your browser. This is a monorepo of the core components that make this possible, including a codemirror client library, language server WebSocket server, and language server "proxy."
6 |
7 | # Background
8 |
9 | The language server protocol is a simple JSON-RPC protocol that allows editor clients to communicate with language servers (LS) to get editor features like code completions and diagnostics. This is what powers red squiggles and fancy code action buttons. Usually LSP is done via payloads over standard in and standard out to a language server process running on your computer, typically spawned by your editor. Often editors make it very easy to install language servers, so you don't know you're using one -- VScode extensions for languages generally pack them in.
10 |
11 | Running arbitrary language servers directly in the browser is challenging for multiple reasons. You have to compile the language server to webasm if it isn't already in JS/TS, and then once you have it running, you need a client to display and interact with the language server, and manage messages and state updates. To make this easier, this repo provides a way to run language servers as WebSocket servers, and a client library for the Codemirror editor to act as a LSP frontend client.
12 |
13 | ## [Codemirror LS Client Library](./codemirror-ls/README.md) [(NPM)](https://www.npmjs.com/package/@valtown/codemirror-ls)
14 |
15 | Our Codemirror client library provides various extensions to serve as a client to a language server. It propagates code edits to the language server, and displays things like code diagnostics and tooltips. It uses some extensions from [Codemirror's official client library](https://github.com/codemirror/lsp-client) with modification, and was originally based on [Monaco's client](https://github.com/TypeFox/monaco-languageclient).
16 |
17 |
18 | ## [LS WebSocket server](./ls-ws-server/README.md) [(NPM)](https://www.npmjs.com/package/@valtown/ls-ws-server)
19 |
20 | To actually communicate from a browser to a language server, it is most simple to rely messages through a WebSocket server. Our Codemirror Client is intentionally agnostic about the type of transport used, but we provide a reference WebSocket transport in our client library.
21 |
22 | Our language server WebSocket server is able to multicast messages to many consumers of the same language server process, so you can connect from multiple browsers, and requests made from a specific session ID (which is a query parameter) will only be responded to to the specific WebSocket connection, but notifications get broadcasted.
23 |
24 | Additionally, to run an LSP remotely, some language servers, like the Deno language server, rely on file system events and require physical files on disc to work properly. There are also other language server lifecycle events that are useful to intercept or handle differently in a remote environment, like for example installing packages as the user imports them. We provide an LS proxy to make it easy to automatically transform requests for specific methods with arbitrary middleware.
25 |
26 | # [Live Demo (Here)](https://cf-vtlsp-demo.val-town.workers.dev/demo)
27 |
28 | 
29 |
30 | We have a full demo of all the components of this repo in [./demo](./demo/README.md), where we deploy a simple React app with Codemirror that connects to the `Deno` language server over a WebSocket connection to a cloudflare container (an easy, ephemeral docker container in the cloud). It runs a WebSocket server for the deno language server with simple language server proxy modifications: Deno requires a `deno.json` in the directory of the language server to activate, and can get buggy if files do not actually exist locally, so the proxy simulates those two things on respective requests.
31 |
32 | # Alternatives and Inspiration
33 |
34 | This general LSP architecture of having a proxy and relaying messages over a WebSocket"ification" server is partially inspired by Qualified's [lsp-ws-proxy](https://github.com/qualified/lsp-ws-proxy) project. The main difference here is that we make it easy to add additional, arbitrary logic at the LS proxy layer, and we handle chunking WebSocket messages differently.
35 |
36 | For our Codemirror language server, we are using components derived or reused from Marijn's official Codemirror [language server client](https://github.com/FurqanSoftware/codemirror-languageserver), like the signature help extension. Initially, we started with a fork of [Monaco](https://github.com/TypeFox/monaco-languageclient)'s Codemirror client implementation.
37 |
38 | ## Try it out!
39 |
40 | To try it out, copy out the [demo](./demo/), open the [`wrangler.json`](./demo/wrangler.jsonc), replace the `accountId` with your cloudflare account ID, and then run `npx wrangler@latest deploy`!
41 |
--------------------------------------------------------------------------------
/codemirror-ls/README.md:
--------------------------------------------------------------------------------
1 | # Codemirror Client library
2 |
3 | `codemirror-ls` is a Codemirror client library for connecting a Codemirror editor to a language server.
4 |
5 | The library is designed to make it easy to use arbitrary "renderer"s to actually display language server widgets like tooltips or context menus, so you can use your favorite libraries, like React, to render components.
6 |
7 | To use `codemirror-ls`, you have to:
8 |
9 | 1) Set up a `Transport`. This is an implementation of `LSITransport` that contains methods for registering message callbacks and sending messages to a language server. We provide a simple reference implementation of a WebSocket transport that assumes WebSocket input messages are raw output from stdout of an LSP process. This is a bit unique from other Codemirror language server clients out there -- our reference WebSocket transport needs to receive the raw output including `Content-Length` and all. It should be simple to change this behavior in a custom transport.
10 | 2) Create a `LSClient`. The `LSClient` wraps the transport and provides utility methods that are internally used by plugins but also exposed. It is useful if you want to add your own additional extensions or also make LSP calls. You can reuuse an `LSClient` and memoize it if you want to share a language server between editors.
11 | 3) Create actual extensions. There is a utility `languageServerWithClient` function to easily construct a large array of all language server extensions and configure everything at once. Under the hood, it is just constructing the extensions using the extensions "getters" this library exports, and then passing along your parameters. Importantly, if you decide to NOT use this helper, make sure that you provide a `LSClient` before any language server Codemirror extensions in your Codemirror extensions array.
12 |
13 | A simple example that uses React to render components may look like
14 |
15 |
16 | ```tsx
17 | import { LSContents } from "./components/LSContents";
18 | import { useMemo, useCallback, useState } from "react";
19 | import ReactDOM from "react-dom/client";
20 | import { LSContextMenu } from "./components/LSContextMenu";
21 | import { LSSignatureHelp } from "./components/LSSignatureHelp";
22 | import { LSGoTo } from "./components/LSGoTo";
23 | import { LSWindow } from "./components/LSWindow";
24 | import type * as LSP from "vscode-languageserver-protocol";
25 | import { languageServerWithClient, LSClient } from "codemirror-ls";
26 | import { LSWebSocketTransport } from "codemirror-ls/transport";
27 |
28 | const wsTransport = new LSWebSocketTransport(url);
29 |
30 | const lsClient = new LSClient({
31 | transport: newTransport,
32 | workspaceFolders: [{ uri: "file:///demo", name: "Demo" }],
33 | });
34 |
35 | const lspExtensions = languageServerWithClient({
36 | client: lsClient,
37 | documentUri: `file://${path}`,
38 | languageId: "typescript",
39 | sendIncrementalChanges: false,
40 | sendDidOpen: true,
41 | features: {
42 | signatureHelp: {
43 | render: async (dom, data, activeSignature, activeParameter) => {
44 | const root = ReactDOM.createRoot(dom);
45 | root.render(
46 | ,
51 | );
52 | },
53 | },
54 | linting: {
55 | render: async (dom, message) => {
56 | const root = ReactDOM.createRoot(dom);
57 | root.render( );
58 | },
59 | },
60 | references: {
61 | render: async (dom, references, goToReference, onClose, kind) => {
62 | const root = ReactDOM.createRoot(dom);
63 | root.render(
64 | ,
70 | );
71 | },
72 | modClickForDefinition: true,
73 | onExternalReference: (uri) => {
74 | console.log("Go to external reference", uri);
75 | },
76 | goToDefinitionShortcuts: ["F12"],
77 | modClickForDefinition: true,
78 | },
79 | },
80 | });
81 |
82 | const editorView = new EditorView({
83 | extensions: [
84 | basicSetup,
85 | javascript(),
86 | ...lspExtensions,
87 | ],
88 | parent: document.body,
89 | });
90 | ```
91 |
92 | Currently, our library provides the following extensions:
93 | - **Completions**: These are the list of editor suggestions you get as you type.
94 | - **Renames**: Symbol renaming. This is, for example, useful for when you want to rename a variable or function.
95 | - **Signatures**: Method or function signatures that pop up as you type out a function call.
96 | - **Hovers**: Hover tooltips that you receive when you hover over symbols.
97 | - **Context Menu**: A context menu that is triggered by right clicking a symbol. This hijacks the native context menu.
98 | - **Linting**: Red, gray, or yellow squiggles under "bad code." May also include associated code action buttons.
99 | - **References**: For viewing a list of places a symbol is referenced, or for go to definition.
100 | - **Window**: For warning messages from the LSP.
101 |
102 | ## Renderers
103 |
104 | Many of the extensions take a "renderer" as a parameter. A Renderer is just a callback that takes a dom, followed by some useful metadata, and is expected to append children to the dom to display the metadata's content.
--------------------------------------------------------------------------------
/ls-ws-server/src/LSProxy/utils.test.ts:
--------------------------------------------------------------------------------
1 | /** biome-ignore-all lint/suspicious/noExplicitAny: useful for tests */
2 |
3 | import { describe, expect, it } from "vitest";
4 | import { replaceFileUris } from "./utils.ts";
5 |
6 | describe("replaceFileUris", () => {
7 | const uriConverter = (uri: string) => `converted:${uri}`;
8 |
9 | it("should replace URIs in simple objects", () => {
10 | const simpleObj = { uri: "file:///path/to/file.ts" };
11 | const convertedSimple = replaceFileUris(simpleObj, uriConverter) as any;
12 |
13 | expect(convertedSimple.uri).toBe("converted:file:///path/to/file.ts");
14 | expect(simpleObj.uri).toBe("file:///path/to/file.ts"); // Original object should not be modified
15 | });
16 |
17 | it("should replace URIs in nested objects", () => {
18 | const nestedObj = {
19 | name: "test",
20 | resource: {
21 | uri: "file:///path/to/resource.ts",
22 | specifier: "file:///specifier.ts",
23 | },
24 | };
25 | const convertedNested = replaceFileUris(nestedObj, uriConverter) as any;
26 |
27 | expect(convertedNested.resource.uri).toBe(
28 | "converted:file:///path/to/resource.ts",
29 | );
30 | expect(convertedNested.resource.specifier).toBe(
31 | "converted:file:///specifier.ts",
32 | );
33 | });
34 |
35 | it("should replace URIs in arrays", () => {
36 | const arrayObj = {
37 | files: [
38 | { uri: "file:///file1.ts" },
39 | { uri: "file:///file2.ts", name: "file2" },
40 | { specifier: "file:///file3.ts" },
41 | ],
42 | };
43 | const convertedArray = replaceFileUris(arrayObj, uriConverter) as any;
44 |
45 | expect(convertedArray.files[0].uri).toBe("converted:file:///file1.ts");
46 | expect(convertedArray.files[1].uri).toBe("converted:file:///file2.ts");
47 | expect(convertedArray.files[2].specifier).toBe(
48 | "converted:file:///file3.ts",
49 | );
50 | });
51 |
52 | it("should not replace URIs in non-URI keys", () => {
53 | const obj = {
54 | description: "This is a file:///path/description.txt in a description",
55 | title: "Contains file:///example.js in title",
56 | content: "Some file:///content.md reference",
57 | };
58 | const converted = replaceFileUris(obj, uriConverter) as any;
59 |
60 | expect(converted.description).toBe(
61 | "This is a converted:file:///path/description.txt in a description",
62 | );
63 | expect(converted.title).toBe(
64 | "Contains converted:file:///example.js in title",
65 | );
66 | expect(converted.content).toBe(
67 | "Some converted:file:///content.md reference",
68 | );
69 | });
70 |
71 | it("should handle mixed content with file URIs and other text", () => {
72 | const obj = {
73 | message: "Error in file:///src/main.ts at line 42",
74 | uri: "file:///workspace/project.json",
75 | description:
76 | "Processing file:///data/input.csv and file:///config/settings.json",
77 | nonUriField: "This file:///path/should/be/converted.ts anyway",
78 | };
79 | const converted = replaceFileUris(obj, uriConverter) as any;
80 |
81 | expect(converted.message).toBe(
82 | "Error in converted:file:///src/main.ts at line 42",
83 | );
84 | expect(converted.uri).toBe("converted:file:///workspace/project.json");
85 | expect(converted.description).toBe(
86 | "Processing converted:file:///data/input.csv and converted:file:///config/settings.json",
87 | );
88 | expect(converted.nonUriField).toBe(
89 | "This converted:file:///path/should/be/converted.ts anyway",
90 | );
91 | });
92 |
93 | it("should handle primitive values", () => {
94 | expect(replaceFileUris("file:///test.ts", uriConverter)).toBe(
95 | "converted:file:///test.ts",
96 | );
97 | expect(replaceFileUris(42, uriConverter)).toBe(42);
98 | expect(replaceFileUris(true, uriConverter)).toBe(true);
99 | expect(replaceFileUris(null, uriConverter)).toBe(null);
100 | expect(replaceFileUris(undefined, uriConverter)).toBe(undefined);
101 | });
102 |
103 | it("should handle empty objects and arrays", () => {
104 | expect(replaceFileUris({}, uriConverter)).toEqual({});
105 | expect(replaceFileUris([], uriConverter)).toEqual([]);
106 | });
107 |
108 | it("should handle complex nested structures", () => {
109 | const complexObj = {
110 | project: {
111 | name: "Test Project",
112 | files: [
113 | { uri: "file:///src/index.ts" },
114 | { uri: "file:///src/utils.ts" },
115 | ],
116 | config: {
117 | mainFile: "file:///src/index.ts",
118 | dependencies: [
119 | "file:///node_modules/dependency1",
120 | "file:///node_modules/dependency2",
121 | ],
122 | },
123 | },
124 | };
125 | const convertedComplex = replaceFileUris(complexObj, uriConverter) as any;
126 |
127 | expect(convertedComplex.project.files[0].uri).toBe(
128 | "converted:file:///src/index.ts",
129 | );
130 | expect(convertedComplex.project.files[1].uri).toBe(
131 | "converted:file:///src/utils.ts",
132 | );
133 | expect(convertedComplex.project.config.mainFile).toBe(
134 | "converted:file:///src/index.ts",
135 | );
136 | expect(convertedComplex.project.config.dependencies[0]).toBe(
137 | "converted:file:///node_modules/dependency1",
138 | );
139 | expect(convertedComplex.project.config.dependencies[1]).toBe(
140 | "converted:file:///node_modules/dependency2",
141 | );
142 | });
143 | });
144 |
--------------------------------------------------------------------------------
/demo/src/components/LSContents.tsx:
--------------------------------------------------------------------------------
1 | import { isLSPMarkupContent } from "@valtown/codemirror-ls";
2 | import { toJsxRuntime } from "hast-util-to-jsx-runtime";
3 | import typescript from "highlight.js/lib/languages/typescript";
4 | import { createLowlight } from "lowlight";
5 | import { Fragment, type JSX, jsx, jsxs } from "react/jsx-runtime";
6 | import ReactMarkdown from "react-markdown";
7 | import rehypeRaw from "rehype-raw";
8 | import type * as LSP from "vscode-languageserver-protocol";
9 |
10 | export function LSContents({
11 | contents,
12 | className = "",
13 | }: {
14 | contents: string | LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[];
15 | className?: string;
16 | }): JSX.Element {
17 | if (typeof contents === "string") {
18 | return ;
19 | }
20 | if (isLSPMarkupContent(contents)) {
21 | return ;
22 | }
23 | if (Array.isArray(contents)) {
24 | return (
25 |
26 | {contents.map((content) => {
27 | if (typeof content === "string") {
28 | return (
29 |
34 | );
35 | }
36 | if (isLSPMarkupContent(content)) {
37 | if (content.kind === "markdown") {
38 | return (
39 |
44 | );
45 | }
46 |
47 | return (
48 |
49 |
53 |
54 | );
55 | }
56 | return null;
57 | })}
58 |
59 | );
60 | }
61 | // Handle MarkedString case - it has value and optional language
62 | if ("language" in contents && contents.language) {
63 | return (
64 |
65 | );
66 | }
67 | return ;
68 | }
69 |
70 | function MarkdownContent({
71 | content,
72 | className: additionalClassNames,
73 | }: {
74 | content: string;
75 | className?: string;
76 | }) {
77 | return (
78 |
104 | {
105 | // @ts-ignore: react types don't type these.
106 | toJsxRuntime(tree, { Fragment, jsx, jsxs })
107 | }
108 |
109 | );
110 | } catch {
111 | // Fallback if language not supported
112 | return (
113 |
117 | {children}
118 |
119 | );
120 | }
121 | }
122 |
123 | return (
124 |
128 | {children}
129 |
130 | );
131 | },
132 | }}
133 | >
134 | {content}
135 |
136 | );
137 | }
138 |
139 | export function LowLightCodeBlock({
140 | code,
141 | language,
142 | className = "",
143 | }: {
144 | code: string;
145 | language: string;
146 | className?: string;
147 | }) {
148 | try {
149 | const highlightLang = ["typescript", "typescriptreact"].includes(language)
150 | ? "typescript"
151 | : language || "plaintext";
152 | const tree = lowlight.highlight(highlightLang, code);
153 |
154 | return (
155 |
156 | {
157 | // @ts-ignore: react types don't type these.
158 | toJsxRuntime(tree, { Fragment, jsx, jsxs })
159 | }
160 |
161 | );
162 | } catch {
163 | return (
164 |
165 | {code}
166 |
167 | );
168 | }
169 | }
170 |
171 | // biome-ignore format: Don't split up these into multiple lines
172 | const allowedTags = ["h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "p", "br", "hr",
173 | "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tr", "th", "td", "blockquote",
174 | "pre", "code", "em", "strong", "a", "img"];
175 |
176 | const lowlight = createLowlight();
177 | lowlight.register({ typescript });
178 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSProxy/utils.ts:
--------------------------------------------------------------------------------
1 | import * as path from "node:path";
2 | import { URI } from "vscode-uri";
3 |
4 | const FILE_URI_PATTERN = /(file:\/\/[^\s"']+)/g;
5 |
6 | /**
7 | * Recursively process both keys and values of an object to update all file URIs in all keys
8 | * and values.
9 | *
10 | * Since the keys of the object might change, we operate unknown -> unknown.
11 | *
12 | * @param obj The object to process, which can be an object, array, or string.
13 | * @param convertUri A function that takes a string and returns a modified string.
14 | * @returns A new object with all file URIs replaced according to the callback.
15 | */
16 | export function replaceFileUris(
17 | obj: unknown,
18 | convertUri: (str: string) => string,
19 | ): unknown {
20 | // If the input is a string, replace all URIs in the string
21 | if (typeof obj === "string") {
22 | return obj.replace(FILE_URI_PATTERN, convertUri);
23 | }
24 | // If the input is not an object or array, return it as is
25 | if (obj === null || typeof obj !== "object") {
26 | return structuredClone(obj);
27 | }
28 |
29 | // If the input is an array, recurse on each item
30 | if (Array.isArray(obj)) {
31 | return obj.map((item) => replaceFileUris(item, convertUri));
32 | }
33 |
34 | // If the input is an object, recurse on each key-value pair, and do replacements on keys
35 | // and recursive calls on values
36 | const result: Record = {};
37 |
38 | for (const [key, value] of Object.entries(obj)) {
39 | const newKey = key.replace(FILE_URI_PATTERN, convertUri);
40 | result[newKey] = replaceFileUris(value, convertUri);
41 | }
42 |
43 | return result;
44 | }
45 |
46 | /**
47 | * Check if the given object is a valid LSP parameters-like object.
48 | * This includes objects, arrays, or undefined.
49 | *
50 | * @param obj The object to check.
51 | * @returns True if the object is a valid LSP parameters-like object, false otherwise.
52 | */
53 | export function isLspParamsLike(
54 | obj: unknown,
55 | ): obj is object | unknown[] | undefined {
56 | return (
57 | (typeof obj === "object" || Array.isArray(obj) || obj === undefined) &&
58 | obj !== null
59 | );
60 | }
61 |
62 | /**
63 | * Check if the given object is a valid LSP response-like object.
64 | * This includes objects, arrays, strings, or null.
65 | *
66 | * @param obj The object to check.
67 | * @returns True if the object is a valid LSP response-like object, false otherwise.
68 | */
69 | export function isLspRespLike(
70 | obj: unknown,
71 | ): obj is object | unknown[] | string | null {
72 | return (
73 | typeof obj === "object" ||
74 | Array.isArray(obj) ||
75 | typeof obj === "string" ||
76 | obj === null
77 | );
78 | }
79 |
80 | /**
81 | * Convert a virtual URI/path to a real temp file URI.
82 | *
83 | * Examples:
84 | * - "file://foobar.tsx" -> "file:///tmp/dir/foobar.tsx"
85 | * - "/foobar.tsx" -> "file:///tmp/dir/foobar.tsx"
86 | * - "foobar.tsx" -> "file:///tmp/dir/foobar.tsx"
87 | * - "http://example.com/foobar.tsx" -> "http://example.com/foobar.tsx" (unchanged)
88 | * - "file:///tmp/dir/foobar.tsx" -> "file:///tmp/dir/foobar.tsx" (unchanged if already temp)
89 | */
90 | export function virtualUriToTempDirUri(
91 | pathOrUri: string,
92 | tempDir: string,
93 | ): string | undefined {
94 | // If it's a non-file URI, return as is
95 | if (/^\w+:/i.test(pathOrUri) && !pathOrUri.startsWith("file://")) {
96 | return pathOrUri;
97 | }
98 |
99 | try {
100 | let virtualPath: string;
101 |
102 | if (pathOrUri.startsWith("file://")) {
103 | const parsedUri = URI.parse(pathOrUri);
104 | // If already in temp dir, return as is
105 | if (parsedUri.fsPath.startsWith(tempDir)) {
106 | return pathOrUri;
107 | }
108 | virtualPath = parsedUri.fsPath;
109 | } else {
110 | // Handle absolute or relative paths
111 | if (pathOrUri.startsWith(tempDir)) {
112 | return URI.from({ scheme: "file", path: pathOrUri }).toString();
113 | }
114 | virtualPath = pathOrUri;
115 | }
116 |
117 | // Ensure virtual path starts with /
118 | if (!virtualPath.startsWith("/")) {
119 | virtualPath = `/${virtualPath}`;
120 | }
121 |
122 | // Join with temp directory and return as URI
123 | const realPath = path.join(tempDir, virtualPath);
124 | return URI.from({ scheme: "file", path: realPath }).toString();
125 | } catch {
126 | return undefined;
127 | }
128 | }
129 |
130 | /**
131 | * Convert a real temp file URI/path to a virtual URI.
132 | *
133 | * Examples:
134 | * - "file:///tmp/dir/foobar.tsx" -> "file:///foobar.tsx"
135 | * - "/tmp/dir/foobar.tsx" -> "file:///foobar.tsx"
136 | * - "/foobar.tsx" -> "file:///foobar.tsx" (unchanged if not temp)
137 | * - "foobar.tsx" -> "file:///foobar.tsx"
138 | * - "http://example.com/foobar.tsx" -> "http://example.com/foobar.tsx" (unchanged)
139 | */
140 | export function tempDirUriToVirtualUri(
141 | pathOrUri: string,
142 | tempDir: string,
143 | ): string {
144 | // If it's a non-file URI, return as is
145 | if (/^\w+:/i.test(pathOrUri) && !pathOrUri.startsWith("file://")) {
146 | return pathOrUri;
147 | }
148 |
149 | let actualPath: string;
150 |
151 | if (pathOrUri.startsWith("file://")) {
152 | const uri = URI.parse(pathOrUri);
153 | actualPath = uri.path;
154 | } else {
155 | actualPath = pathOrUri;
156 | }
157 |
158 | // If it's in the temp directory, remove the temp prefix
159 | if (actualPath.startsWith(tempDir)) {
160 | const relativePath = actualPath.substring(tempDir.length);
161 | return URI.from({
162 | scheme: "file",
163 | path: relativePath || "/",
164 | }).toString();
165 | }
166 |
167 | // If not a temp path, ensure it starts with / and return as file URI
168 | if (!actualPath.startsWith("/")) {
169 | actualPath = `/${actualPath}`;
170 | }
171 |
172 | return URI.from({ scheme: "file", path: actualPath }).toString();
173 | }
174 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSWSServer/procs/LSProc.ts:
--------------------------------------------------------------------------------
1 | import { type ChildProcess, spawn } from "node:child_process";
2 | import * as fs from "node:fs";
3 | import type { Readable, Writable } from "node:stream";
4 | import { $ } from "execa";
5 |
6 | interface LSProcOptions {
7 | /** command to run the LS process */
8 | lsCommand: string;
9 | /** Arguments to pass to the LS process */
10 | lsArgs: string[];
11 | /** Callback for when LS process exits */
12 | onExit?: (code: number | null, signal: NodeJS.Signals | null) => void;
13 | /** Callback for when LS process errors */
14 | onError?: (error: Error) => void;
15 | /**
16 | * File to stream stdout to.
17 | *
18 | * Useful since the LSP naturally communicates over stdout/stderr, so teeing
19 | * it to a file is often useful for debugging.
20 | **/
21 | lsStdoutLogPath?: string;
22 | /**
23 | * File to stream stderr to.
24 | *
25 | * Useful since the LSP naturally communicates over stdout/stderr, so teeing
26 | * it to a file is often useful for debugging.
27 | **/
28 | lsStderrLogPath?: string;
29 | }
30 |
31 | /**
32 | * The LSProc class manages a Language Server process, allowing for spawning,
33 | * killing, and logging of the process's output. It's a thin wrapper around
34 | * Node.js's ChildProcess, and exposes properties like .stdin, .stdout, and .stderr directly.
35 | */
36 | export class LSProc {
37 | public proc: ChildProcess | null = null;
38 | public spawnedAt: Date | null = null;
39 |
40 | public readonly lsCommand: string;
41 | public readonly lsArgs: string[];
42 | public readonly lsStdoutLogPath?: string;
43 | public readonly lsStderrLogPath?: string;
44 |
45 | public readonly stdoutLogFile?: fs.WriteStream;
46 | public readonly stderrLogFile?: fs.WriteStream;
47 |
48 | public readonly onExit?: (
49 | code: number | null,
50 | signal: NodeJS.Signals | null,
51 | ) => void | Promise;
52 | public readonly onError?: (error: Error) => void | Promise;
53 |
54 | constructor({
55 | lsCommand,
56 | lsArgs,
57 | onExit,
58 | onError,
59 | lsStdoutLogPath,
60 | lsStderrLogPath,
61 | }: LSProcOptions) {
62 | this.lsCommand = lsCommand;
63 | this.lsArgs = lsArgs;
64 | this.lsStdoutLogPath = lsStdoutLogPath;
65 | this.lsStderrLogPath = lsStderrLogPath;
66 | this.onExit = onExit;
67 | this.onError = onError;
68 | }
69 |
70 | public get pid(): number | null {
71 | return this.proc?.pid ?? null;
72 | }
73 |
74 | public get stdin(): Writable | null {
75 | return this.proc?.stdin ?? null;
76 | }
77 |
78 | public get stdout(): Readable | null {
79 | return this.proc?.stdout ?? null;
80 | }
81 |
82 | public get stderr(): Readable | null {
83 | return this.proc?.stderr ?? null;
84 | }
85 |
86 | public async kill(): Promise {
87 | if (!this.proc) return;
88 |
89 | try {
90 | this.proc.kill("SIGTERM");
91 |
92 | await new Promise((resolve) => {
93 | this.proc!.once("exit", async () => {
94 | await this.onExit?.(
95 | this.proc?.exitCode || null,
96 | this.proc?.signalCode || null,
97 | );
98 | resolve();
99 | });
100 | });
101 | } catch (error) {
102 | if (!(error instanceof Error)) {
103 | throw new Error(`Unknown error when killing process: ${error}`);
104 | }
105 | this.onError?.(error);
106 | }
107 | }
108 |
109 | public spawn(): void {
110 | try {
111 | this.proc = spawn(this.lsCommand, this.lsArgs, {
112 | stdio: ["pipe", "pipe", "pipe"],
113 | });
114 |
115 | this.spawnedAt = new Date();
116 |
117 | this.#setupStdoutLogging();
118 | this.#setupStderrLogging();
119 |
120 | this.#registerProcCompletion();
121 | } catch (error) {
122 | if (!(error instanceof Error))
123 | throw new Error(`Unknown error when spawning process: ${error}`);
124 | this.onError?.(error);
125 | }
126 | }
127 |
128 | /**
129 | * Get the past n lines from stderr and stdout log files.
130 | *
131 | * @param n The number of lines to retrieve from the end of the log file.
132 | */
133 | public async getLogTail(n: number): Promise<[string, string]> {
134 | const stdoutTail = this.lsStdoutLogPath
135 | ? (await $("tail", ["-n", n.toString(), this.lsStdoutLogPath])).stdout
136 | : "";
137 | const stderrTail = this.lsStderrLogPath
138 | ? (await $("tail", ["-n", n.toString(), this.lsStderrLogPath])).stdout
139 | : "";
140 |
141 | return [stdoutTail, stderrTail];
142 | }
143 |
144 | #setupLoggingForStream(stream: Readable, logFilePath: string) {
145 | if (!stream || !logFilePath) return;
146 |
147 | try {
148 | const logFile = fs.createWriteStream(logFilePath, { flags: "a" });
149 | stream.pipe(logFile);
150 | return logFile;
151 | } catch (error) {
152 | if (!(error instanceof Error)) {
153 | throw new Error(`Unknown error when setting up logging: ${error}`);
154 | }
155 | this.onError?.(error);
156 | return null;
157 | }
158 | }
159 |
160 | #setupStdoutLogging() {
161 | if (!this.proc?.stdout || !this.lsStdoutLogPath) return;
162 | this.#setupLoggingForStream(this.proc.stdout, this.lsStdoutLogPath);
163 | }
164 |
165 | #setupStderrLogging() {
166 | if (!this.proc?.stderr || !this.lsStderrLogPath) return;
167 | this.#setupLoggingForStream(this.proc.stderr, this.lsStderrLogPath);
168 | }
169 |
170 | #registerProcCompletion() {
171 | if (!this.proc) return;
172 |
173 | this.proc.on("exit", async (code, signal) => {
174 | await this.onExit?.(code, signal);
175 |
176 | // Close log files
177 | this.stdoutLogFile?.end();
178 | this.stderrLogFile?.end();
179 |
180 | this.proc = null;
181 | });
182 |
183 | this.proc.on("error", async (error) => {
184 | await this.onError?.(error);
185 | });
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/codemirror-ls/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { Text } from "@codemirror/state";
2 | import type { EditorView } from "@codemirror/view";
3 | import type * as LSP from "vscode-languageserver-protocol";
4 |
5 | export function posToOffset(
6 | doc: Text,
7 | pos: { line: number; character: number },
8 | ): number | undefined {
9 | if (pos.line >= doc.lines) {
10 | // Next line (implying the end of the document)
11 | if (pos.character === 0) {
12 | return doc.length;
13 | }
14 | return;
15 | }
16 | const offset = doc.line(pos.line + 1).from + pos.character;
17 | if (offset > doc.length) {
18 | return;
19 | }
20 | return offset;
21 | }
22 |
23 | export function posToOffsetOrZero(
24 | doc: Text,
25 | pos: { line: number; character: number },
26 | ): number {
27 | return posToOffset(doc, pos) || 0;
28 | }
29 |
30 | export function offsetToPos(
31 | doc: Text,
32 | offset: number,
33 | ): { character: number; line: number } {
34 | const line = doc.lineAt(offset);
35 | return {
36 | character: offset - line.from,
37 | line: line.number - 1,
38 | };
39 | }
40 |
41 | export function defaultContentFormatter(
42 | contents:
43 | | LSP.MarkupContent
44 | | LSP.MarkedString
45 | | LSP.MarkedString[]
46 | | undefined,
47 | ): HTMLElement {
48 | const element = document.createElement("div");
49 | if (!contents) {
50 | return element;
51 | }
52 | if (isLSPMarkupContent(contents)) {
53 | element.innerText = contents.value;
54 | return element;
55 | }
56 | if (Array.isArray(contents)) {
57 | contents
58 | .map((c) => defaultContentFormatter(c))
59 | .filter(Boolean)
60 | .forEach((child) => element.appendChild(child));
61 | return element;
62 | }
63 | if (typeof contents === "string") {
64 | element.innerText = contents;
65 | return element;
66 | }
67 | return element;
68 | }
69 |
70 | /**
71 | * Finds the longest common prefix among an array of strings.
72 | *
73 | * @param strs - Array of strings to analyze
74 | * @returns The longest common prefix string
75 | */
76 | function longestCommonPrefix(strs: string[]): string {
77 | if (strs.length === 0) return "";
78 | if (strs.length === 1) return strs[0] || "";
79 |
80 | // Sort the array
81 | strs.sort();
82 |
83 | // Get the first and last string after sorting
84 | const firstStr = strs[0] || "";
85 | const lastStr = strs[strs.length - 1] || "";
86 |
87 | // Find the common prefix between the first and last string
88 | let i = 0;
89 | while (i < firstStr.length && firstStr[i] === lastStr[i]) {
90 | i++;
91 | }
92 |
93 | return firstStr.substring(0, i);
94 | }
95 |
96 | /**
97 | * Analyzes completion items to generate a regex pattern for matching prefixes.
98 | * Used to determine what text should be considered part of the current token
99 | * when filtering completion items.
100 | *
101 | * @param items - Array of LSP completion items to analyze
102 | * @returns A RegExp object that matches anywhere in a string
103 | */
104 | export function prefixMatch(items: LSP.CompletionItem[]) {
105 | if (items.length === 0) {
106 | return undefined;
107 | }
108 |
109 | const labels = items.map((item) => item.textEdit?.newText || item.label);
110 | const prefix = longestCommonPrefix(labels);
111 |
112 | if (prefix === "") {
113 | return undefined;
114 | }
115 |
116 | const explodedPrefixes: string[] = [];
117 | for (let i = 0; i < prefix.length; i++) {
118 | const slice = prefix.slice(0, i + 1);
119 | if (slice.length > 0) {
120 | // Escape special regex characters to avoid pattern errors
121 | const escapedSlice = slice.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
122 | explodedPrefixes.push(escapedSlice);
123 | }
124 | }
125 | const orPattern = explodedPrefixes.join("|");
126 | // Create regex pattern that matches the common prefix for each possible prefix by dropping the last character
127 | const pattern = new RegExp(`(${orPattern})$`);
128 |
129 | return pattern;
130 | }
131 |
132 | export function isLSPMarkupContent(
133 | contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[],
134 | ): contents is LSP.MarkupContent {
135 | return (
136 | (contents as LSP.MarkupContent).kind !== undefined || // TODO: Make sure this check is right
137 | (contents as LSP.MarkupContent).value !== undefined
138 | );
139 | }
140 |
141 | export function isEmptyDocumentation(
142 | documentation:
143 | | LSP.MarkupContent
144 | | LSP.MarkedString
145 | | LSP.MarkedString[]
146 | | undefined,
147 | ) {
148 | if (documentation == null) {
149 | return true;
150 | }
151 | if (Array.isArray(documentation)) {
152 | return (
153 | documentation.length === 0 || documentation.every(isEmptyDocumentation)
154 | );
155 | }
156 | if (typeof documentation === "string") {
157 | return isEmptyIshValue(documentation);
158 | }
159 | const value = documentation.value;
160 | if (typeof value === "string") {
161 | return isEmptyIshValue(value);
162 | }
163 | return false;
164 | }
165 |
166 | function isEmptyIshValue(value: unknown) {
167 | if (value == null) {
168 | return true;
169 | }
170 | if (typeof value === "string") {
171 | // Empty string or string with only whitespace or backticks
172 | return value.trim() === "" || /^[\s\n`]*$/.test(value);
173 | }
174 | return false;
175 | }
176 |
177 | /**
178 | * Check if a given range is within the current document bounds.
179 | *
180 | * @param range The range to check.
181 | * @param view The editor view containing the document.
182 | * @returns Whether the range is within the document bounds.
183 | */
184 | export function isInCurrentDocumentBounds(
185 | range: LSP.Range,
186 | view: EditorView,
187 | ): boolean {
188 | const { start, end } = range;
189 | return (
190 | start.line >= 0 &&
191 | end.line < view.state.doc.lines &&
192 | start.character >= 0 &&
193 | end.character <= view.state.doc.lineAt(end.line).length
194 | );
195 | }
196 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSWSServer/procs/LSProcManager.ts:
--------------------------------------------------------------------------------
1 | import { LSProc } from "~/LSWSServer/procs/LSProc.js";
2 | import { defaultLogger, type Logger } from "~/logger.js";
3 |
4 | export interface LSProcManagerOptions {
5 | /** Command to spawn the LS process */
6 | lsCommand: string;
7 | /** Arguments to pass to the LS process */
8 | lsArgs: string[];
9 | /** Maximum number of LS processes to allow at once. 0 for unlimited. */
10 | maxProcs?: number;
11 | /** Callback for when LS process errors */
12 | onProcError?: (sessionId: string, error: Error) => void;
13 | /** Callback for when LS process exits */
14 | onProcExit?: (
15 | /** Session ID for the LS process that exited */
16 | sessionId: string,
17 | code: number | null,
18 | signal: string | null,
19 | /**
20 | * The LSProc instance that exited.
21 | *
22 | * By the time that the proc exits it may have been removed from the session
23 | * map. Use this instead if you still need to access it.
24 | **/
25 | lsProc: LSProc,
26 | ) => void;
27 | lsStdoutLogPath?: string;
28 | lsStderrLogPath?: string;
29 | logger?: Logger;
30 | }
31 |
32 | /**
33 | * The LSProcManager manages Language Server processes for different sessions by session ID,
34 | * providing utility functions to spawn, release, and clean up these processes.
35 | *
36 | * This is used by the LS WebSocket Server to manage LS processes for different
37 | * client sessions, but can also be used in other contexts where managing
38 | * multiple LS processes is needed.
39 | */
40 | export class LSProcManager {
41 | public readonly lsCommand: string;
42 | public readonly lsArgs: string[];
43 | public readonly procs: Map;
44 | public readonly maxProcs: number;
45 | public readonly lsStdoutLogPath?: string;
46 | public readonly lsStderrLogPath?: string;
47 |
48 | #logger: Logger;
49 |
50 | private onProcError?: (
51 | sessionId: string,
52 | error: Error,
53 | ) => void | Promise;
54 | private onProcExit?: (
55 | sessionId: string,
56 | code: number | null,
57 | signal: NodeJS.Signals | null,
58 | lsProc: LSProc,
59 | ) => void | Promise;
60 |
61 | constructor({
62 | lsCommand,
63 | lsArgs,
64 | lsStdoutLogPath,
65 | lsStderrLogPath,
66 | maxProcs = 0,
67 | onProcError,
68 | onProcExit,
69 | logger = defaultLogger,
70 | }: LSProcManagerOptions) {
71 | this.lsCommand = lsCommand;
72 | this.lsArgs = lsArgs;
73 | this.lsStdoutLogPath = lsStdoutLogPath;
74 | this.lsStderrLogPath = lsStderrLogPath;
75 | this.#logger = logger;
76 |
77 | this.procs = new Map();
78 | this.maxProcs = maxProcs;
79 | this.onProcError = onProcError;
80 | this.onProcExit = onProcExit;
81 | }
82 |
83 | /**
84 | * Retrieves the LS process for a given session ID, if it exists.
85 | *
86 | * @param sessionId The session ID for which to retrieve the LS process.
87 | * @returns The LSProc instance for the session, or null if not found.
88 | */
89 | public getProc(sessionId: string): LSProc | null {
90 | return this.procs.get(sessionId) ?? null;
91 | }
92 |
93 | /**
94 | * Retrieves an existing LS process for the given session ID, or spawns a new
95 | * one if it doesn't exist.
96 | *
97 | * @param sessionId The session ID for which to get or create the LS process.
98 | * @returns The LSProc instance for the session.
99 | */
100 | public getOrCreateProc(sessionId: string): LSProc {
101 | const existing = this.procs.get(sessionId);
102 |
103 | if (existing) {
104 | this.#logger.info(
105 | { sessionId, pid: existing.pid },
106 | "Reusing existing LS process",
107 | );
108 | return existing;
109 | }
110 |
111 | const lsProc = this.#spawn(sessionId);
112 | this.procs.set(sessionId, lsProc);
113 |
114 | this.#logger.info({ sessionId, pid: lsProc.pid }, "Spawning LS process");
115 | return lsProc;
116 | }
117 |
118 | /**
119 | * Releases the LS process for a given session ID. Kills the process if it exists, or
120 | * silently does nothing if no process is found for that session ID.
121 | *
122 | * @param sessionId The session ID for which to release the LS process.
123 | */
124 | public async releaseProc(sessionId: string): Promise {
125 | const proc = this.procs.get(sessionId);
126 |
127 | if (proc) {
128 | this.#logger.info({ sessionId, pid: proc.pid }, "Releasing LS process");
129 | await proc.kill();
130 | this.procs.delete(sessionId);
131 | } else {
132 | this.#logger.warn({ sessionId }, "No LS process found to release");
133 | }
134 | }
135 |
136 | #spawn(sessionId: string): LSProc {
137 | const lsProc = new LSProc({
138 | lsCommand: this.lsCommand,
139 | lsArgs: this.lsArgs,
140 | onExit: async (code, signal) => {
141 | await this.onProcExit?.(sessionId, code, signal, lsProc);
142 |
143 | this.#logger.info({ sessionId, code }, "LS process exited");
144 | this.procs.delete(sessionId);
145 | },
146 | onError: async (error) => {
147 | await this.onProcError?.(sessionId, error);
148 | },
149 | lsStdoutLogPath: this.lsStdoutLogPath,
150 | lsStderrLogPath: this.lsStderrLogPath,
151 | });
152 |
153 | lsProc.spawn();
154 |
155 | this.#enforceMaxProcs();
156 |
157 | return lsProc;
158 | }
159 |
160 | #enforceMaxProcs() {
161 | if (this.maxProcs <= 0 || this.procs.size < this.maxProcs) {
162 | // -n will allow unlimited processes
163 | return;
164 | }
165 |
166 | // Sort processes by spawn time
167 | const processes = Array.from(this.procs.entries()).sort(
168 | ([, procA], [, procB]) => {
169 | const timeA = procA.spawnedAt?.getTime() || 0;
170 | const timeB = procB.spawnedAt?.getTime() || 0;
171 | return timeA - timeB;
172 | },
173 | );
174 |
175 | // Remove oldest processes until we're under the limit
176 | while (processes.length >= this.maxProcs) {
177 | const [sessionId, proc] = processes.shift()!;
178 | this.#logger.info(
179 | { sessionId, pid: proc.pid, spawnTime: proc.spawnedAt },
180 | "Killing oldest LS process to make room for new one",
181 | );
182 | proc.kill();
183 | this.procs.delete(sessionId);
184 | }
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/ls-ws-server/README.md:
--------------------------------------------------------------------------------
1 | # LS WebSocket Server
2 |
3 | This is a WebSocket server for language servers that allows clients (typically code editors) to communicate to a running language server process.
4 |
5 | It is meant to be used with your framework of choice, and provides a simple `handleNewWebsocket` handler for when a new connection has been upgraded and should be wired to an LSP.
6 |
7 | Here's a simple example set up for the Deno language server:
8 |
9 | ```typescript
10 | // ls-proxy.ts
11 |
12 | import { LSWSServer } from "vtls-server";
13 | import { Hono } from "hono";
14 | import { z } from "zod";
15 | import { zValidator } from "@hono/zod-validator";
16 |
17 | const lsWsServer = new LSWSServer({
18 | lsCommand: "deno", // proxy LS
19 | lsArgs: ["run", "-A", "./ls-proxy.ts"],
20 | lsLogPath: Deno.makeTempDirSync({ prefix: "vtlsp-procs" }),
21 | });
22 |
23 | const app = new Hono()
24 | .get("/", zValidator("query", z.object({ session: z.string() })), (c) => {
25 | const { socket, response } = Deno.upgradeWebSocket(c.req.raw);
26 | return lsWsServer.handleNewWebsocket(
27 | socket,
28 | c.req.valid("query").session,
29 | );
30 | })
31 |
32 | Deno.serve({ port: 5002, hostname: "0.0.0.0" }, app.fetch);
33 | ```
34 |
35 | Including a small language server "proxy" server:
36 |
37 | ```ts
38 | // main.ts
39 |
40 | import { LSroxy, utils } from "vtls-server";
41 |
42 | const TEMP_DIR = await Deno.makeTempDir({ prefix: "vtlsp-proxy" });
43 |
44 | const onExit = async () => await Deno.remove(TEMP_DIR, { recursive: true });
45 | Deno.addSignalListener("SIGINT", onExit);
46 | Deno.addSignalListener("SIGTERM", onExit);
47 |
48 | const proxy = new LSProxy({
49 | name: "lsp-server",
50 | cwd: TEMP_DIR, // Where the LS gets spawned from
51 | exec: {
52 | command: "deno", // real LS (we're using deno to run, and proxy, the deno language server)
53 | args: ["lsp", "-q"],
54 | },
55 | // Also, you can use procToClientMiddlewares, procToClientHandlers, and clientToProcHandlers
56 | clientToProcMiddlewares: {
57 | initialize: async (params) => {
58 | await Deno.writeTextFile(`${TEMP_DIR}/deno.json`, JSON.stringify({})); // Create a deno.json in the temp dir
59 | return params;
60 | },
61 | "textDocument/publishDiagnostics": async (params) => { // Params are automatically typed! All "official" LSP methods have strong types
62 | if (params.uri.endsWith(".test.ts")) {
63 | return {
64 | ls_proxy_code: "cancel_response" // A "magic" code that makes it so that the message is NOT passed on to the LS
65 | }
66 | }
67 | }
68 | },
69 | uriConverters: {
70 | fromProcUri: (uriString: string) => {
71 | // Takes URIs like /tmp/fije3189rj/buzz/foo.ts and makes it /buzz/foo.ts
72 | return utils.tempDirUriToVirtualUri(uriString, TEMP_DIR);
73 | },
74 | toProcUri: (uriString: string) => {
75 | // Takes URIs like /bar/foo.ts and makes it /tmp/fije3189rj/foo.ts
76 | return utils.virtualUriToTempDirUri(uriString, TEMP_DIR)!;
77 | },
78 | },
79 | });
80 |
81 | proxy.listen(); // Listens by default on standard in / out, and acts as a real LS
82 | ```
83 |
84 | We're using Deno, but you could just as well write this in Node. To run it, you'd use a command like:
85 |
86 | ```bash
87 | deno run -A main.ts
88 | ```
89 |
90 | Or if you want the server to crash if a language server process has a "bad exit" (crash),
91 |
92 | ```bash
93 | EXIT_ON_LS_BAD_EXIT=1 deno run -A main.ts
94 | ```
95 |
96 | ## Routing to LS processes
97 |
98 | Every connection to our WebSocket language server requires a `?session={}`. The session IDs are unique identifiers for a language server process; if you connect to the same session in multiple places you will be "speaking" to the same language server process. As a result of this design, the WebSocket server allows multicasting language server connections. Many clients (for example, tabs) can connect to the same language server process, and when they make requests to the language server (like go to definition), only the requesting connection receives a response for their requests.
99 |
100 | There are some additional subtileies here that you may need to think about if you're designing a language server with multiple clients. Some language servers, like the Deno language server, may crash or exhibit weird behavior if clients try to initialize and they are already initialized. Additionally, during the LSP handshake, clients learn information about supported capabilities of the language server. One easy solution to this is to use an LS proxy to "cache" the initialize handshake, so that clients that come in the future will not send additional initialize requests to the language server.
101 |
102 | ## LS Proxying Server
103 |
104 | This library exposes a language server proxy builder, which makes it really easy to automatically transform requests going to or coming out from the language server. With the language server proxy, you can:
105 |
106 | Language server processes communicate via JSON-RPC messages - either "requests" or "notifications". Usually they communicate via inbound messages on standard in and outbound messages on standard out.
107 |
108 | Notifications are send and forget. An example of a notification we send to the language server may look like
109 |
110 | ```json
111 | { "jsonrpc": "2.0",
112 | "method": "textDocument/didChange",
113 | "params": { "textDocument": { "uri": "file:///document.txt", "version": 2 }, "contentChanges": [ { "text": "Hello" } ] }
114 | }
115 | ```
116 |
117 | Requests get exactly one reply, and look like
118 |
119 | ```json
120 | {
121 | "jsonrpc": "2.0",
122 | "id": 1,
123 | "method": "textDocument/hover",
124 | "params": { "textDocument": { "uri": "file:///document.txt" }, "position": { "line": 0, "character": 2 } }
125 | }
126 | ```
127 |
128 | ```json
129 | {
130 | "jsonrpc": "2.0",
131 | "id": 1,
132 | "result": { "contents": { "kind": "plaintext", "value": "Hover information" }
133 | }
134 | }
135 | ```
136 |
137 | With our language server proxy builder, you can
138 |
139 | - Intercept notifications that leave the language server, and maybe modify or cancel them, or vice versa.
140 | - Intercept requests that come to the language server, and maybe modify the request parameters, or the response.
141 | - Define custom handlers that override existing ones or implement entirely new language server methods.
142 |
143 | And, the result is a new language server that also reads from standard in and writes to standard out, but may transform messages before they get to the process, or the client.
144 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/inlayHints.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module inlayHints
3 | * @description Extensions for handling inlay hints in the editor.
4 | *
5 | * Inlay hints provide additional information about code elements,
6 | * such as type annotations, parameter names, and other contextual details. They
7 | * are the things that show up inline in your code, like the names of function
8 | * parameters.
9 | *
10 | * @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_inlayHint
11 | * @todo Fancy editors only request inlay hints for the visible part of the document
12 | * @todo Add resolve support
13 | */
14 |
15 | import {
16 | Decoration,
17 | type EditorView,
18 | ViewPlugin,
19 | type ViewUpdate,
20 | WidgetType,
21 | } from "@codemirror/view";
22 | import PQueue from "p-queue";
23 | import type * as LSP from "vscode-languageserver-protocol";
24 | import { LSCore } from "../LSPlugin.js";
25 | import { offsetToPos, posToOffset } from "../utils.js";
26 | import type { LSExtensionGetter, Renderer } from "./types.js";
27 |
28 | /**
29 | * Renderer function for inlay hints.
30 | *
31 | * Some inlay hints are "fancy" and can be resolved for additional
32 | * information/actions (like special tooltips).
33 | */
34 | export type InlayHintsRenderer = Renderer<
35 | [hint: LSP.InlayHint, resolve: () => Promise]
36 | >;
37 |
38 | export interface InlayHintArgs {
39 | render: InlayHintsRenderer;
40 | /** Inlay hints will be debounced to only start showing up after this long (in ms) */
41 | debounceTime?: number;
42 | /** Whether to clear the currently shown inlay hints when the user starts editing. */
43 | clearOnEdit?: boolean;
44 | /** Whether the inlay hints come before or after the cursor. */
45 | sideOfCursor?: "after" | "before";
46 | }
47 |
48 | export const getInlayHintExtensions: LSExtensionGetter = ({
49 | render,
50 | debounceTime = 100,
51 | clearOnEdit = false,
52 | sideOfCursor = "after",
53 | }: InlayHintArgs) => {
54 | return [
55 | ViewPlugin.fromClass(
56 | class {
57 | /**
58 | * Pending textDocument/inlayHint requests queue.
59 | *
60 | * If we aren't debounced "enough" and request many at close to the same
61 | * time, we want to make sure we apply them in the order we sent them,
62 | * not the order they return.
63 | **/
64 | #requestQueue = new PQueue({ concurrency: 1 });
65 |
66 | #debounceTimeoutId: number | null = null;
67 | #view: EditorView;
68 |
69 | inlayHints: LSP.InlayHint[] | null = null;
70 |
71 | constructor(view: EditorView) {
72 | this.#view = view;
73 | void this.#queueRefreshInlayHints();
74 | }
75 |
76 | update(update: ViewUpdate) {
77 | if (!update.docChanged) return;
78 |
79 | if (clearOnEdit) {
80 | // the .decorations() provider is naturally triggered on updates so
81 | // no need to dispatch (also, we cannot dispatch DURING an update).
82 | this.inlayHints = [];
83 | }
84 |
85 | void this.#queueRefreshInlayHints();
86 | }
87 |
88 | async #queueRefreshInlayHints() {
89 | if (this.#debounceTimeoutId) {
90 | window.clearInterval(this.#debounceTimeoutId);
91 | }
92 |
93 | this.#debounceTimeoutId = window.setTimeout(async () => {
94 | const lsCore = LSCore.ofOrThrow(this.#view);
95 |
96 | if (lsCore.client.capabilities?.inlayHintProvider === false) {
97 | return null;
98 | }
99 |
100 | const endOfDocPos = this.#view.state.doc.length - 1;
101 | const newInlayHints = this.#requestQueue.add(
102 | async () =>
103 | await lsCore.client.request("textDocument/inlayHint", {
104 | textDocument: { uri: lsCore.documentUri },
105 | range: {
106 | start: { line: 0, character: 0 },
107 | end: offsetToPos(this.#view.state.doc, endOfDocPos),
108 | },
109 | }),
110 | );
111 | this.inlayHints = (await newInlayHints) ?? [];
112 |
113 | // This is an event "in the middle of nowhere" -- it's based on a
114 | // timeout. We need to dispatch to force a requery of decorations.
115 | this.#view.dispatch();
116 | }, debounceTime);
117 | }
118 |
119 | get decorations() {
120 | if (this.inlayHints === null) return Decoration.none;
121 |
122 | const decorations = this.inlayHints
123 | .map((hint) => {
124 | const offset = posToOffset(this.#view.state.doc, hint.position);
125 | if (offset === undefined) return null;
126 |
127 | return Decoration.widget({
128 | widget: new InlayHintWidget(hint, render, this.#view),
129 | // Side is a number -1000 to 1000, which orders the widgets. >0
130 | // means after the cursor, <0 means before the cursor.
131 | side: sideOfCursor === "after" ? 1 : -1,
132 | }).range(offset);
133 | })
134 | .filter((widget) => widget !== null);
135 |
136 | return Decoration.set(decorations, true);
137 | }
138 | },
139 | {
140 | decorations: (v) => v.decorations,
141 | },
142 | ),
143 | ];
144 | };
145 |
146 | class InlayHintWidget extends WidgetType {
147 | #inlayHint: LSP.InlayHint;
148 | #render: InlayHintsRenderer;
149 | #view: EditorView;
150 |
151 | constructor(
152 | inlayHint: LSP.InlayHint,
153 | render: InlayHintsRenderer,
154 | view: EditorView,
155 | ) {
156 | super();
157 |
158 | this.#inlayHint = inlayHint;
159 | this.#render = render;
160 | this.#view = view;
161 | }
162 |
163 | override toDOM() {
164 | const span = document.createElement("span");
165 | span.className = "cm-inlay-hint";
166 | void this.#render(span, this.#inlayHint, async () => {
167 | const lsCore = LSCore.ofOrThrow(this.#view);
168 |
169 | // Some inlay hints have "fancy" extras that we should only render when
170 | // they come into view (this is the case when there is a "data" field)
171 | if ("data" in this.#inlayHint) {
172 | this.#inlayHint =
173 | (await lsCore.client.request("inlayHint/resolve", this.#inlayHint)) ??
174 | this.#inlayHint;
175 | }
176 |
177 | return this.#inlayHint;
178 | });
179 | return span;
180 | }
181 |
182 | override eq(other: InlayHintWidget) {
183 | return (
184 | this.#inlayHint.position.line === other.#inlayHint.position.line &&
185 | this.#inlayHint.position.character ===
186 | other.#inlayHint.position.character &&
187 | this.#inlayHint.label === other.#inlayHint.label
188 | );
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/demo/src/useLsCodemirror.tsx:
--------------------------------------------------------------------------------
1 | import { showDialog } from "@codemirror/view";
2 | import { LSClient, languageServerWithClient } from "@valtown/codemirror-ls";
3 | import { LSWebSocketTransport } from "@valtown/codemirror-ls/transport";
4 | import { useCallback, useMemo, useState } from "react";
5 | import ReactDOM from "react-dom/client";
6 | import type * as LSP from "vscode-languageserver-protocol";
7 | import { LSContents } from "./components/LSContents";
8 | import { LSContextMenu } from "./components/LSContextMenu";
9 | import { LSGoTo } from "./components/LSGoTo";
10 | import { LSInlayHint } from "./components/LSInlayHint";
11 | import { LSRename } from "./components/LSRename";
12 | import { LSSignatureHelp } from "./components/LSSignatureHelp";
13 | import { LSWindow } from "./components/LSWindow";
14 |
15 | export function useLsCodemirror({ path }: { path: string }): {
16 | extensions: ReturnType | null;
17 | connect: (url: string) => Promise;
18 | isConnected: boolean;
19 | } {
20 | const [lsClient, setLsClient] = useState(null);
21 | const [transport, setTransport] = useState(null);
22 |
23 | const connect = useCallback(
24 | async (url: string) => {
25 | const hadTransport = !!transport;
26 |
27 | if (transport) {
28 | transport.dispose();
29 | setTransport(null);
30 | setLsClient(null);
31 | }
32 |
33 | const newTransport = new LSWebSocketTransport(url, {});
34 | const newClient = new LSClient({
35 | transport: newTransport,
36 | workspaceFolders: [{ uri: "file:///demo", name: "Demo" }],
37 | initializationOptions: {
38 | // Deno needs this to enable inlay hints
39 | inlayHints: {
40 | parameterNames: {
41 | enabled: "all",
42 | suppressWhenArgumentMatchesName: true,
43 | },
44 | parameterTypes: { enabled: true },
45 | variableTypes: { enabled: true, suppressWhenTypeMatchesName: true },
46 | propertyDeclarationTypes: { enabled: true },
47 | functionLikeReturnTypes: { enabled: true },
48 | enumMemberValues: { enabled: true },
49 | },
50 | },
51 | });
52 |
53 | setTransport(newTransport);
54 | setLsClient(newClient);
55 |
56 | await newTransport.connect();
57 | if (hadTransport) {
58 | newClient.changeTransport(newTransport);
59 | newClient.initialize();
60 | }
61 | },
62 | [transport],
63 | );
64 |
65 | const extensions = useMemo(() => {
66 | if (!lsClient) return null;
67 |
68 | const renderContents = async (
69 | dom: HTMLElement,
70 | contents:
71 | | string
72 | | LSP.MarkupContent
73 | | LSP.MarkedString
74 | | LSP.MarkedString[],
75 | ) => {
76 | const root = ReactDOM.createRoot(dom);
77 | root.render( );
78 | };
79 |
80 | const lspExtensions = languageServerWithClient({
81 | client: lsClient,
82 | onError: (error, view) => {
83 | // Codemirror's native "dock" area for dialogs
84 | showDialog(view, { label: error.message });
85 | },
86 | documentUri: `file://${path}`,
87 | languageId: "typescript",
88 | sendDidOpen: true,
89 | features: {
90 | signatureHelp: {
91 | render: async (dom, data, activeSignature, activeParameter) => {
92 | dom.style.cssText = `
93 | max-width: 600px;
94 | max-height: 300px;
95 | overflow: auto;
96 | margin: 12px;
97 | transform: translateY(-16px);
98 | `;
99 | const root = ReactDOM.createRoot(dom);
100 | root.render(
101 | ,
106 | );
107 | },
108 | },
109 | hovers: {
110 | render: renderContents,
111 | },
112 | renames: {
113 | render: async (dom, placeholder, onClose, onComplete) => {
114 | const root = ReactDOM.createRoot(dom);
115 | root.render(
116 | {
120 | onComplete(newName);
121 | onClose();
122 | }}
123 | />,
124 | );
125 | },
126 | },
127 | linting: {
128 | render: async (dom, message) => {
129 | const root = ReactDOM.createRoot(dom);
130 | root.render( );
131 | },
132 | },
133 | completion: {
134 | completionMatchBefore: /.+/,
135 | render: renderContents,
136 | },
137 | contextMenu: {
138 | render: async (dom, callbacks, onDismiss) => {
139 | const container = document.createElement("div");
140 | container.classList.add("ls-context-menu-container");
141 | const root = ReactDOM.createRoot(container);
142 | root.render( );
143 | dom.appendChild(container);
144 | },
145 | referencesArgs: {
146 | onExternalReference: (uri) => {
147 | // biome-ignore lint/suspicious/noConsole: for demo
148 | console.log("Go to external reference from context menu", uri);
149 | },
150 | },
151 | },
152 | references: {
153 | render: async (dom, references, goToReference, onClose, kind) => {
154 | const root = ReactDOM.createRoot(dom);
155 | root.render(
156 | ,
162 | );
163 | },
164 | onExternalReference: (uri) => {
165 | // biome-ignore lint/suspicious/noConsole: for demo
166 | console.log("Go to external reference", uri);
167 | },
168 | modClickForDefinition: true,
169 | },
170 | window: {
171 | render: async (dom, message: LSP.ShowMessageParams, onDismiss) => {
172 | const root = ReactDOM.createRoot(dom);
173 | root.render( );
174 | },
175 | },
176 | inlayHints: {
177 | render: async (dom, hints) => {
178 | const root = ReactDOM.createRoot(dom);
179 | const hint = Array.isArray(hints) ? hints[0] : hints;
180 | root.render( );
181 | },
182 | },
183 | },
184 | });
185 |
186 | return lspExtensions;
187 | }, [lsClient, path]);
188 |
189 | return {
190 | extensions,
191 | connect,
192 | isConnected: !!lsClient,
193 | };
194 | }
195 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSProxy/types.ts:
--------------------------------------------------------------------------------
1 | /** biome-ignore-all lint/suspicious/noExplicitAny: specifically broad handlers */
2 |
3 | import type { Logger } from "~/logger.js";
4 | import type { codes } from "./codes.js";
5 | import type { LSPNotifyMap, LSPRequestMap } from "./types.lsp.js";
6 |
7 | type MaybePromise = T | Promise;
8 |
9 | export type LSProxyCode = (typeof codes)[keyof typeof codes];
10 |
11 | // Let the proxy include a "ls_proxy_code" property to change how the request is handled. Right
12 | // now we just support the "cancel_response" code, which tells the proxy to cancel the response
13 | // to the client (or process).
14 | export type MaybeWithLSProxyCode = T | (T & { ls_proxy_code: LSProxyCode });
15 |
16 | export type ParamsMiddlewareFunction = (
17 | params: T,
18 | ) => MaybePromise>; // modifies params
19 |
20 | export type ResultMiddlewareFunction = (
21 | result: T,
22 | params?: K,
23 | ) => MaybePromise>; // modifies result
24 |
25 | export type CatchAllMiddlewareFunction = (
26 | method: string,
27 | params: unknown,
28 | result?: unknown,
29 | ) => MaybePromise>;
30 |
31 | export type HandlerFunction = (
32 | params: T,
33 | ) => MaybePromise>;
34 |
35 | export type CatchAllHandlerFunction = (
36 | method: string,
37 | params: unknown,
38 | ) => MaybePromise>;
39 |
40 | export type LSProxyClientToProcHandlers = {
41 | [K in keyof LSPNotifyMap]?: HandlerFunction;
42 | } & {
43 | [K in keyof LSPRequestMap]?: HandlerFunction<
44 | LSPRequestMap[K][0],
45 | LSPRequestMap[K][1]
46 | >;
47 | } & Record;
48 |
49 | export type LSProxyProcToClientHandlers = {
50 | [K in keyof LSPNotifyMap]?: HandlerFunction;
51 | } & {
52 | [K in keyof LSPRequestMap]?: HandlerFunction<
53 | LSPRequestMap[K][1],
54 | LSPRequestMap[K][1]
55 | >;
56 | } & Record;
57 |
58 | export type LSProxyClientToProcMiddlewares = {
59 | [K in keyof LSPNotifyMap]?: ParamsMiddlewareFunction;
60 | } & {
61 | [K in keyof LSPRequestMap]?: ResultMiddlewareFunction<
62 | LSPRequestMap[K][0],
63 | LSPRequestMap[K][1]
64 | >;
65 | } & Record<
66 | string,
67 | | ParamsMiddlewareFunction
68 | | ResultMiddlewareFunction
69 | | CatchAllMiddlewareFunction
70 | >;
71 |
72 | export type LSProxyProcToClientMiddlewares = {
73 | [K in keyof LSPNotifyMap]?: ParamsMiddlewareFunction;
74 | } & {
75 | // (result)
76 | [K in keyof LSPRequestMap]?: ResultMiddlewareFunction<
77 | LSPRequestMap[K][1],
78 | LSPRequestMap[K][0]
79 | >;
80 | } & Record<
81 | // (result, original-params)
82 | string,
83 | | ParamsMiddlewareFunction
84 | | ResultMiddlewareFunction
85 | | CatchAllMiddlewareFunction
86 | >; // (method, original-params, result)
87 |
88 | export type LSProxyCallbacks = {
89 | onNotification?: (method: string, params: any) => MaybePromise;
90 | onRequest?: (method: string, params: any) => MaybePromise;
91 | };
92 |
93 | /**
94 | * A set of functions to convert URIs between the language server process format and the consumer client format.
95 | *
96 | * One common use case here is to convert file paths between being relative to a
97 | * temp path and a virtual root. For example, a user editing files in a browser
98 | * may want to convert deal with paths that look like "/bar.ts," but, for
99 | * security reasons, on disc want to contain those files in a temporary
100 | * directory like `/tmp/ls-proxy/bar.ts`.
101 | */
102 | export type UriConverters = {
103 | /**
104 | * Converts a URI from the language server process format to the consumer client format.
105 | *
106 | * @param uri The URI in the language server process format.
107 | * @returns The URI in the consumer client format.
108 | */
109 | toProcUri: (uri: string) => string;
110 | /**
111 | * Converts a URI from the consumer client format to the language server process format.
112 | *
113 | * @param uri The URI in the consumer client format.
114 | * @returns The URI in the language server process format.
115 | */
116 | fromProcUri: (uri: string) => string;
117 | };
118 |
119 | export type LSPExec = {
120 | /** The command to execute the language server process. */
121 | command: string;
122 | /** Arguments to pass to the command. If not provided, the default is an empty array. */
123 | args?: string[];
124 | /**
125 | * Either a dictionary of environment variables to set for the process, or a
126 | * function that returns a promise that resolves to such a dictionary.
127 | */
128 | env?: () => MaybePromise<{ [K: string]: string }> | Record;
129 | /**
130 | * Lifecycle callbacks for the language server process.
131 | */
132 | callbacks?: {
133 | /** Called before the language server process is spawned. */
134 | preSpawn?: () => MaybePromise;
135 | /** Called after the language server process is spawned. */
136 | postSpawn?: () => MaybePromise;
137 | /** Called when the language server process exits. */
138 | onExit?: (code: number | null) => MaybePromise;
139 | /** Called when the language server process encounters an error. */
140 | onError?: (error: Error) => MaybePromise;
141 | };
142 | };
143 |
144 | export interface LSProxyParams {
145 | name: string;
146 | /**
147 | * Information to spawn the language server process.
148 | */
149 | exec: LSPExec;
150 | /**
151 | * Input stream for receiving messages from the LSP client.
152 | *
153 | * @default process.stdin
154 | */
155 | inputStream?: NodeJS.ReadableStream;
156 | /**
157 | * Output stream for sending messages to the LSP client.
158 | *
159 | * @default process.stdout
160 | */
161 | outputStream?: NodeJS.WritableStream;
162 | /** Logger for the LSP proxy */
163 | logger?: Logger;
164 | /**
165 | * Callbacks that intercept and maybe transform messages sent from the language server consumer client en route to the language server process.
166 | */
167 | clientToProcMiddlewares?: LSProxyClientToProcMiddlewares;
168 | /**
169 | * Callbacks that intercept and maybe transform messages sent from the language server process en route to the language server consumer client.
170 | */
171 | procToClientMiddlewares?: LSProxyProcToClientMiddlewares;
172 | /**
173 | * Callbacks that handle messages sent from the language server consumer client en route to the language server process.
174 | */
175 | clientToProcHandlers?: LSProxyClientToProcHandlers;
176 | /**
177 | * Callbacks that handle messages sent from the language server process en route to the language server consumer client.
178 | */
179 | procToClientHandlers?: LSProxyProcToClientHandlers;
180 | /**
181 | * Callbacks that handle messages sent from the language server consumer client or process.
182 | */
183 | uriConverters: UriConverters;
184 | /**
185 | * The working directory for the language server process.
186 | *
187 | * Language servers are often finicky about the file system, and in many cases use file system watchers to detect specific types of changes.
188 | *
189 | * @default process.cwd()
190 | */
191 | cwd: string;
192 | lsLogStderrPath?: string;
193 | lsLogStdoutPath?: string;
194 | }
195 |
--------------------------------------------------------------------------------
/codemirror-ls/src/setup.ts:
--------------------------------------------------------------------------------
1 | import { type Extension, Prec } from "@codemirror/state";
2 | import type * as LSP from "vscode-languageserver-protocol";
3 | import {
4 | completions,
5 | contextMenu,
6 | hovers,
7 | inlayHints,
8 | linting,
9 | references,
10 | renames,
11 | signatures,
12 | window,
13 | } from "./extensions/index.js";
14 | import type { LSClient } from "./LSClient.js";
15 | import { type ErrorHandler, LSPlugin } from "./LSPlugin.js";
16 |
17 | async function asyncNoop(): Promise {}
18 |
19 | /**
20 | * Utility function to set up a CodeMirror extension array that includes
21 | * everything needed to connect to a language server via the provided client.
22 | *
23 | * Gets extensions for all supported features, unless explicitly disabled, and
24 | * uses all provided configs.
25 | *
26 | * @example
27 | * ```ts
28 | * const lspExtensions = languageServerWithClient({
29 | * client: lsClient,
30 | * documentUri: `file://${path}`,
31 | * languageId: "typescript",
32 | * sendIncrementalChanges: false,
33 | * sendDidOpen: true,
34 | * features: {
35 | * signatureHelp: {
36 | * render: async (dom, data, activeSignature, activeParameter) => {
37 | * const root = ReactDOM.createRoot(dom);
38 | * root.render(
39 | * ,
44 | * );
45 | * },
46 | * },
47 | * linting: {
48 | * disable: true,
49 | * },
50 | * references: {
51 | * render: async (dom, references, goToReference, onClose, kind) => {
52 | * const root = ReactDOM.createRoot(dom);
53 | * root.render(
54 | * ,
60 | * );
61 | * },
62 | * modClickForDefinition: true,
63 | * onExternalReference: (uri) => {
64 | * console.log("Go to external reference", uri);
65 | * },
66 | * goToDefinitionShortcuts: ["F12"],
67 | * modClickForDefinition: true,
68 | * },
69 | * inlayHints: {
70 | * render: async (dom, hint) => {
71 | * const root = ReactDOM.createRoot(dom);
72 | * root.render( );
73 | * },
74 | * debounceTime: 150,
75 | * clearOnEdit: true,
76 | * },
77 | * },
78 | * });
79 | * ```
80 | */
81 | export function languageServerWithClient(options: LanguageServerOptions) {
82 | const features = {
83 | inlayHints: {
84 | disabled: false,
85 | render: asyncNoop,
86 | ...options.features.inlayHints,
87 | },
88 | signatureHelp: {
89 | disabled: false,
90 | render: asyncNoop,
91 | ...options.features.signatureHelp,
92 | },
93 | hovers: { disabled: false, render: asyncNoop, ...options.features.hovers },
94 | references: {
95 | disabled: false,
96 | render: asyncNoop,
97 | ...options.features.references,
98 | },
99 | completion: {
100 | disabled: false,
101 | render: asyncNoop,
102 | ...options.features.completion,
103 | },
104 | renames: {
105 | disabled: false,
106 | shortcuts: [{ key: "F2" }],
107 | render: asyncNoop,
108 | ...options.features.renames,
109 | },
110 | contextMenu: {
111 | disabled: false,
112 | referencesArgs: {},
113 | render: asyncNoop,
114 | ...options.features.contextMenu,
115 | },
116 | linting: {
117 | disabled: false,
118 | render: asyncNoop,
119 | ...options.features.linting,
120 | },
121 | window: { disabled: false, render: asyncNoop, ...options.features.window },
122 | } satisfies LanguageServerFeatures;
123 | const extensions: Extension[] = [];
124 |
125 | const lsClient = options.client;
126 |
127 | const lsPlugin = LSPlugin.of({
128 | client: lsClient,
129 | documentUri: options.documentUri,
130 | languageId: options.languageId,
131 | sendDidOpen: options.sendDidOpen ?? true,
132 | sendCloseOnDestroy: options.sendCloseOnDestroy ?? true,
133 | onWorkspaceEdit: options.onWorkspaceEdit,
134 | onError: options.onError,
135 | });
136 | extensions.push(Prec.highest(lsPlugin));
137 |
138 | if (!features.signatureHelp.disabled) {
139 | extensions.push(
140 | ...signatures.getSignatureExtensions(features.signatureHelp),
141 | );
142 | }
143 |
144 | if (!features.hovers.disabled) {
145 | extensions.push(hovers.getHoversExtensions(features.hovers));
146 | }
147 |
148 | if (!features.completion?.disabled) {
149 | extensions.push(completions.getCompletionsExtensions(features.completion));
150 | }
151 |
152 | if (!features.references.disabled) {
153 | extensions.push(...references.getReferencesExtensions(features.references));
154 | }
155 |
156 | if (!features.renames.disabled) {
157 | extensions.push(...renames.getRenameExtensions(features.renames));
158 | }
159 |
160 | if (!features.contextMenu.disabled) {
161 | extensions.push(
162 | ...contextMenu.getContextMenuExtensions({
163 | render: features.contextMenu.render,
164 | referencesArgs: {
165 | render: !features.references.disabled
166 | ? features.references?.render
167 | : asyncNoop,
168 | ...features.contextMenu.referencesArgs,
169 | },
170 | disableRename: features.renames.disabled,
171 | }),
172 | );
173 | }
174 |
175 | if (!features.linting.disabled) {
176 | extensions.push(...linting.getLintingExtensions(features.linting));
177 | }
178 |
179 | if (!features.inlayHints.disabled) {
180 | extensions.push(
181 | ...inlayHints.getInlayHintExtensions({ ...features.inlayHints }),
182 | );
183 | }
184 |
185 | if (!features.window.disabled) {
186 | extensions.push(...window.getWindowExtensions(features.window));
187 | }
188 |
189 | return extensions;
190 | }
191 |
192 | type FeatureOption = ({ disabled?: boolean } & T) | { disabled: true };
193 |
194 | export interface LanguageServerFeatures {
195 | signatureHelp: FeatureOption;
196 | hovers: FeatureOption;
197 | references: FeatureOption;
198 | completion: FeatureOption;
199 | renames: FeatureOption;
200 | contextMenu: FeatureOption<
201 | Omit & {
202 | referencesArgs?: FeatureOption;
203 | }
204 | >;
205 | linting: FeatureOption;
206 | inlayHints: FeatureOption;
207 | window: FeatureOption;
208 | }
209 |
210 | /**
211 | * Complete options for configuring the language server integration
212 | */
213 | export interface LanguageServerOptions {
214 | /** Language server features, including which extensions to enable or disable */
215 | features: Partial;
216 | /** Pre-configured language server client instance */
217 | client: LSClient;
218 | /** URI of the current document being edited. */
219 | documentUri: string;
220 | /** Language identifier (e.g., 'typescript', 'javascript', etc.). */
221 | languageId: string;
222 | /** Whether to send the didOpen notification when the editor is initialized */
223 | sendDidOpen?: boolean;
224 | /** Whether to send the didClose notification when the editor is destroyed */
225 | sendCloseOnDestroy?: boolean;
226 | /** Called when a workspace edit is received, for events that may have edited some or many files. */
227 | onWorkspaceEdit?: (edit: LSP.WorkspaceEdit) => void | Promise;
228 | /** Called when codemirror-ls extensions encounters an error. */
229 | onError?: ErrorHandler;
230 | }
231 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/contextMenu.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module contextMenu
3 | * @description Extensions for handling context menus in the editor.
4 | * @author Modification of code from Marijnh's codemirror-lsp-client
5 | *
6 | * Context menus are an override for the default right-click context menu
7 | * that allows users to perform actions like going to definitions, finding
8 | * references, renaming symbols, etc.
9 | *
10 | * Note that this is not a standard LSP feature, but rather a custom
11 | * implementation that uses the LSP for callbacks with various actions.
12 | */
13 |
14 | import { Annotation, StateField } from "@codemirror/state";
15 | import { EditorView, showTooltip, type Tooltip } from "@codemirror/view";
16 | import { LSCore } from "../LSPlugin.js";
17 | import {
18 | handleFindReferences,
19 | REFERENCE_CAPABILITY_MAP,
20 | type ReferenceExtensionsArgs,
21 | } from "./references.js";
22 | import { handleRename, type RenameExtensionsArgs } from "./renames.js";
23 | import type { LSExtensionGetter, Renderer } from "./types.js";
24 |
25 | export interface ContextMenuArgs {
26 | render: ContextMenuRenderer;
27 | referencesArgs?: ReferenceExtensionsArgs;
28 | renameArgs?: RenameExtensionsArgs;
29 | disableGoToDefinition?: boolean;
30 | disableGoToTypeDefinition?: boolean;
31 | disableGoToImplementation?: boolean;
32 | disableFindAllReferences?: boolean;
33 | disableRename?: boolean;
34 | }
35 |
36 | export type ContextMenuRenderer = Renderer<
37 | [callbacks: ContextMenuCallbacks, dismiss: () => void]
38 | >;
39 |
40 | export type ContextMenuCallbacks = {
41 | goToDefinition: (() => void) | null;
42 | goToTypeDefinition: (() => void) | null;
43 | goToImplementation: (() => void) | null;
44 | findAllReferences: (() => void) | null;
45 | rename?: (() => void) | null;
46 | };
47 |
48 | export const getContextMenuExtensions: LSExtensionGetter = ({
49 | render,
50 | referencesArgs,
51 | disableGoToDefinition,
52 | disableGoToTypeDefinition,
53 | disableGoToImplementation,
54 | disableFindAllReferences,
55 | disableRename,
56 | }) => {
57 | const contextMenuField = StateField.define({
58 | create() {
59 | return [];
60 | },
61 |
62 | update(tooltips, tr) {
63 | const clickData = tr.annotation(contextMenuActivated);
64 |
65 | if (clickData) {
66 | return getContextMenuTooltip({
67 | pos: clickData.pos,
68 | render,
69 | referencesArgs,
70 | disableGoToDefinition,
71 | disableGoToTypeDefinition,
72 | disableGoToImplementation,
73 | disableFindAllReferences,
74 | disableRename,
75 | });
76 | }
77 |
78 | return tooltips;
79 | },
80 |
81 | provide: (field) => {
82 | return showTooltip.computeN([field], (state) => state.field(field));
83 | },
84 | });
85 |
86 | return [
87 | contextMenuField,
88 | EditorView.domEventHandlers({
89 | contextmenu: (event, view) => {
90 | if (event.button !== 2) return false; // Only handle right-clicks
91 |
92 | const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
93 | if (pos === null) return false;
94 |
95 | // Only show a custom context menu if the symbol under the cursor is not empty
96 | const symbol = view.state.doc.sliceString(pos, pos + 2);
97 | if (/^[\s\n]*$/.test(symbol)) {
98 | return false;
99 | }
100 |
101 | view.dispatch(
102 | view.state.update({
103 | annotations: contextMenuActivated.of({ event, pos }),
104 | }),
105 | );
106 |
107 | return true; // Handled the event, don't show normal context menu
108 | },
109 | }),
110 | ];
111 | };
112 |
113 | export const contextMenuActivated = Annotation.define<{
114 | event: MouseEvent;
115 | pos: number;
116 | }>();
117 |
118 | export function handleContextMenu({
119 | view,
120 | pos,
121 | referencesArgs,
122 | renameArgs,
123 | disableGoToDefinition,
124 | disableGoToTypeDefinition,
125 | disableGoToImplementation,
126 | disableFindAllReferences,
127 | disableRename,
128 | }: {
129 | view: EditorView;
130 | pos: number;
131 | disableGoToDefinition?: boolean;
132 | disableGoToTypeDefinition?: boolean;
133 | disableGoToImplementation?: boolean;
134 | disableFindAllReferences?: boolean;
135 | disableRename?: boolean;
136 | referencesArgs?: ReferenceExtensionsArgs;
137 | renameArgs?: RenameExtensionsArgs;
138 | }): ContextMenuCallbacks {
139 | const lsPlugin = LSCore.ofOrThrow(view);
140 |
141 | const callbacks: ContextMenuCallbacks = {
142 | goToDefinition: null,
143 | goToTypeDefinition: null,
144 | goToImplementation: null,
145 | findAllReferences: null,
146 | rename: null,
147 | };
148 |
149 | const { capabilities } = lsPlugin.client;
150 | if (!capabilities) {
151 | return callbacks;
152 | }
153 |
154 | if (
155 | !disableGoToDefinition &&
156 | capabilities?.[REFERENCE_CAPABILITY_MAP["textDocument/definition"]]
157 | ) {
158 | callbacks.goToDefinition = () => {
159 | handleFindReferences({
160 | view,
161 | kind: "textDocument/definition",
162 | goToIfOneOption: true,
163 | pos,
164 | ...referencesArgs,
165 | });
166 | };
167 | }
168 |
169 | if (
170 | !disableGoToTypeDefinition &&
171 | capabilities?.[REFERENCE_CAPABILITY_MAP["textDocument/typeDefinition"]]
172 | ) {
173 | callbacks.goToTypeDefinition = () => {
174 | handleFindReferences({
175 | view,
176 | kind: "textDocument/typeDefinition",
177 | goToIfOneOption: true,
178 | pos,
179 | ...referencesArgs,
180 | });
181 | };
182 | }
183 |
184 | if (
185 | !disableGoToImplementation &&
186 | capabilities?.[REFERENCE_CAPABILITY_MAP["textDocument/implementation"]]
187 | ) {
188 | callbacks.goToImplementation = () => {
189 | handleFindReferences({
190 | view,
191 | kind: "textDocument/implementation",
192 | goToIfOneOption: true,
193 | pos,
194 | ...referencesArgs,
195 | });
196 | };
197 | }
198 |
199 | if (
200 | !disableFindAllReferences &&
201 | capabilities?.[REFERENCE_CAPABILITY_MAP["textDocument/references"]]
202 | ) {
203 | callbacks.findAllReferences = () => {
204 | handleFindReferences({
205 | view,
206 | kind: "textDocument/references",
207 | ...referencesArgs,
208 | pos,
209 | ...referencesArgs,
210 | });
211 | };
212 | }
213 |
214 | if (!disableRename && capabilities?.renameProvider) {
215 | callbacks.rename = () => {
216 | handleRename({
217 | view,
218 | pos,
219 | ...renameArgs,
220 | });
221 | };
222 | }
223 |
224 | return callbacks;
225 | }
226 |
227 | function getContextMenuTooltip({
228 | pos,
229 | render,
230 | referencesArgs,
231 | disableGoToDefinition,
232 | disableGoToTypeDefinition,
233 | disableGoToImplementation,
234 | disableFindAllReferences,
235 | disableRename,
236 | }: {
237 | pos: number;
238 | render: ContextMenuRenderer;
239 | referencesArgs?: ReferenceExtensionsArgs;
240 | disableGoToDefinition?: boolean;
241 | disableGoToTypeDefinition?: boolean;
242 | disableGoToImplementation?: boolean;
243 | disableFindAllReferences?: boolean;
244 | disableRename?: boolean;
245 | }): readonly Tooltip[] {
246 | return [
247 | {
248 | pos,
249 | above: false,
250 | create: (view) => {
251 | const contextMenuCallbacks = handleContextMenu({
252 | view,
253 | referencesArgs,
254 | pos,
255 | disableGoToDefinition,
256 | disableGoToTypeDefinition,
257 | disableGoToImplementation,
258 | disableFindAllReferences,
259 | disableRename,
260 | });
261 |
262 | const dom = document.createElement("div");
263 | dom.className = "cm-lsp-context-menu";
264 | render(dom, contextMenuCallbacks, () => {
265 | dom.remove();
266 | });
267 |
268 | return {
269 | dom,
270 | };
271 | },
272 | },
273 | ];
274 | }
275 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSWSServer/WSStream.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Node.js stream implementations for the browser WebSocket class.
3 | *
4 | * In most cases use the createWebSocketStreams function to create
5 | * readable and writable streams for a WebSocket connection.
6 | *
7 | * The Writable stream will automatically chunk data into smaller
8 | * pieces to avoid exceeding WebSocket message size limits that some providers (like Cloudflare)
9 | * impose.
10 | */
11 |
12 | import { Buffer } from "node:buffer";
13 | import { Readable, Writable } from "node:stream";
14 | import { type MessageEvent, WebSocket } from "isows";
15 | import { defaultLogger } from "~/logger.js";
16 |
17 | interface WebSocketStreamOptions {
18 | chunkSize?: number;
19 | }
20 |
21 | /**
22 | * A NodeJS readable stream that reads data from a WebSocket as a stream of bytes.
23 | */
24 | class WebSocketReadableStream extends Readable {
25 | #cleanupCbs: (() => void)[] = [];
26 |
27 | constructor(ws: WebSocket) {
28 | super();
29 | defaultLogger.debug("WebSocketReadableStream initialized");
30 |
31 | const messageHandler = (event: MessageEvent) => {
32 | defaultLogger.debug("WebSocketReadableStream received message");
33 |
34 | // Handle different data types that WebSocket can receive
35 | let buffer: Buffer;
36 | if (event.data instanceof ArrayBuffer) {
37 | buffer = Buffer.from(event.data);
38 | } else if (typeof event.data === "string") {
39 | buffer = Buffer.from(event.data, "utf8");
40 | } else if (event.data instanceof Uint8Array) {
41 | buffer = Buffer.from(event.data);
42 | } else {
43 | buffer = Buffer.from(String(event.data), "utf8");
44 | }
45 |
46 | this.push(buffer);
47 | };
48 | ws.addEventListener("message", messageHandler);
49 | this.#cleanupCbs.push(() =>
50 | ws.removeEventListener("message", messageHandler),
51 | );
52 |
53 | const errorHandler = (event: ErrorEvent) => {
54 | defaultLogger.error(
55 | { event },
56 | "WebSocketReadableStream received error event",
57 | );
58 | this.emit("error", event);
59 | };
60 | ws.addEventListener("error", errorHandler as EventListener);
61 | this.#cleanupCbs.push(() =>
62 | ws.removeEventListener("error", errorHandler as EventListener),
63 | );
64 |
65 | const closeHandler = (event: CloseEvent) => {
66 | defaultLogger.info(
67 | {
68 | code: event.code,
69 | reason: event.reason,
70 | wasClean: event.wasClean,
71 | },
72 | "WebSocketReadableStream received close event",
73 | );
74 | // stream.push(null) signals EOF
75 | this.push(null);
76 | };
77 | ws.addEventListener("close", closeHandler);
78 | this.#cleanupCbs.push(() => ws.removeEventListener("close", closeHandler));
79 | }
80 |
81 | override _read() {
82 | // Reading is driven by WebSocket events, so no action needed here
83 | defaultLogger.trace("WebSocketReadableStream: _read called");
84 | }
85 |
86 | override _destroy(
87 | error: Error | null,
88 | callback: (error?: Error | null) => void,
89 | ) {
90 | defaultLogger.debug(
91 | { error: error?.message },
92 | "WebSocketReadableStream is getting destroyed",
93 | );
94 |
95 | this.#cleanupCbs.forEach((cb) => cb());
96 |
97 | callback(error);
98 | }
99 | }
100 |
101 | /**
102 | * A NodeJS writable stream that writes data to a WebSocket as a stream of bytes.
103 | */
104 | class WebSocketWritableStream extends Writable {
105 | #websocket: WebSocket;
106 | #chunkSize: number;
107 | #buffer: Buffer = Buffer.alloc(0);
108 |
109 | constructor(
110 | ws: WebSocket,
111 | { chunkSize = 100 * 1024 }: WebSocketStreamOptions = {},
112 | ) {
113 | super();
114 | this.#websocket = ws;
115 | this.#chunkSize = chunkSize;
116 |
117 | this.#websocket.addEventListener("close", (event: CloseEvent) => {
118 | defaultLogger.info(
119 | {
120 | code: event.code,
121 | reason: event.reason,
122 | wasClean: event.wasClean,
123 | },
124 | "WebSocketWritableStream received close event",
125 | );
126 | });
127 |
128 | defaultLogger.debug({ chunkSize }, "WebSocketWritableStream initialized");
129 | }
130 |
131 | override _write(
132 | // biome-ignore lint/suspicious/noExplicitAny: arbitrary data
133 | chunk: any,
134 | _encoding: BufferEncoding,
135 | callback: (error?: Error | null) => void,
136 | ) {
137 | const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
138 |
139 | defaultLogger.debug(
140 | { bytes: buffer.length },
141 | "WebSocketWritableStream writing data",
142 | );
143 |
144 | if (this.#websocket.readyState === WebSocket.OPEN) {
145 | try {
146 | // Append the new chunk to the buffer
147 | this.#appendToBuffer(buffer);
148 |
149 | // Send the entire buffer
150 | this.#sendWithChunking(this.#buffer);
151 |
152 | // Clear the buffer after sending
153 | this.#clearBuffer();
154 | callback();
155 | } catch (err) {
156 | defaultLogger.error(
157 | { error: err },
158 | "WebSocketWritableStream: error during write",
159 | );
160 | callback(err instanceof Error ? err : new Error(String(err)));
161 | }
162 | } else {
163 | defaultLogger.debug(
164 | { readyState: this.#websocket.readyState },
165 | "WebSocketWritableStream socket not open, buffering data",
166 | );
167 | // If WebSocket is not open, buffer the data
168 | this.#appendToBuffer(buffer);
169 | callback();
170 | }
171 | }
172 |
173 | #appendToBuffer(chunk: Buffer) {
174 | this.#buffer = Buffer.concat([this.#buffer, chunk]);
175 | defaultLogger.debug(
176 | {
177 | newBufferSize: this.#buffer.length,
178 | addedBytes: chunk.length,
179 | },
180 | "WebSocketWritableStream buffer updated",
181 | );
182 | }
183 |
184 | #clearBuffer() {
185 | defaultLogger.debug("WebSocketWritableStream clearing buffer");
186 | this.#buffer = Buffer.alloc(0);
187 | }
188 |
189 | override _final(callback: (error?: Error | null) => void) {
190 | defaultLogger.debug("WebSocketWritableStream: finalizing");
191 |
192 | // Send any remaining buffered data
193 | if (this.#buffer.length > 0) {
194 | try {
195 | this.#sendWithChunking(this.#buffer);
196 | this.#clearBuffer();
197 | } catch (err) {
198 | return callback(err instanceof Error ? err : new Error(String(err)));
199 | }
200 | }
201 |
202 | // Don't close the WebSocket - let the application manage the WebSocket lifecycle
203 | defaultLogger.debug(
204 | "WebSocketWritableStream finalized without closing WebSocket",
205 | );
206 | callback();
207 | }
208 |
209 | override _destroy(
210 | error: Error | null,
211 | callback: (error?: Error | null) => void,
212 | ) {
213 | defaultLogger.debug(
214 | { error: error?.message },
215 | "WebSocketWritableStream is getting destroyed",
216 | );
217 | // Let application handle WebSocket close
218 | callback(error);
219 | }
220 |
221 | #sendWithChunking(data: Buffer) {
222 | defaultLogger.debug(
223 | { totalBytes: data.length, chunkSize: this.#chunkSize },
224 | "WebSocketWritableStream sending data with chunking",
225 | );
226 |
227 | for (const chunk of chunkByteArray(data, this.#chunkSize)) {
228 | this.#websocket.send(chunk);
229 | }
230 | }
231 | }
232 |
233 | /**
234 | * Creates stream interfaces for a WebSocket
235 | *
236 | * @param ws The WebSocket instance to wrap with streams
237 | * @returns Readable and writable stream interfaces
238 | */
239 | export function createWebSocketStreams(
240 | ws: WebSocket,
241 | { chunkSize = 900 * 1024 }: WebSocketStreamOptions = {},
242 | ) {
243 | defaultLogger.info({ chunkSize }, "Creating WebSocket streams");
244 | ws.binaryType = "arraybuffer";
245 |
246 | const readable = new WebSocketReadableStream(ws);
247 | const writable = new WebSocketWritableStream(ws, { chunkSize });
248 |
249 | return { readable, writable };
250 | }
251 |
252 | export function* chunkByteArray(
253 | byteArray: Uint8Array,
254 | chunkSize: number,
255 | ): Generator {
256 | const totalSize = byteArray.byteLength;
257 |
258 | for (let i = 0; i < totalSize; i += chunkSize) {
259 | const chunkEnd = Math.min(totalSize, i + chunkSize);
260 | yield byteArray.slice(i, chunkEnd);
261 | }
262 | }
263 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSProxy/types.lsp.ts:
--------------------------------------------------------------------------------
1 | /** biome-ignore-all lint/suspicious/noExplicitAny: generated */
2 | import type * as LSP from "vscode-languageserver-protocol";
3 |
4 | export interface LSPRequestMap {
5 | "textDocument/implementation": [
6 | LSP.ImplementationParams,
7 | LSP.Definition | LSP.DefinitionLink[] | null | null,
8 | ];
9 | "textDocument/typeDefinition": [
10 | LSP.TypeDefinitionParams,
11 | LSP.Definition | LSP.DefinitionLink[] | null | null,
12 | ];
13 | "workspace/workspaceFolders": [any, LSP.WorkspaceFolder[] | null | null];
14 | "workspace/configuration": [LSP.ConfigurationParams, LSP.LSPAny[] | null];
15 | "textDocument/documentColor": [
16 | LSP.DocumentColorParams,
17 | LSP.ColorInformation[] | null,
18 | ];
19 | "textDocument/colorPresentation": [
20 | LSP.ColorPresentationParams,
21 | LSP.ColorPresentation[] | null,
22 | ];
23 | "textDocument/foldingRange": [
24 | LSP.FoldingRangeParams,
25 | LSP.FoldingRange[] | null | null,
26 | ];
27 | "workspace/foldingRange/refresh": [any, null | null];
28 | "textDocument/declaration": [
29 | LSP.DeclarationParams,
30 | LSP.Declaration | LSP.DeclarationLink[] | null | null,
31 | ];
32 | "textDocument/selectionRange": [
33 | LSP.SelectionRangeParams,
34 | LSP.SelectionRange[] | null | null,
35 | ];
36 | "window/workDoneProgress/create": [
37 | LSP.WorkDoneProgressCreateParams,
38 | null | null,
39 | ];
40 | "textDocument/prepareCallHierarchy": [
41 | LSP.CallHierarchyPrepareParams,
42 | LSP.CallHierarchyItem[] | null | null,
43 | ];
44 | "callHierarchy/incomingCalls": [
45 | LSP.CallHierarchyIncomingCallsParams,
46 | LSP.CallHierarchyIncomingCall[] | null | null,
47 | ];
48 | "callHierarchy/outgoingCalls": [
49 | LSP.CallHierarchyOutgoingCallsParams,
50 | LSP.CallHierarchyOutgoingCall[] | null | null,
51 | ];
52 | "textDocument/semanticTokens/full": [
53 | LSP.SemanticTokensParams,
54 | LSP.SemanticTokens | null | null,
55 | ];
56 | "textDocument/semanticTokens/full/delta": [
57 | LSP.SemanticTokensDeltaParams,
58 | LSP.SemanticTokens | LSP.SemanticTokensDelta | null | null,
59 | ];
60 | "textDocument/semanticTokens/range": [
61 | LSP.SemanticTokensRangeParams,
62 | LSP.SemanticTokens | null | null,
63 | ];
64 | "workspace/semanticTokens/refresh": [any, null | null];
65 | "window/showDocument": [
66 | LSP.ShowDocumentParams,
67 | LSP.ShowDocumentResult | null,
68 | ];
69 | "textDocument/linkedEditingRange": [
70 | LSP.LinkedEditingRangeParams,
71 | LSP.LinkedEditingRanges | null | null,
72 | ];
73 | "workspace/willCreateFiles": [
74 | LSP.CreateFilesParams,
75 | LSP.WorkspaceEdit | null | null,
76 | ];
77 | "workspace/willRenameFiles": [
78 | LSP.RenameFilesParams,
79 | LSP.WorkspaceEdit | null | null,
80 | ];
81 | "workspace/willDeleteFiles": [
82 | LSP.DeleteFilesParams,
83 | LSP.WorkspaceEdit | null | null,
84 | ];
85 | "textDocument/moniker": [LSP.MonikerParams, LSP.Moniker[] | null | null];
86 | "textDocument/prepareTypeHierarchy": [
87 | LSP.TypeHierarchyPrepareParams,
88 | LSP.TypeHierarchyItem[] | null | null,
89 | ];
90 | "typeHierarchy/supertypes": [
91 | LSP.TypeHierarchySupertypesParams,
92 | LSP.TypeHierarchyItem[] | null | null,
93 | ];
94 | "typeHierarchy/subtypes": [
95 | LSP.TypeHierarchySubtypesParams,
96 | LSP.TypeHierarchyItem[] | null | null,
97 | ];
98 | "textDocument/inlineValue": [
99 | LSP.InlineValueParams,
100 | LSP.InlineValue[] | null | null,
101 | ];
102 | "workspace/inlineValue/refresh": [any, null | null];
103 | "textDocument/inlayHint": [
104 | LSP.InlayHintParams,
105 | LSP.InlayHint[] | null | null,
106 | ];
107 | "inlayHint/resolve": [LSP.InlayHint, LSP.InlayHint | null];
108 | "workspace/inlayHint/refresh": [any, null | null];
109 | "textDocument/diagnostic": [
110 | LSP.DocumentDiagnosticParams,
111 | LSP.DocumentDiagnosticReport | null,
112 | ];
113 | "workspace/diagnostic": [
114 | LSP.WorkspaceDiagnosticParams,
115 | LSP.WorkspaceDiagnosticReport | null,
116 | ];
117 | "workspace/diagnostic/refresh": [any, null | null];
118 | "textDocument/inlineCompletion": [
119 | LSP.InlineCompletionParams,
120 | LSP.InlineCompletionList | LSP.InlineCompletionItem[] | null | null,
121 | ];
122 | "client/registerCapability": [LSP.RegistrationParams, null | null];
123 | "client/unregisterCapability": [LSP.UnregistrationParams, null | null];
124 | initialize: [LSP.InitializeParams, LSP.InitializeResult | null];
125 | shutdown: [any, null | null];
126 | "window/showMessageRequest": [
127 | LSP.ShowMessageRequestParams,
128 | LSP.MessageActionItem | null | null,
129 | ];
130 | "textDocument/willSaveWaitUntil": [
131 | LSP.WillSaveTextDocumentParams,
132 | LSP.TextEdit[] | null | null,
133 | ];
134 | "textDocument/completion": [
135 | LSP.CompletionParams,
136 | LSP.CompletionItem[] | LSP.CompletionList | null | null,
137 | ];
138 | "completionItem/resolve": [LSP.CompletionItem, LSP.CompletionItem | null];
139 | "textDocument/hover": [LSP.HoverParams, LSP.Hover | null | null];
140 | "textDocument/signatureHelp": [
141 | LSP.SignatureHelpParams,
142 | LSP.SignatureHelp | null | null,
143 | ];
144 | "textDocument/definition": [
145 | LSP.DefinitionParams,
146 | LSP.Definition | LSP.DefinitionLink[] | null | null,
147 | ];
148 | "textDocument/references": [
149 | LSP.ReferenceParams,
150 | LSP.Location[] | null | null,
151 | ];
152 | "textDocument/documentHighlight": [
153 | LSP.DocumentHighlightParams,
154 | LSP.DocumentHighlight[] | null | null,
155 | ];
156 | "textDocument/documentSymbol": [
157 | LSP.DocumentSymbolParams,
158 | LSP.SymbolInformation[] | LSP.DocumentSymbol[] | null | null,
159 | ];
160 | "textDocument/codeAction": [
161 | LSP.CodeActionParams,
162 | (LSP.Command | LSP.CodeAction)[] | null | null,
163 | ];
164 | "codeAction/resolve": [LSP.CodeAction, LSP.CodeAction | null];
165 | "workspace/symbol": [
166 | LSP.WorkspaceSymbolParams,
167 | LSP.SymbolInformation[] | LSP.WorkspaceSymbol[] | null | null,
168 | ];
169 | "workspaceSymbol/resolve": [LSP.WorkspaceSymbol, LSP.WorkspaceSymbol | null];
170 | "textDocument/codeLens": [LSP.CodeLensParams, LSP.CodeLens[] | null | null];
171 | "codeLens/resolve": [LSP.CodeLens, LSP.CodeLens | null];
172 | "workspace/codeLens/refresh": [any, null | null];
173 | "textDocument/documentLink": [
174 | LSP.DocumentLinkParams,
175 | LSP.DocumentLink[] | null | null,
176 | ];
177 | "documentLink/resolve": [LSP.DocumentLink, LSP.DocumentLink | null];
178 | "textDocument/formatting": [
179 | LSP.DocumentFormattingParams,
180 | LSP.TextEdit[] | null | null,
181 | ];
182 | "textDocument/rangeFormatting": [
183 | LSP.DocumentRangeFormattingParams,
184 | LSP.TextEdit[] | null | null,
185 | ];
186 | "textDocument/rangesFormatting": [
187 | LSP.DocumentRangesFormattingParams,
188 | LSP.TextEdit[] | null | null,
189 | ];
190 | "textDocument/onTypeFormatting": [
191 | LSP.DocumentOnTypeFormattingParams,
192 | LSP.TextEdit[] | null | null,
193 | ];
194 | "textDocument/rename": [LSP.RenameParams, LSP.WorkspaceEdit | null | null];
195 | "textDocument/prepareRename": [
196 | LSP.PrepareRenameParams,
197 | LSP.PrepareRenameResult | null | null,
198 | ];
199 | "workspace/executeCommand": [
200 | LSP.ExecuteCommandParams,
201 | LSP.LSPAny | null | null,
202 | ];
203 | "workspace/applyEdit": [
204 | LSP.ApplyWorkspaceEditParams,
205 | LSP.ApplyWorkspaceEditResult | null,
206 | ];
207 | }
208 |
209 | export interface LSPNotifyMap {
210 | "workspace/didChangeWorkspaceFolders": LSP.DidChangeWorkspaceFoldersParams;
211 | "window/workDoneProgress/cancel": LSP.WorkDoneProgressCancelParams;
212 | "workspace/didCreateFiles": LSP.CreateFilesParams;
213 | "workspace/didRenameFiles": LSP.RenameFilesParams;
214 | "workspace/didDeleteFiles": LSP.DeleteFilesParams;
215 | "notebookDocument/didOpen": LSP.DidOpenNotebookDocumentParams;
216 | "notebookDocument/didChange": LSP.DidChangeNotebookDocumentParams;
217 | "notebookDocument/didSave": LSP.DidSaveNotebookDocumentParams;
218 | "notebookDocument/didClose": LSP.DidCloseNotebookDocumentParams;
219 | initialized: LSP.InitializedParams;
220 | exit: any;
221 | "workspace/didChangeConfiguration": LSP.DidChangeConfigurationParams;
222 | "window/showMessage": LSP.ShowMessageParams;
223 | "window/logMessage": LSP.LogMessageParams;
224 | "telemetry/event": LSP.LSPAny;
225 | "textDocument/didOpen": LSP.DidOpenTextDocumentParams;
226 | "textDocument/didChange": LSP.DidChangeTextDocumentParams;
227 | "textDocument/didClose": LSP.DidCloseTextDocumentParams;
228 | "textDocument/didSave": LSP.DidSaveTextDocumentParams;
229 | "textDocument/willSave": LSP.WillSaveTextDocumentParams;
230 | "workspace/didChangeWatchedFiles": LSP.DidChangeWatchedFilesParams;
231 | "textDocument/publishDiagnostics": LSP.PublishDiagnosticsParams;
232 | "$/setTrace": LSP.SetTraceParams;
233 | "$/logTrace": LSP.LogTraceParams;
234 | }
235 |
--------------------------------------------------------------------------------
/codemirror-ls/src/types.lsp.ts:
--------------------------------------------------------------------------------
1 | // Generated file, do not edit
2 |
3 | /** biome-ignore-all lint/suspicious/noExplicitAny: generated */
4 | import type * as LSP from "vscode-languageserver-protocol";
5 |
6 | export interface LSPRequestMap {
7 | "textDocument/implementation": [
8 | LSP.ImplementationParams,
9 | LSP.Definition | LSP.DefinitionLink[] | null | null,
10 | ];
11 | "textDocument/typeDefinition": [
12 | LSP.TypeDefinitionParams,
13 | LSP.Definition | LSP.DefinitionLink[] | null | null,
14 | ];
15 | "workspace/workspaceFolders": [any, LSP.WorkspaceFolder[] | null | null];
16 | "workspace/configuration": [LSP.ConfigurationParams, LSP.LSPAny[] | null];
17 | "textDocument/documentColor": [
18 | LSP.DocumentColorParams,
19 | LSP.ColorInformation[] | null,
20 | ];
21 | "textDocument/colorPresentation": [
22 | LSP.ColorPresentationParams,
23 | LSP.ColorPresentation[] | null,
24 | ];
25 | "textDocument/foldingRange": [
26 | LSP.FoldingRangeParams,
27 | LSP.FoldingRange[] | null | null,
28 | ];
29 | "workspace/foldingRange/refresh": [any, null | null];
30 | "textDocument/declaration": [
31 | LSP.DeclarationParams,
32 | LSP.Declaration | LSP.DeclarationLink[] | null | null,
33 | ];
34 | "textDocument/selectionRange": [
35 | LSP.SelectionRangeParams,
36 | LSP.SelectionRange[] | null | null,
37 | ];
38 | "window/workDoneProgress/create": [
39 | LSP.WorkDoneProgressCreateParams,
40 | null | null,
41 | ];
42 | "textDocument/prepareCallHierarchy": [
43 | LSP.CallHierarchyPrepareParams,
44 | LSP.CallHierarchyItem[] | null | null,
45 | ];
46 | "callHierarchy/incomingCalls": [
47 | LSP.CallHierarchyIncomingCallsParams,
48 | LSP.CallHierarchyIncomingCall[] | null | null,
49 | ];
50 | "callHierarchy/outgoingCalls": [
51 | LSP.CallHierarchyOutgoingCallsParams,
52 | LSP.CallHierarchyOutgoingCall[] | null | null,
53 | ];
54 | "textDocument/semanticTokens/full": [
55 | LSP.SemanticTokensParams,
56 | LSP.SemanticTokens | null | null,
57 | ];
58 | "textDocument/semanticTokens/full/delta": [
59 | LSP.SemanticTokensDeltaParams,
60 | LSP.SemanticTokens | LSP.SemanticTokensDelta | null | null,
61 | ];
62 | "textDocument/semanticTokens/range": [
63 | LSP.SemanticTokensRangeParams,
64 | LSP.SemanticTokens | null | null,
65 | ];
66 | "workspace/semanticTokens/refresh": [any, null | null];
67 | "window/showDocument": [
68 | LSP.ShowDocumentParams,
69 | LSP.ShowDocumentResult | null,
70 | ];
71 | "textDocument/linkedEditingRange": [
72 | LSP.LinkedEditingRangeParams,
73 | LSP.LinkedEditingRanges | null | null,
74 | ];
75 | "workspace/willCreateFiles": [
76 | LSP.CreateFilesParams,
77 | LSP.WorkspaceEdit | null | null,
78 | ];
79 | "workspace/willRenameFiles": [
80 | LSP.RenameFilesParams,
81 | LSP.WorkspaceEdit | null | null,
82 | ];
83 | "workspace/willDeleteFiles": [
84 | LSP.DeleteFilesParams,
85 | LSP.WorkspaceEdit | null | null,
86 | ];
87 | "textDocument/moniker": [LSP.MonikerParams, LSP.Moniker[] | null | null];
88 | "textDocument/prepareTypeHierarchy": [
89 | LSP.TypeHierarchyPrepareParams,
90 | LSP.TypeHierarchyItem[] | null | null,
91 | ];
92 | "typeHierarchy/supertypes": [
93 | LSP.TypeHierarchySupertypesParams,
94 | LSP.TypeHierarchyItem[] | null | null,
95 | ];
96 | "typeHierarchy/subtypes": [
97 | LSP.TypeHierarchySubtypesParams,
98 | LSP.TypeHierarchyItem[] | null | null,
99 | ];
100 | "textDocument/inlineValue": [
101 | LSP.InlineValueParams,
102 | LSP.InlineValue[] | null | null,
103 | ];
104 | "workspace/inlineValue/refresh": [any, null | null];
105 | "textDocument/inlayHint": [
106 | LSP.InlayHintParams,
107 | LSP.InlayHint[] | null | null,
108 | ];
109 | "inlayHint/resolve": [LSP.InlayHint, LSP.InlayHint | null];
110 | "workspace/inlayHint/refresh": [any, null | null];
111 | "textDocument/diagnostic": [
112 | LSP.DocumentDiagnosticParams,
113 | LSP.DocumentDiagnosticReport | null,
114 | ];
115 | "workspace/diagnostic": [
116 | LSP.WorkspaceDiagnosticParams,
117 | LSP.WorkspaceDiagnosticReport | null,
118 | ];
119 | "workspace/diagnostic/refresh": [any, null | null];
120 | "textDocument/inlineCompletion": [
121 | LSP.InlineCompletionParams,
122 | LSP.InlineCompletionList | LSP.InlineCompletionItem[] | null | null,
123 | ];
124 | "client/registerCapability": [LSP.RegistrationParams, null | null];
125 | "client/unregisterCapability": [LSP.UnregistrationParams, null | null];
126 | initialize: [LSP.InitializeParams, LSP.InitializeResult | null];
127 | shutdown: [any, null | null];
128 | "window/showMessageRequest": [
129 | LSP.ShowMessageRequestParams,
130 | LSP.MessageActionItem | null | null,
131 | ];
132 | "textDocument/willSaveWaitUntil": [
133 | LSP.WillSaveTextDocumentParams,
134 | LSP.TextEdit[] | null | null,
135 | ];
136 | "textDocument/completion": [
137 | LSP.CompletionParams,
138 | LSP.CompletionItem[] | LSP.CompletionList | null | null,
139 | ];
140 | "completionItem/resolve": [LSP.CompletionItem, LSP.CompletionItem | null];
141 | "textDocument/hover": [LSP.HoverParams, LSP.Hover | null | null];
142 | "textDocument/signatureHelp": [
143 | LSP.SignatureHelpParams,
144 | LSP.SignatureHelp | null | null,
145 | ];
146 | "textDocument/definition": [
147 | LSP.DefinitionParams,
148 | LSP.Definition | LSP.DefinitionLink[] | null | null,
149 | ];
150 | "textDocument/references": [
151 | LSP.ReferenceParams,
152 | LSP.Location[] | null | null,
153 | ];
154 | "textDocument/documentHighlight": [
155 | LSP.DocumentHighlightParams,
156 | LSP.DocumentHighlight[] | null | null,
157 | ];
158 | "textDocument/documentSymbol": [
159 | LSP.DocumentSymbolParams,
160 | LSP.SymbolInformation[] | LSP.DocumentSymbol[] | null | null,
161 | ];
162 | "textDocument/codeAction": [
163 | LSP.CodeActionParams,
164 | (LSP.Command | LSP.CodeAction)[] | null | null,
165 | ];
166 | "codeAction/resolve": [LSP.CodeAction, LSP.CodeAction | null];
167 | "workspace/symbol": [
168 | LSP.WorkspaceSymbolParams,
169 | LSP.SymbolInformation[] | LSP.WorkspaceSymbol[] | null | null,
170 | ];
171 | "workspaceSymbol/resolve": [LSP.WorkspaceSymbol, LSP.WorkspaceSymbol | null];
172 | "textDocument/codeLens": [LSP.CodeLensParams, LSP.CodeLens[] | null | null];
173 | "codeLens/resolve": [LSP.CodeLens, LSP.CodeLens | null];
174 | "workspace/codeLens/refresh": [any, null | null];
175 | "textDocument/documentLink": [
176 | LSP.DocumentLinkParams,
177 | LSP.DocumentLink[] | null | null,
178 | ];
179 | "documentLink/resolve": [LSP.DocumentLink, LSP.DocumentLink | null];
180 | "textDocument/formatting": [
181 | LSP.DocumentFormattingParams,
182 | LSP.TextEdit[] | null | null,
183 | ];
184 | "textDocument/rangeFormatting": [
185 | LSP.DocumentRangeFormattingParams,
186 | LSP.TextEdit[] | null | null,
187 | ];
188 | "textDocument/rangesFormatting": [
189 | LSP.DocumentRangesFormattingParams,
190 | LSP.TextEdit[] | null | null,
191 | ];
192 | "textDocument/onTypeFormatting": [
193 | LSP.DocumentOnTypeFormattingParams,
194 | LSP.TextEdit[] | null | null,
195 | ];
196 | "textDocument/rename": [LSP.RenameParams, LSP.WorkspaceEdit | null | null];
197 | "textDocument/prepareRename": [
198 | LSP.PrepareRenameParams,
199 | LSP.PrepareRenameResult | null | null,
200 | ];
201 | "workspace/executeCommand": [
202 | LSP.ExecuteCommandParams,
203 | LSP.LSPAny | null | null,
204 | ];
205 | "workspace/applyEdit": [
206 | LSP.ApplyWorkspaceEditParams,
207 | LSP.ApplyWorkspaceEditResult | null,
208 | ];
209 | }
210 |
211 | export interface LSPNotifyMap {
212 | "workspace/didChangeWorkspaceFolders": LSP.DidChangeWorkspaceFoldersParams;
213 | "window/workDoneProgress/cancel": LSP.WorkDoneProgressCancelParams;
214 | "workspace/didCreateFiles": LSP.CreateFilesParams;
215 | "workspace/didRenameFiles": LSP.RenameFilesParams;
216 | "workspace/didDeleteFiles": LSP.DeleteFilesParams;
217 | "notebookDocument/didOpen": LSP.DidOpenNotebookDocumentParams;
218 | "notebookDocument/didChange": LSP.DidChangeNotebookDocumentParams;
219 | "notebookDocument/didSave": LSP.DidSaveNotebookDocumentParams;
220 | "notebookDocument/didClose": LSP.DidCloseNotebookDocumentParams;
221 | initialized: LSP.InitializedParams;
222 | exit: any;
223 | "workspace/didChangeConfiguration": LSP.DidChangeConfigurationParams;
224 | "window/showMessage": LSP.ShowMessageParams;
225 | "window/logMessage": LSP.LogMessageParams;
226 | "telemetry/event": LSP.LSPAny;
227 | "textDocument/didOpen": LSP.DidOpenTextDocumentParams;
228 | "textDocument/didChange": LSP.DidChangeTextDocumentParams;
229 | "textDocument/didClose": LSP.DidCloseTextDocumentParams;
230 | "textDocument/didSave": LSP.DidSaveTextDocumentParams;
231 | "textDocument/willSave": LSP.WillSaveTextDocumentParams;
232 | "workspace/didChangeWatchedFiles": LSP.DidChangeWatchedFilesParams;
233 | "textDocument/publishDiagnostics": LSP.PublishDiagnosticsParams;
234 | "$/setTrace": LSP.SetTraceParams;
235 | "$/logTrace": LSP.LogTraceParams;
236 | }
237 |
--------------------------------------------------------------------------------
/ls-ws-server/src/LSWSServer/LSTransform.ts:
--------------------------------------------------------------------------------
1 | // From https://github.com/ImperiumMaximus/ts-lsp-client
2 |
3 | import { Buffer } from "node:buffer";
4 | import {
5 | Readable,
6 | Transform,
7 | type TransformCallback,
8 | type TransformOptions,
9 | type Writable,
10 | } from "node:stream";
11 | import { defaultLogger } from "~/logger.js";
12 |
13 | type ReceiveState = "content-length" | "jsonrpc";
14 |
15 | /**
16 | * Take a raw input stream of bytes, parse out LSP messages, and re-output as a
17 | * stream of bytes, but as chunks that are entire LSP messages.
18 | *
19 | * We want to send full LSP messages to the language server process in case we
20 | * have multiple workers sending chunks to the language server at the same time.
21 | *
22 | * @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#baseProtocol
23 | */
24 | export class ToLSTransform extends Transform {
25 | private _state: ReceiveState;
26 | private _curContentLength = 0;
27 | private _curChunk: Buffer;
28 |
29 | private constructor(options?: TransformOptions) {
30 | options = options || {};
31 | options.objectMode = true;
32 | super(options);
33 |
34 | this.on("pipe", (src) => {
35 | if (!this.readableEncoding) {
36 | if (src instanceof Readable) {
37 | this.setEncoding(src.readableEncoding!);
38 | }
39 | }
40 | });
41 |
42 | this._curChunk = Buffer.from([]);
43 | this._state = "content-length";
44 | }
45 |
46 | public override _transform(
47 | chunk: Buffer | string,
48 | encoding: NodeJS.BufferEncoding,
49 | cb: TransformCallback,
50 | ): void {
51 | // decode binary chunks as UTF-8
52 | encoding = encoding || "utf8";
53 |
54 | if (!Buffer.isBuffer(chunk)) {
55 | chunk = Buffer.from(chunk, encoding);
56 | }
57 |
58 | this._curChunk = Buffer.concat([this._curChunk, chunk]);
59 |
60 | const prefixMinLength = Buffer.byteLength(
61 | "Content-Length: 0\r\n\r\n",
62 | encoding,
63 | );
64 | const prefixLength = Buffer.byteLength("Content-Length: ", encoding);
65 | const prefixRegex = /^Content-Length: /i;
66 | const digitLength = Buffer.byteLength("0", encoding);
67 | const digitRe = /^[0-9]/;
68 | const suffixLength = Buffer.byteLength("\r\n\r\n", encoding);
69 | const suffixRe = /^\r\n\r\n/;
70 |
71 | while (true) {
72 | if (this._state === "content-length") {
73 | // Not enough data for a content length match
74 | if (this._curChunk.length < prefixMinLength) {
75 | break;
76 | }
77 |
78 | const leading = this._curChunk.subarray(0, prefixLength);
79 | if (!prefixRegex.test(leading.toString(encoding))) {
80 | cb(
81 | new Error(
82 | `[_transform] Bad header: ${this._curChunk.toString(encoding)}`,
83 | ),
84 | );
85 | return;
86 | }
87 |
88 | let numString = "";
89 | let position = leading.length;
90 | while (this._curChunk.length - position > digitLength) {
91 | const ch = this._curChunk
92 | .subarray(position, position + digitLength)
93 | .toString(encoding);
94 | if (!digitRe.test(ch)) {
95 | break;
96 | }
97 |
98 | numString += ch;
99 | position += 1;
100 | }
101 |
102 | if (
103 | position === leading.length ||
104 | this._curChunk.length - position < suffixLength ||
105 | !suffixRe.test(
106 | this._curChunk
107 | .subarray(position, position + suffixLength)
108 | .toString(encoding),
109 | )
110 | ) {
111 | cb(
112 | new Error(
113 | `[_transform] Bad header: ${this._curChunk.toString(encoding)}`,
114 | ),
115 | );
116 | return;
117 | }
118 |
119 | this._curContentLength = Number(numString);
120 | this._curChunk = this._curChunk.subarray(position + suffixLength);
121 | this._state = "jsonrpc";
122 | }
123 |
124 | if (this._state === "jsonrpc") {
125 | if (this._curChunk.length >= this._curContentLength) {
126 | this.push(
127 | this._reencode(
128 | this._curChunk.subarray(0, this._curContentLength),
129 | encoding,
130 | ),
131 | );
132 | this._curChunk = this._curChunk.subarray(this._curContentLength);
133 | this._state = "content-length";
134 |
135 | continue;
136 | }
137 | }
138 |
139 | break;
140 | }
141 | cb();
142 | }
143 |
144 | private _reencode(chunk: Buffer, chunkEncoding: NodeJS.BufferEncoding) {
145 | if (this.readableEncoding && this.readableEncoding !== chunkEncoding) {
146 | return chunk.toString(this.readableEncoding);
147 | }
148 | if (this.readableEncoding) {
149 | // this should be the most common case, i.e. we're using an encoded source stream
150 | return chunk.toString(chunkEncoding);
151 | }
152 | return chunk;
153 | }
154 |
155 | public static createStream(
156 | readStream?: Readable,
157 | options?: TransformOptions,
158 | ): ToLSTransform {
159 | const jrt = new ToLSTransform(options);
160 | if (readStream) {
161 | readStream.pipe(jrt);
162 | }
163 | return jrt;
164 | }
165 | }
166 |
167 | /**
168 | * Takes object LSP messages as input and formats them according to LSP spec to
169 | * add things like the Content-Length header. Outputs as a stream of bytes that
170 | * conforms to the LSP protocol.
171 | *
172 | * @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#baseProtocol
173 | */
174 | export class FromLSTransform extends Transform {
175 | private _encoding: NodeJS.BufferEncoding;
176 |
177 | private constructor(options?: TransformOptions) {
178 | options = options || {};
179 | // We expect objects as input
180 | options.objectMode = true;
181 | super(options);
182 | this._encoding = (options.encoding as NodeJS.BufferEncoding) || "utf8";
183 | }
184 |
185 | public override _transform(
186 | chunk: unknown,
187 | _encoding: string,
188 | cb: TransformCallback,
189 | ): void {
190 | if (typeof chunk !== "string") {
191 | chunk = String(chunk);
192 | }
193 |
194 | if (typeof chunk !== "string") {
195 | cb(
196 | new Error(
197 | `[FromLSTransform] Input chunk must be a string, got ${typeof chunk} (${chunk})`,
198 | ),
199 | );
200 | return;
201 | }
202 |
203 | try {
204 | // Get the byte length of the JSON content using the specified encoding
205 | const contentLength = Buffer.byteLength(chunk, this._encoding);
206 |
207 | // Create the header
208 | const header = `Content-Length: ${contentLength}\r\n\r\n`;
209 |
210 | // Create the complete message as a string and then convert to buffer
211 | const message = header + chunk;
212 | const messageBuffer = Buffer.from(message, this._encoding);
213 |
214 | // Push the formatted message
215 | this.push(messageBuffer);
216 | cb();
217 | } catch (error) {
218 | cb(new Error(`[FromLSTransform] Failed to transform: ${error}`));
219 | }
220 | }
221 |
222 | public override setEncoding(encoding: NodeJS.BufferEncoding): this {
223 | this._encoding = encoding;
224 | return super.setEncoding(encoding);
225 | }
226 |
227 | public static createStream(
228 | readStream?: Readable,
229 | options?: TransformOptions,
230 | ): FromLSTransform {
231 | const jrt = new FromLSTransform(options);
232 | if (readStream) {
233 | readStream.pipe(jrt);
234 | }
235 | return jrt;
236 | }
237 | }
238 |
239 | /**
240 | * Takes an input stream of bytes, process/parses into LSP messages, and then
241 | * re-outputs as a stream of bytes, but as chunks that are entire LSP messages.
242 | *
243 | * @param inputStream The input stream of bytes, for example from WebSocket connection.
244 | * @param outputStream The output stream of bytes, for example to stdin of LSP process.
245 | */
246 | export function pipeLsInToLsOut(
247 | inputStream: Readable,
248 | outputStream: Writable,
249 | middleware?: (chunk: string) => string | null,
250 | ) {
251 | const preLsTransform = ToLSTransform.createStream(inputStream);
252 |
253 | if (middleware) {
254 | const middlewareTransform = new Transform({
255 | objectMode: true,
256 | transform(
257 | chunk: Buffer,
258 | encoding: NodeJS.BufferEncoding,
259 | cb: TransformCallback,
260 | ) {
261 | const result = middleware(chunk.toString(encoding));
262 | if (result == null) return cb();
263 | defaultLogger.debug(`LS pipe middleware transformed chunk: ${result}`);
264 | cb(null, Buffer.from(result, encoding));
265 | },
266 | });
267 |
268 | const postLsTransform = FromLSTransform.createStream(middlewareTransform);
269 | preLsTransform.pipe(middlewareTransform);
270 | postLsTransform.pipe(outputStream);
271 | } else {
272 | const postLsTransform = FromLSTransform.createStream(preLsTransform);
273 | postLsTransform.pipe(outputStream);
274 | }
275 | }
276 |
--------------------------------------------------------------------------------
/codemirror-ls/src/extensions/renames.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module renames
3 | * @description Extensions for handling renaming of symbols in the editor.
4 | *
5 | * Renaming allows users to change the name of a symbol across the codebase.
6 | * This is a "refactor" operation that updates all references to the symbol
7 | * in the current document and potentially across multiple files.
8 | *
9 | * @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_rename
10 | */
11 |
12 | import {
13 | Annotation,
14 | EditorSelection,
15 | type Extension,
16 | StateField,
17 | } from "@codemirror/state";
18 | import type { EditorView, KeyBinding, Tooltip } from "@codemirror/view";
19 | import { keymap, showTooltip } from "@codemirror/view";
20 | import * as LSP from "vscode-languageserver-protocol";
21 | import { LSCore } from "../LSPlugin.js";
22 | import { offsetToPos, posToOffset } from "../utils.js";
23 | import type { LSExtensionGetter, Renderer } from "./types.js";
24 |
25 | export interface RenameExtensionsArgs {
26 | /** Keybindings to trigger the rename action. */
27 | shortcuts?: KeyBinding[];
28 | /** Function to render the rename dialog. */
29 | render: RenameRenderer;
30 | /**
31 | * Whether to select the symbol at the cursor when initiating a rename.
32 | * Defaults to `true`.
33 | */
34 | selectSymbol?: boolean;
35 | /**
36 | * Whether to reset the symbol selection after completing or dismissing
37 | * the rename action. Defaults to `true`.
38 | */
39 | resetSymbolSelection?: boolean;
40 | }
41 |
42 | export type RenameRenderer = Renderer<
43 | [
44 | placeholder: string,
45 | onDismiss: () => void,
46 | onComplete: (newName: string) => void,
47 | ]
48 | >;
49 |
50 | export type OnRenameCallback = (
51 | uri: string,
52 | rename: LSP.TextDocumentEdit,
53 | ) => void;
54 | export type OnExternalRenameCallback = OnRenameCallback;
55 |
56 | /**
57 | * Creates and returns extensions for handling renaming functionality
58 | */
59 | export const getRenameExtensions: LSExtensionGetter = ({
60 | shortcuts,
61 | render,
62 | selectSymbol = true,
63 | resetSymbolSelection = true,
64 | }: RenameExtensionsArgs): Extension[] => {
65 | const renameDialogField = StateField.define({
66 | create() {
67 | return null;
68 | },
69 | update(tooltip, tr) {
70 | const rename = tr.annotation(symbolRename);
71 |
72 | if (rename === null) return null;
73 |
74 | if (rename) {
75 | return {
76 | create: (view) => {
77 | const onComplete = async (newName: string) => {
78 | const lsPlugin = LSCore.ofOrThrow(view);
79 | const pos = offsetToPos(view.state.doc, rename.pos);
80 |
81 | const edit = await lsPlugin.client.request(
82 | "textDocument/rename",
83 | {
84 | textDocument: { uri: lsPlugin.documentUri },
85 | position: { line: pos.line, character: pos.character },
86 | newName,
87 | },
88 | );
89 |
90 | if (!edit) return;
91 |
92 | void lsPlugin.applyWorkspaceEdit(edit);
93 | };
94 |
95 | const onDismiss = () => {
96 | view.dispatch({
97 | selection: resetSymbolSelection
98 | ? { anchor: view.state.selection.main.head }
99 | : view.state.selection,
100 | annotations: [symbolRename.of(null)],
101 | });
102 | };
103 |
104 | const div = document.createElement("div");
105 | void render(div, rename.placeholder, onDismiss, onComplete);
106 |
107 | return {
108 | dom: div,
109 | above: false,
110 | strictSide: true,
111 | };
112 | },
113 | pos: rename.pos,
114 | };
115 | }
116 |
117 | return tooltip;
118 | },
119 | provide: (field) => {
120 | return showTooltip.compute([field], (state) => state.field(field));
121 | },
122 | });
123 |
124 | return [
125 | keymap.of(
126 | (shortcuts || []).map((shortcut) => ({
127 | ...shortcut,
128 | run: (view: EditorView) => {
129 | void handleRename({
130 | view,
131 | pos: view.state.selection.main.head,
132 | selectSymbol,
133 | });
134 | return true;
135 | },
136 | })),
137 | ),
138 | renameDialogField,
139 | ];
140 | };
141 |
142 | /** When the language server responds with information so that the user can rename a symbol */
143 | const symbolRename = Annotation.define<{
144 | pos: number;
145 | placeholder: string;
146 | } | null>();
147 |
148 | export async function handleRename({
149 | view,
150 | pos,
151 | selectSymbol = true,
152 | }: {
153 | view: EditorView;
154 | pos: number;
155 | selectSymbol?: boolean;
156 | }) {
157 | const lsPlugin = LSCore.ofOrThrow(view);
158 |
159 | // Gather information about the rename location and maybe the placeholder to
160 | // show in the dialog
161 | // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareRename
162 |
163 | const word = view.state.wordAt(pos);
164 | if (!word) return;
165 |
166 | let prepareResult: LSP.PrepareRenameResult | null = null;
167 |
168 | // Attempt to use textDocument/prepareRename, which may not be a thing
169 | try {
170 | const realPrepareResult = await lsPlugin.client.request(
171 | "textDocument/prepareRename",
172 | {
173 | textDocument: { uri: lsPlugin.documentUri },
174 | position: offsetToPos(view.state.doc, pos)!,
175 | },
176 | );
177 | if (realPrepareResult) {
178 | prepareResult = realPrepareResult;
179 | }
180 | } catch {
181 | const positionData = offsetToPos(view.state.doc, pos);
182 | const fallbackResult = prepareRenameFallback({
183 | view,
184 | character: positionData.character,
185 | line: positionData.line,
186 | });
187 | prepareResult = fallbackResult;
188 | }
189 |
190 | if (!prepareResult) return;
191 |
192 | // Handle different types of PrepareRenameResult
193 | let placeholder: string;
194 | let range: LSP.Range;
195 |
196 | if (LSP.Range.is(prepareResult)) {
197 | // If it's just a Range, use the word text as placeholder
198 | range = prepareResult;
199 | const start = posToOffset(view.state.doc, range.start)!;
200 | const end = posToOffset(view.state.doc, range.end)!;
201 | placeholder = view.state.doc.sliceString(start, end);
202 | } else if ("range" in prepareResult && "placeholder" in prepareResult) {
203 | // It's a PrepareRename with placeholder and range
204 | placeholder = prepareResult.placeholder;
205 | range = prepareResult.range;
206 | } else if ("defaultBehavior" in prepareResult) {
207 | // Server indicated to use default behavior, use the word range
208 | const wordRange = view.state.wordAt(pos);
209 | if (!wordRange) return;
210 | const posData = offsetToPos(view.state.doc, pos);
211 | range = {
212 | start: {
213 | line: posData.line,
214 | character: posData.character - (pos - wordRange.from),
215 | },
216 | end: {
217 | line: posData.line,
218 | character: posData.character + (wordRange.to - pos),
219 | },
220 | };
221 | placeholder = view.state.doc.sliceString(wordRange.from, wordRange.to);
222 | } else {
223 | return; // Unknown format
224 | }
225 |
226 | view.dispatch({
227 | selection: selectSymbol
228 | ? EditorSelection.create([
229 | EditorSelection.range(
230 | posToOffset(view.state.doc, range.start)!,
231 | posToOffset(view.state.doc, range.end)!,
232 | ),
233 | ])
234 | : view.state.selection,
235 | annotations: symbolRename.of({
236 | placeholder,
237 | pos: posToOffset(view.state.doc, range.start)!,
238 | }),
239 | });
240 | }
241 |
242 | function prepareRenameFallback({
243 | view,
244 | line,
245 | character,
246 | }: {
247 | view: EditorView;
248 | line: number;
249 | character: number;
250 | }): LSP.PrepareRenameResult | null {
251 | const doc = view.state.doc;
252 | const lineText = doc.line(line + 1).text;
253 | const wordRegex = /\w+/g;
254 | let match: RegExpExecArray | null;
255 | let start = character;
256 | let end = character;
257 |
258 | // Find all word matches in the line
259 | match = wordRegex.exec(lineText);
260 | while (match !== null) {
261 | const matchStart = match.index;
262 | const matchEnd = match.index + match[0].length;
263 |
264 | // Check if cursor position is within or at the boundaries of this word
265 | if (character >= matchStart && character <= matchEnd) {
266 | start = matchStart;
267 | end = matchEnd;
268 | break;
269 | }
270 | match = wordRegex.exec(lineText);
271 | }
272 |
273 | if (start === character && end === character) {
274 | return null; // No word found at cursor position
275 | }
276 |
277 | return {
278 | range: {
279 | start: { line, character: start },
280 | end: { line, character: end },
281 | },
282 | placeholder: lineText.slice(start, end),
283 | };
284 | }
285 |
--------------------------------------------------------------------------------
/codemirror-ls/src/LSClient.ts:
--------------------------------------------------------------------------------
1 | import { Emitter } from "vscode-jsonrpc";
2 | import type * as LSP from "vscode-languageserver-protocol";
3 | import type { LSITransport } from "./transport/LSITransport.js";
4 | import type { LSPNotifyMap, LSPRequestMap } from "./types.lsp.js";
5 |
6 | export interface LanguageServerClientOptions {
7 | /** List of workspace folders to send to the language server */
8 | workspaceFolders: LSP.WorkspaceFolder[] | null;
9 | /** Whether to automatically close the connection when the editor is destroyed */
10 | autoClose?: boolean;
11 | /**
12 | * Client capabilities to send to the server during initialization.
13 | * Can be an object or a function that modifies the default capabilities.
14 | */
15 | capabilities?:
16 | | LSP.InitializeParams["capabilities"]
17 | | ((
18 | defaultCapabilities: LSP.InitializeParams["capabilities"],
19 | ) => LSP.InitializeParams["capabilities"]);
20 | /** Additional initialization options to send to the language server */
21 | initializationOptions?: LSP.InitializeParams["initializationOptions"];
22 | /** JSON-RPC client for communication with the language server */
23 | transport: LSITransport;
24 | }
25 |
26 | export class LSClient {
27 | public ready: boolean;
28 | public capabilities: LSP.ServerCapabilities | null;
29 |
30 | public initializePromise: Promise;
31 | public resolveInitialize?: () => void;
32 |
33 | private workspaceFolders: LSP.WorkspaceFolder[] | null;
34 |
35 | private initializationOptions: LanguageServerClientOptions["initializationOptions"];
36 | public clientCapabilities: LanguageServerClientOptions["capabilities"];
37 |
38 | private transport: LSITransport;
39 |
40 | // biome-ignore lint/suspicious/noExplicitAny: for all handlers
41 | #requestEmitter = new Emitter<{ method: string; params: any }>();
42 | // biome-ignore lint/suspicious/noExplicitAny: for all handlers
43 | #notificationEmitter = new Emitter<{ method: string; params: any }>();
44 | // biome-ignore lint/suspicious/noExplicitAny: for all handlers
45 | #errorEmitter = new Emitter();
46 | #onInitializedEmitter = new Emitter();
47 |
48 | constructor({
49 | workspaceFolders,
50 | initializationOptions,
51 | capabilities,
52 | transport,
53 | }: LanguageServerClientOptions) {
54 | this.workspaceFolders = workspaceFolders;
55 | this.initializationOptions = initializationOptions;
56 | this.clientCapabilities = capabilities;
57 | this.transport = transport;
58 | this.ready = false;
59 | this.capabilities = null;
60 |
61 | this.initializePromise = new Promise((resolve) => {
62 | this.resolveInitialize = resolve;
63 | });
64 |
65 | this.#registerHandlers();
66 |
67 | void this.initialize();
68 | }
69 |
70 | /**
71 | * Change the underlying transport used for communication with the language server. Updates
72 | * the transport and re-registers all handlers.
73 | *
74 | * After you change the transport, if you are connected to the same lsp
75 | * process as before, you should be all set. If not, you'll need to
76 | * initialize.
77 | *
78 | * @param newTransport The new LSITransport to use.
79 | */
80 | public changeTransport(newTransport: LSITransport) {
81 | this.transport = newTransport;
82 |
83 | this.#registerHandlers();
84 | }
85 |
86 | #registerHandlers() {
87 | this.transport.onRequest((method, params) => {
88 | this.#requestEmitter.fire({ method, params });
89 | });
90 |
91 | this.transport.onNotification((method, params) => {
92 | this.#notificationEmitter.fire({ method, params });
93 | });
94 |
95 | this.transport.onError((error) => {
96 | this.#errorEmitter.fire(error);
97 | });
98 | }
99 |
100 | protected getInitializationOptions(): LSP.InitializeParams["initializationOptions"] {
101 | const defaultClientCapabilities: LSP.ClientCapabilities = {
102 | textDocument: {
103 | hover: {
104 | dynamicRegistration: true,
105 | contentFormat: ["markdown", "plaintext"],
106 | },
107 | moniker: {},
108 | synchronization: {
109 | dynamicRegistration: true,
110 | willSave: false,
111 | didSave: false,
112 | willSaveWaitUntil: false,
113 | },
114 | codeAction: {
115 | dynamicRegistration: true,
116 | codeActionLiteralSupport: {
117 | codeActionKind: {
118 | valueSet: [
119 | "",
120 | "quickfix",
121 | "refactor",
122 | "refactor.extract",
123 | "refactor.inline",
124 | "refactor.rewrite",
125 | "source",
126 | "source.organizeImports",
127 | ],
128 | },
129 | },
130 | resolveSupport: {
131 | properties: ["edit"],
132 | },
133 | },
134 | completion: {
135 | dynamicRegistration: true,
136 | completionItem: {
137 | snippetSupport: true,
138 | insertReplaceSupport: true,
139 | commitCharactersSupport: true,
140 | documentationFormat: ["markdown", "plaintext"],
141 | deprecatedSupport: false,
142 | resolveSupport: {
143 | properties: ["documentation", "detail", "additionalTextEdits"],
144 | },
145 | preselectSupport: true,
146 | },
147 | contextSupport: true,
148 | },
149 | signatureHelp: {
150 | dynamicRegistration: true,
151 | signatureInformation: {
152 | documentationFormat: ["markdown", "plaintext"],
153 | },
154 | },
155 | declaration: {
156 | dynamicRegistration: true,
157 | linkSupport: true,
158 | },
159 | definition: {
160 | dynamicRegistration: true,
161 | linkSupport: true,
162 | },
163 | typeDefinition: {
164 | dynamicRegistration: true,
165 | linkSupport: true,
166 | },
167 | implementation: {
168 | dynamicRegistration: true,
169 | linkSupport: true,
170 | },
171 | rename: {
172 | dynamicRegistration: true,
173 | prepareSupport: true,
174 | },
175 | inlayHint: {
176 | dynamicRegistration: true,
177 | },
178 | },
179 | workspace: {
180 | didChangeConfiguration: {
181 | dynamicRegistration: true,
182 | },
183 | },
184 | };
185 |
186 | const defaultOptions = {
187 | capabilities: this.clientCapabilities
188 | ? typeof this.clientCapabilities === "function"
189 | ? this.clientCapabilities(defaultClientCapabilities)
190 | : this.clientCapabilities
191 | : defaultClientCapabilities,
192 | initializationOptions: this.initializationOptions,
193 | processId: null,
194 | workspaceFolders: this.workspaceFolders,
195 | };
196 |
197 | return defaultOptions;
198 | }
199 |
200 | public async initialize() {
201 | const response = await this.request(
202 | "initialize",
203 | this.getInitializationOptions(),
204 | );
205 |
206 | if (response === null) {
207 | throw new Error("Initialization response is null");
208 | }
209 |
210 | this.capabilities = response.capabilities;
211 | await this.notify("initialized", {});
212 |
213 | this.ready = true;
214 |
215 | this.#onInitializedEmitter.fire();
216 | this.resolveInitialize?.();
217 | }
218 |
219 | public close() {
220 | this.transport.close?.();
221 | this.#requestEmitter.dispose();
222 | this.#notificationEmitter.dispose();
223 | this.#errorEmitter.dispose();
224 | }
225 |
226 | public async request(
227 | method: K,
228 | params: LSPRequestMap[K][0],
229 | ): Promise {
230 | if (method !== "initialize" && !this.ready) {
231 | await this.initializePromise;
232 | }
233 |
234 | return await this.requestUnsafe(method, params);
235 | }
236 |
237 | // biome-ignore lint/suspicious/noExplicitAny: explicitly for unsafe requests
238 | public async requestUnsafe(method: string, params: any): Promise {
239 | return await this.transport.sendRequest(method, params);
240 | }
241 |
242 | /**
243 | * Send a notification to the LSP server.
244 | *
245 | * @param method The LSP method to notify
246 | * @param params The parameters for the notification method
247 | * @returns A promise that resolves when the notification is sent
248 | */
249 | public notify(
250 | method: T,
251 | params: LSPNotifyMap[T],
252 | ): Promise {
253 | return this.notifyUnsafe(method, params);
254 | }
255 |
256 | // biome-ignore lint/suspicious/noExplicitAny: explicitly for unsafe notifications
257 | public async notifyUnsafe(method: string, params: any): Promise {
258 | return this.transport.sendNotification(method, params);
259 | }
260 |
261 | // biome-ignore lint/suspicious/noExplicitAny: for all handlers
262 | public onRequest(handler: (method: string, params: any) => any): () => void {
263 | return this.#requestEmitter.event(({ method, params }) =>
264 | handler(method, params),
265 | ).dispose;
266 | }
267 |
268 | public onNotification(
269 | // biome-ignore lint/suspicious/noExplicitAny: for all handlers
270 | handler: (method: string, params: any) => void,
271 | ): () => void {
272 | return this.#notificationEmitter.event(({ method, params }) =>
273 | handler(method, params),
274 | ).dispose;
275 | }
276 |
277 | // biome-ignore lint/suspicious/noExplicitAny: for all handlers
278 | public onError(handler: (error: any) => void): () => void {
279 | return this.#errorEmitter.event(handler).dispose;
280 | }
281 |
282 | public onInitialize(handler: () => void | Promise): () => void {
283 | return this.#onInitializedEmitter.event(handler).dispose;
284 | }
285 | }
286 |
--------------------------------------------------------------------------------