diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 44cf263f..6b537974 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -103,6 +103,7 @@ export namespace LSP { broken: new Set(), servers, clients, + spawning: new Map>(), } }, async (state) => { @@ -145,6 +146,49 @@ export namespace LSP { 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) @@ -156,39 +200,31 @@ export namespace LSP { result.push(match) continue } - const handle = await server - .spawn(root) - .then((h) => { - if (h === undefined) { - s.broken.add(root + server.id) - } - return h - }) - .catch((err) => { - s.broken.add(root + server.id) - log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) - return undefined - }) - if (!handle) continue - log.info("spawned lsp server", { serverID: server.id }) - const client = await LSPClient.create({ - serverID: server.id, - server: handle, - root, - }).catch((err) => { - s.broken.add(root + server.id) - handle.process.kill() - log.error(`Failed to initialize LSP client ${server.id}`, { - error: err, - }) - return undefined + 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 - s.clients.push(client) + result.push(client) Bus.publish(Event.Updated, {}) } + return result } @@ -199,6 +235,7 @@ export namespace LSP { 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 }) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index d9fff5b1..281d1a0e 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -632,73 +632,135 @@ export namespace LSPServer { root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]), extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], async spawn(root) { - let bin = Bun.which("clangd", { - PATH: process.env["PATH"] + ":" + Global.Path.bin, - }) - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading clangd from GitHub releases") - - const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch clangd release info") - return + const args = ["--background-index", "--clang-tidy"] + const fromPath = Bun.which("clangd") + if (fromPath) { + return { + process: spawn(fromPath, args, { + cwd: root, + }), } - - const release = (await releaseResponse.json()) as any - - const platform = process.platform - let assetName = "" - - if (platform === "darwin") { - assetName = "clangd-mac-" - } else if (platform === "linux") { - assetName = "clangd-linux-" - } else if (platform === "win32") { - assetName = "clangd-windows-" - } else { - log.error(`Platform ${platform} is not supported by clangd auto-download`) - return - } - - assetName += release.tag_name + ".zip" - - const asset = release.assets.find((a: any) => a.name === assetName) - if (!asset) { - log.error(`Could not find asset ${assetName} in latest clangd release`) - return - } - - const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) - if (!downloadResponse.ok) { - log.error("Failed to download clangd") - return - } - - const zipPath = path.join(Global.Path.bin, "clangd.zip") - await Bun.file(zipPath).write(downloadResponse) - - await $`unzip -o -q ${zipPath}`.quiet().cwd(Global.Path.bin).nothrow() - await fs.rm(zipPath, { force: true }) - - const extractedDir = path.join(Global.Path.bin, assetName.replace(".zip", "")) - bin = path.join(extractedDir, "bin", "clangd" + (platform === "win32" ? ".exe" : "")) - - if (!(await Bun.file(bin).exists())) { - log.error("Failed to extract clangd binary") - return - } - - if (platform !== "win32") { - await $`chmod +x ${bin}`.nothrow() - } - - log.info(`installed clangd`, { bin }) } + const ext = process.platform === "win32" ? ".exe" : "" + const direct = path.join(Global.Path.bin, "clangd" + ext) + if (await Bun.file(direct).exists()) { + return { + process: spawn(direct, args, { + cwd: root, + }), + } + } + + const entries = await fs.readdir(Global.Path.bin, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (!entry.name.startsWith("clangd_")) continue + const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) + if (await Bun.file(candidate).exists()) { + return { + process: spawn(candidate, args, { + cwd: root, + }), + } + } + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading clangd from GitHub releases") + + const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch clangd release info") + return + } + + const release: { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } = await releaseResponse.json() + + const tag = release.tag_name + if (!tag) { + log.error("clangd release did not include a tag name") + return + } + const platform = process.platform + const tokens: Record = { + darwin: "mac", + linux: "linux", + win32: "windows", + } + const token = tokens[platform] + if (!token) { + log.error(`Platform ${platform} is not supported by clangd auto-download`) + return + } + + const assets = release.assets ?? [] + const valid = (item: { name?: string; browser_download_url?: string }) => { + if (!item.name) return false + if (!item.browser_download_url) return false + if (!item.name.includes(token)) return false + return item.name.includes(tag) + } + + const asset = + assets.find((item) => valid(item) && item.name?.endsWith(".zip")) ?? + assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ?? + assets.find((item) => valid(item)) + if (!asset?.name || !asset.browser_download_url) { + log.error("clangd could not match release asset", { tag, platform }) + return + } + + const name = asset.name + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + log.error("Failed to download clangd") + return + } + + const archive = path.join(Global.Path.bin, name) + const buf = await downloadResponse.arrayBuffer() + if (buf.byteLength === 0) { + log.error("Failed to write clangd archive") + return + } + await Bun.write(archive, buf) + + const zip = name.endsWith(".zip") + const tar = name.endsWith(".tar.xz") + if (!zip && !tar) { + log.error("clangd encountered unsupported asset", { asset: name }) + return + } + + if (zip) { + await $`unzip -o -q ${archive}`.quiet().cwd(Global.Path.bin).nothrow() + } + if (tar) { + await $`tar -xf ${archive}`.cwd(Global.Path.bin).nothrow() + } + await fs.rm(archive, { force: true }) + + const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) + if (!(await Bun.file(bin).exists())) { + log.error("Failed to extract clangd binary") + return + } + + if (platform !== "win32") { + await $`chmod +x ${bin}`.nothrow() + } + + await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) + await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {}) + + log.info(`installed clangd`, { bin }) + return { - process: spawn(bin, ["--background-index", "--clang-tidy"], { + process: spawn(bin, args, { cwd: root, }), } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 56269395..a9be085b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8739e74e..25de5755 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +}