import { Log } from "../util/log" import { LSPClient } from "./client" import path from "path" import { LSPServer } from "./server" import z from "zod" import { Config } from "../config/config" import { spawn } from "child_process" import { Instance } from "../project/instance" import { Bus } from "../bus" export namespace LSP { const log = Log.create({ service: "lsp" }) export const Event = { Updated: Bus.event("lsp.updated", z.object({})), } export const Range = z .object({ start: z.object({ line: z.number(), character: z.number(), }), end: z.object({ line: z.number(), character: z.number(), }), }) .meta({ ref: "Range", }) export type Range = z.infer export const Symbol = z .object({ name: z.string(), kind: z.number(), location: z.object({ uri: z.string(), range: Range, }), }) .meta({ ref: "Symbol", }) export type Symbol = z.infer export const DocumentSymbol = z .object({ name: z.string(), detail: z.string().optional(), kind: z.number(), range: Range, selectionRange: Range, }) .meta({ ref: "DocumentSymbol", }) export type DocumentSymbol = z.infer const state = Instance.state( async () => { const clients: LSPClient.Info[] = [] const servers: Record = {} const cfg = await Config.get() if (cfg.lsp === false) { log.info("all LSPs are disabled") return { broken: new Set(), servers, clients, spawning: new Map>(), } } for (const server of Object.values(LSPServer)) { servers[server.id] = server } for (const [name, item] of Object.entries(cfg.lsp ?? {})) { const existing = servers[name] if (item.disabled) { log.info(`LSP server ${name} is disabled`) delete servers[name] continue } servers[name] = { ...existing, id: name, root: existing?.root ?? (async () => Instance.directory), extensions: item.extensions ?? existing?.extensions ?? [], spawn: async (root) => { return { process: spawn(item.command[0], item.command.slice(1), { cwd: root, env: { ...process.env, ...item.env, }, }), initialization: item.initialization, } }, } } log.info("enabled LSP servers", { serverIds: Object.values(servers) .map((server) => server.id) .join(", "), }) return { broken: new Set(), servers, clients, spawning: new Map>(), } }, async (state) => { await Promise.all(state.clients.map((client) => client.shutdown())) }, ) export async function init() { return state() } export const Status = z .object({ id: z.string(), name: z.string(), root: z.string(), status: z.union([z.literal("connected"), z.literal("error")]), }) .meta({ ref: "LSPStatus", }) export type Status = z.infer export async function status() { return state().then((x) => { const result: Status[] = [] for (const client of x.clients) { result.push({ id: client.serverID, name: x.servers[client.serverID].id, root: path.relative(Instance.directory, client.root), status: "connected", }) } return result }) } async function getClients(file: string) { const s = await state() const extension = path.parse(file).ext || file const result: LSPClient.Info[] = [] async function schedule(server: LSPServer.Info, root: string, key: string) { const handle = await server .spawn(root) .then((value) => { if (!value) s.broken.add(key) return value }) .catch((err) => { s.broken.add(key) log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) return undefined }) if (!handle) return undefined log.info("spawned lsp server", { serverID: server.id }) const client = await LSPClient.create({ serverID: server.id, server: handle, root, }).catch((err) => { s.broken.add(key) handle.process.kill() log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) return undefined }) if (!client) { handle.process.kill() return undefined } const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) if (existing) { handle.process.kill() return existing } s.clients.push(client) return client } for (const server of Object.values(s.servers)) { if (server.extensions.length && !server.extensions.includes(extension)) continue const root = await server.root(file) if (!root) continue if (s.broken.has(root + server.id)) continue const match = s.clients.find((x) => x.root === root && x.serverID === server.id) if (match) { result.push(match) continue } const inflight = s.spawning.get(root + server.id) if (inflight) { const client = await inflight if (!client) continue result.push(client) continue } const task = schedule(server, root, root + server.id) s.spawning.set(root + server.id, task) task.finally(() => { if (s.spawning.get(root + server.id) === task) { s.spawning.delete(root + server.id) } }) const client = await task if (!client) continue result.push(client) Bus.publish(Event.Updated, {}) } return result } export async function touchFile(input: string, waitForDiagnostics?: boolean) { log.info("touching file", { file: input }) const clients = await getClients(input) await run(async (client) => { if (!clients.includes(client)) return const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() await client.notify.open({ path: input }) return wait }).catch((err) => { log.error("failed to touch file", { err, file: input }) }) } export async function diagnostics() { const results: Record = {} 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, }, }) }) } enum SymbolKind { File = 1, Module = 2, Namespace = 3, Package = 4, Class = 5, Method = 6, Property = 7, Field = 8, Constructor = 9, Enum = 10, Interface = 11, Function = 12, Variable = 13, Constant = 14, String = 15, Number = 16, Boolean = 17, Array = 18, Object = 19, Key = 20, Null = 21, EnumMember = 22, Struct = 23, Event = 24, Operator = 25, TypeParameter = 26, } const kinds = [ SymbolKind.Class, SymbolKind.Function, SymbolKind.Method, SymbolKind.Interface, SymbolKind.Variable, SymbolKind.Constant, SymbolKind.Struct, SymbolKind.Enum, ] export async function workspaceSymbol(query: string) { return run((client) => client.connection .sendRequest("workspace/symbol", { query, }) .then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind))) .then((result: any) => result.slice(0, 10)) .catch(() => []), ).then((result) => result.flat() as LSP.Symbol[]) } export async function documentSymbol(uri: string) { return run((client) => client.connection .sendRequest("textDocument/documentSymbol", { textDocument: { uri, }, }) .catch(() => []), ) .then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]) .then((result) => result.filter(Boolean)) } async function run(input: (client: LSPClient.Info) => Promise): Promise { const clients = await state().then((x) => x.clients) const tasks = clients.map((x) => input(x)) return Promise.all(tasks) } 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}` } } }