This commit is contained in:
Dax Raad
2025-05-30 20:47:56 -04:00
parent 9a26b3058f
commit f3da73553c
178 changed files with 765 additions and 3382 deletions

View File

@@ -0,0 +1,208 @@
import { spawn } from "child_process";
import path from "path";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node";
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types";
import { App } from "../app/app";
import { Log } from "../util/log";
import { LANGUAGE_EXTENSIONS } from "./language";
import { Bus } from "../bus";
import z from "zod";
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" });
export type Info = Awaited<ReturnType<typeof create>>;
export type Diagnostic = VSCodeDiagnostic;
export const Event = {
Diagnostics: Bus.event(
"lsp.client.diagnostics",
z.object({
serverID: z.string(),
path: z.string(),
}),
),
};
export async function create(input: { cmd: string[]; serverID: string }) {
log.info("starting client", input);
const app = await App.use();
const [command, ...args] = input.cmd;
const server = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: app.root,
});
const connection = createMessageConnection(
new StreamMessageReader(server.stdout),
new StreamMessageWriter(server.stdin),
);
const diagnostics = new Map<string, Diagnostic[]>();
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const path = new URL(params.uri).pathname;
log.info("textDocument/publishDiagnostics", {
path,
});
const exists = diagnostics.has(path);
diagnostics.set(path, params.diagnostics);
// servers seem to send one blank publishDiagnostics event before the first real one
if (!exists && !params.diagnostics.length) return;
Bus.publish(Event.Diagnostics, { path, serverID: input.serverID });
});
connection.listen();
await connection.sendRequest("initialize", {
processId: server.pid,
initializationOptions: {
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.root,
},
],
tsserver: {
path: require.resolve("typescript/lib/tsserver.js"),
},
},
capabilities: {
workspace: {
configuration: true,
didChangeConfiguration: {
dynamicRegistration: true,
},
didChangeWatchedFiles: {
dynamicRegistration: true,
relativePatternSupport: true,
},
},
textDocument: {
synchronization: {
dynamicRegistration: true,
didSave: true,
},
completion: {
completionItem: {},
},
codeLens: {
dynamicRegistration: true,
},
documentSymbol: {},
codeAction: {
codeActionLiteralSupport: {
codeActionKind: {
valueSet: [],
},
},
},
publishDiagnostics: {
versionSupport: true,
},
semanticTokens: {
requests: {
range: {},
full: {},
},
tokenTypes: [],
tokenModifiers: [],
formats: [],
},
},
window: {},
},
});
await connection.sendNotification("initialized", {});
log.info("initialized");
const files = new Set<string>();
const result = {
get clientID() {
return input.serverID;
},
get connection() {
return connection;
},
notify: {
async open(input: { path: string }) {
const file = Bun.file(input.path);
const text = await file.text();
const opened = files.has(input.path);
if (!opened) {
log.info("textDocument/didOpen", input);
diagnostics.delete(input.path);
const extension = path.extname(input.path);
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
languageId,
version: Date.now(),
text,
},
});
files.add(input.path);
return;
}
log.info("textDocument/didChange", input);
diagnostics.delete(input.path);
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: `file://` + input.path,
version: Date.now(),
},
contentChanges: [
{
text,
},
],
});
},
},
get diagnostics() {
return diagnostics;
},
async waitForDiagnostics(input: { path: string }) {
log.info("waiting for diagnostics", input);
let unsub: () => void;
let timeout: NodeJS.Timeout;
return await Promise.race([
new Promise<void>(async (resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.clientID
) {
log.info("got diagnostics", input);
clearTimeout(timeout);
unsub?.();
resolve();
}
});
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
log.info("timed out refreshing diagnostics", input);
unsub?.();
resolve();
}, 5000);
}),
]);
},
async shutdown() {
log.info("shutting down");
connection.end();
connection.dispose();
},
};
return result;
}
}

View File

