├── .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 | 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 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # VTLSP Demo Deployment 2 | 3 | ![The live demo](https://filedumpthing.val.run/blob/blob_file_1755126264734_output.gif) 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 |
66 |
67 |
68 | {/* Hidden text to determine width */} 69 |
70 | {newName || "W"} 71 |
72 | 73 | {/* Actual input that sits on top */} 74 | setNewName(e.target.value)} 79 | onKeyDown={handleKeyDown} 80 | className="absolute top-0 left-0 text-sm border-none outline-none focus:ring-0 bg-transparent" 81 | /> 82 |
83 |
84 |
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 | 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 | 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 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VTLSP: Val Town's LSP Powered Editor 2 | 3 | ![Val Town editor demo](https://filedumpthing.val.run/blob/blob_file_1755128319090_output_8.gif) 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 | ![The live demo](https://filedumpthing.val.run/blob/blob_file_1755126264734_output.gif) 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 | --------------------------------------------------------------------------------