@@ -0,0 +1,131 @@
import { App } from "../app/app";
import { Log } from "../util/log";
import { LSPClient } from "./client";
import path from "path";
export namespace LSP {
const log = Log.create({ service: "lsp" });
const state = App.state(
"lsp",
async () => {
log.info("initializing");
const clients = new Map<string, LSPClient.Info>();
return {
clients,
};
},
async (state) => {
for (const client of state.clients.values()) {
await client.shutdown();
}
},
);
export async function file(input: string) {
const extension = path.parse(input).ext;
const s = await state();
const matches = AUTO.filter((x) => x.extensions.includes(extension));
for (const match of matches) {
const existing = s.clients.get(match.id);
if (existing) continue;
const client = await LSPClient.create({
cmd: match.command,
serverID: match.id,
});
s.clients.set(match.id, client);
}
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input });
await client.notify.open({ path: input });
return wait;
});
}
export async function diagnostics() {
const results: Record<string, LSPClient.Diagnostic[]> = {};
for (const result of await run(async (client) => client.diagnostics)) {
for (const [path, diagnostics] of result.entries()) {
const arr = results[path] || [];
arr.push(...diagnostics);
results[path] = arr;
}
}
return results;
}
export async function hover(input: {
file: string;
line: number;
character: number;
}) {
return run((client) => {
return client.connection.sendRequest("textDocument/hover", {
textDocument: {
uri: `file://${input.file}`,
},
position: {
line: input.line,
character: input.character,
},
});
});
}
async function run<T>(
input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()]);
const tasks = clients.map((x) => input(x));
return Promise.all(tasks);
}
const AUTO: {
id: string;
command: string[];
extensions: string[];
install?: () => Promise<void>;
}[] = [
{
id: "typescript",
command: ["bun", "x", "typescript-language-server", "--stdio"],
extensions: [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".mts",
".cts",
".mtsx",
".ctsx",
],
},
/*
{
id: "golang",
command: ["gopls"],
extensions: [".go"],
},
*/
];
export namespace Diagnostic {
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
};
const severity = severityMap[diagnostic.severity || 1];
const line = diagnostic.range.start.line + 1;
const col = diagnostic.range.start.character + 1;
return `${severity} [${line}:${col}] ${diagnostic.message}`;
}
}
}

View File

@@ -0,0 +1,89 @@
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".abap": "abap",
".bat": "bat",
".bib": "bibtex",
".bibtex": "bibtex",
".clj": "clojure",
".coffee": "coffeescript",
".c": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".c++": "cpp",
".cs": "csharp",
".css": "css",
".d": "d",
".pas": "pascal",
".pascal": "pascal",
".diff": "diff",
".patch": "diff",
".dart": "dart",
".dockerfile": "dockerfile",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".hrl": "erlang",
".fs": "fsharp",
".fsi": "fsharp",
".fsx": "fsharp",
".fsscript": "fsharp",
".gitcommit": "git-commit",
".gitrebase": "git-rebase",
".go": "go",
".groovy": "groovy",
".hbs": "handlebars",
".handlebars": "handlebars",
".hs": "haskell",
".html": "html",
".htm": "html",
".ini": "ini",
".java": "java",
".js": "javascript",
".jsx": "javascriptreact",
".json": "json",
".tex": "latex",
".latex": "latex",
".less": "less",
".lua": "lua",
".makefile": "makefile",
makefile: "makefile",
".md": "markdown",
".markdown": "markdown",
".m": "objective-c",
".mm": "objective-cpp",
".pl": "perl",
".pm": "perl6",
".php": "php",
".ps1": "powershell",
".psm1": "powershell",
".pug": "jade",
".jade": "jade",
".py": "python",
".r": "r",
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rs": "rust",
".scss": "scss",
".sass": "sass",
".scala": "scala",
".shader": "shaderlab",
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".ksh": "shellscript",
".sql": "sql",
".swift": "swift",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".mtsx": "typescriptreact",
".ctsx": "typescriptreact",
".xml": "xml",
".xsl": "xsl",
".yaml": "yaml",
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
} as const;