Dax Raad 7 месяцев назад
Родитель
Сommit
6de955847c

+ 0 - 1
packages/opencode/src/cli/cmd/debug/lsp.ts

@@ -25,7 +25,6 @@ export const SymbolsCommand = cmd({
   builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
   async handler(args) {
     await bootstrap({ cwd: process.cwd() }, async () => {
-      await LSP.touchFile("./src/index.ts", true)
       using _ = Log.Default.time("symbols")
       const results = await LSP.workspaceSymbol(args.query)
       console.log(JSON.stringify(results, null, 2))

+ 1 - 1
packages/opencode/src/cli/cmd/debug/ripgrep.ts

@@ -45,7 +45,7 @@ const FilesCommand = cmd({
       const files = await Ripgrep.files({
         cwd: app.path.cwd,
         query: args.query,
-        glob: args.glob,
+        glob: args.glob ? [args.glob] : undefined,
         limit: args.limit,
       })
       console.log(files.join("\n"))

+ 9 - 4
packages/opencode/src/file/ripgrep.ts

@@ -185,10 +185,15 @@ export namespace Ripgrep {
     return filepath
   }
 
-  export async function files(input: { cwd: string; query?: string; glob?: string; limit?: number }) {
-    const commands = [
-      `${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
-    ]
+  export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
+    const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
+
+    if (input.glob) {
+      for (const g of input.glob) {
+        commands[0] += ` --glob='${g}'`
+      }
+    }
+
     if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
     if (input.limit) commands.push(`head -n ${input.limit}`)
     const joined = commands.join(" | ")

+ 31 - 20
packages/opencode/src/lsp/client.ts

@@ -34,46 +34,54 @@ export namespace LSPClient {
     ),
   }
 
-  export async function create(serverID: string, server: LSPServer.Handle) {
+  export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
     const app = App.info()
-    log.info("starting client", { id: serverID })
+    const l = log.clone().tag("serverID", input.serverID)
+    l.info("starting client")
 
     const connection = createMessageConnection(
-      new StreamMessageReader(server.process.stdout),
-      new StreamMessageWriter(server.process.stdin),
+      new StreamMessageReader(input.server.process.stdout),
+      new StreamMessageWriter(input.server.process.stdin),
     )
 
     const diagnostics = new Map<string, Diagnostic[]>()
     connection.onNotification("textDocument/publishDiagnostics", (params) => {
       const path = new URL(params.uri).pathname
-      log.info("textDocument/publishDiagnostics", {
+      l.info("textDocument/publishDiagnostics", {
         path,
       })
       const exists = diagnostics.has(path)
       diagnostics.set(path, params.diagnostics)
-      if (!exists && serverID === "typescript") return
-      Bus.publish(Event.Diagnostics, { path, serverID })
+      if (!exists && input.serverID === "typescript") return
+      Bus.publish(Event.Diagnostics, { path, serverID: input.serverID })
+    })
+    connection.onRequest("window/workDoneProgress/create", (params) => {
+      l.info("window/workDoneProgress/create", params)
+      return null
     })
     connection.onRequest("workspace/configuration", async () => {
       return [{}]
     })
     connection.listen()
 
-    log.info("sending initialize", { id: serverID })
+    l.info("sending initialize")
     await withTimeout(
       connection.sendRequest("initialize", {
-        rootUri: "file://" + app.path.cwd,
-        processId: server.process.pid,
+        rootUri: "file://" + input.root,
+        processId: input.server.process.pid,
         workspaceFolders: [
           {
             name: "workspace",
-            uri: "file://" + app.path.cwd,
+            uri: "file://" + input.root,
           },
         ],
         initializationOptions: {
-          ...server.initialization,
+          ...input.server.initialization,
         },
         capabilities: {
+          window: {
+            workDoneProgress: true,
+          },
           workspace: {
             configuration: true,
           },
@@ -90,9 +98,9 @@ export namespace LSPClient {
       }),
       5_000,
     ).catch((err) => {
-      log.error("initialize error", { error: err })
+      l.error("initialize error", { error: err })
       throw new InitializeError(
-        { serverID },
+        { serverID: input.serverID },
         {
           cause: err,
         },
@@ -100,17 +108,15 @@ export namespace LSPClient {
     })
 
     await connection.sendNotification("initialized", {})
-    log.info("initialized", {
-      serverID,
-    })
 
     const files: {
       [path: string]: number
     } = {}
 
     const result = {
+      root: input.root,
       get serverID() {
-        return serverID
+        return input.serverID
       },
       get connection() {
         return connection
@@ -170,13 +176,18 @@ export namespace LSPClient {
           })
       },
       async shutdown() {
-        log.info("shutting down", { serverID })
+        l.info("shutting down")
         connection.end()
         connection.dispose()
-        log.info("shutdown", { serverID })
+        l.info("shutdown")
       },
     }
 
+    if (input.server.onInitialized) {
+      await input.server.onInitialized(result)
+    }
+    l.info("initialized")
+
     return result
   }
 }

+ 26 - 16
packages/opencode/src/lsp/index.ts

@@ -3,8 +3,8 @@ import { Log } from "../util/log"
 import { LSPClient } from "./client"
 import path from "path"
 import { LSPServer } from "./server"
-import { Ripgrep } from "../file/ripgrep"
 import { z } from "zod"
+import { Filesystem } from "../util/filesystem"
 
 export namespace LSP {
   const log = Log.create({ service: "lsp" })
@@ -36,29 +36,36 @@ export namespace LSP {
     "lsp",
     async (app) => {
       log.info("initializing")
-      const clients = new Map<string, LSPClient.Info>()
+      const clients: LSPClient.Info[] = []
+
       for (const server of Object.values(LSPServer)) {
-        for (const extension of server.extensions) {
-          const [file] = await Ripgrep.files({
-            cwd: app.path.cwd,
-            glob: "*" + extension,
+        const roots = await server.roots(app)
+
+        for (const root of roots) {
+          if (!Filesystem.overlaps(app.path.cwd, root)) continue
+          log.info("", {
+            root,
+            serverID: server.id,
           })
-          if (!file) continue
-          const handle = await server.spawn(App.info())
+          const handle = await server.spawn(App.info(), root)
           if (!handle) break
-          const client = await LSPClient.create(server.id, handle).catch((err) => log.error("", { error: err }))
+          const client = await LSPClient.create({
+            serverID: server.id,
+            server: handle,
+            root,
+          }).catch((err) => log.error("", { error: err }))
           if (!client) break
-          clients.set(server.id, client)
-          break
+          clients.push(client)
         }
       }
+
       log.info("initialized")
       return {
         clients,
       }
     },
     async (state) => {
-      for (const client of state.clients.values()) {
+      for (const client of state.clients) {
         await client.shutdown()
       }
     },
@@ -109,14 +116,17 @@ export namespace LSP {
 
   export async function workspaceSymbol(query: string) {
     return run((client) =>
-      client.connection.sendRequest("workspace/symbol", {
-        query,
-      }),
+      client.connection
+        .sendRequest("workspace/symbol", {
+          query,
+        })
+        .then((result: any) => result.slice(0, 10))
+        .catch(() => []),
     ).then((result) => result.flat() as LSP.Symbol[])
   }
 
   async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
-    const clients = await state().then((x) => [...x.clients.values()])
+    const clients = await state().then((x) => x.clients)
     const tasks = clients.map((x) => input(x))
     return Promise.all(tasks)
   }

+ 65 - 111
packages/opencode/src/lsp/server.ts

@@ -6,6 +6,9 @@ import { Log } from "../util/log"
 import { BunProc } from "../bun"
 import { $ } from "bun"
 import fs from "fs/promises"
+import { unique } from "remeda"
+import { Ripgrep } from "../file/ripgrep"
+import type { LSPClient } from "./client"
 
 export namespace LSPServer {
   const log = Log.create({ service: "lsp.server" })
@@ -13,21 +16,40 @@ export namespace LSPServer {
   export interface Handle {
     process: ChildProcessWithoutNullStreams
     initialization?: Record<string, any>
+    onInitialized?: (lsp: LSPClient.Info) => Promise<void>
+  }
+
+  type RootsFunction = (app: App.Info) => Promise<string[]>
+
+  const SimpleRoots = (patterns: string[]): RootsFunction => {
+    return async (app) => {
+      const glob = `**/*/{${patterns.join(",")}}`
+      const files = await Ripgrep.files({
+        glob: [glob],
+        cwd: app.path.root,
+      })
+      const dirs = files.map((file) => path.dirname(file))
+      return unique(dirs).map((dir) => path.join(app.path.root, dir))
+    }
   }
 
   export interface Info {
     id: string
     extensions: string[]
-    spawn(app: App.Info): Promise<Handle | undefined>
+    global?: boolean
+    roots: (app: App.Info) => Promise<string[]>
+    spawn(app: App.Info, root: string): Promise<Handle | undefined>
   }
 
   export const Typescript: Info = {
     id: "typescript",
+    roots: SimpleRoots(["tsconfig.json", "jsconfig.json", "package.json"]),
     extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
-    async spawn(app) {
+    async spawn(app, root) {
       const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
       if (!tsserver) return
       const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
+        cwd: root,
         env: {
           ...process.env,
           BUN_BE_BUN: "1",
@@ -40,14 +62,31 @@ export namespace LSPServer {
             path: tsserver,
           },
         },
+        // tsserver sucks and won't start processing codebase until you open a file
+        onInitialized: async (lsp) => {
+          const [hint] = await Ripgrep.files({
+            cwd: lsp.root,
+            glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
+            limit: 1,
+          })
+          await new Promise<void>(async (resolve) => {
+            const notif = lsp.connection.onNotification("$/progress", (params) => {
+              if (params.value.kind !== "end") return
+              notif.dispose()
+              resolve()
+            })
+            await lsp.notify.open({ path: hint })
+          })
+        },
       }
     },
   }
 
   export const Gopls: Info = {
     id: "golang",
+    roots: SimpleRoots(["go.mod", "go.sum"]),
     extensions: [".go"],
-    async spawn() {
+    async spawn(_, root) {
       let bin = Bun.which("gopls", {
         PATH: process.env["PATH"] + ":" + Global.Path.bin,
       })
@@ -72,15 +111,18 @@ export namespace LSPServer {
         })
       }
       return {
-        process: spawn(bin!),
+        process: spawn(bin!, {
+          cwd: root,
+        }),
       }
     },
   }
 
   export const RubyLsp: Info = {
     id: "ruby-lsp",
+    roots: SimpleRoots(["Gemfile"]),
     extensions: [".rb", ".rake", ".gemspec", ".ru"],
-    async spawn() {
+    async spawn(_, root) {
       let bin = Bun.which("ruby-lsp", {
         PATH: process.env["PATH"] + ":" + Global.Path.bin,
       })
@@ -109,7 +151,9 @@ export namespace LSPServer {
         })
       }
       return {
-        process: spawn(bin!, ["--stdio"]),
+        process: spawn(bin!, ["--stdio"], {
+          cwd: root,
+        }),
       }
     },
   }
@@ -117,8 +161,17 @@ export namespace LSPServer {
   export const Pyright: Info = {
     id: "pyright",
     extensions: [".py", ".pyi"],
-    async spawn() {
+    roots: SimpleRoots([
+      "pyproject.toml",
+      "setup.py",
+      "setup.cfg",
+      "requirements.txt",
+      "Pipfile",
+      "pyrightconfig.json",
+    ]),
+    async spawn(_, root) {
       const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
+        cwd: root,
         env: {
           ...process.env,
           BUN_BE_BUN: "1",
@@ -133,7 +186,8 @@ export namespace LSPServer {
   export const ElixirLS: Info = {
     id: "elixir-ls",
     extensions: [".ex", ".exs"],
-    async spawn() {
+    roots: SimpleRoots(["mix.exs", "mix.lock"]),
+    async spawn(_, root) {
       let binary = Bun.which("elixir-ls")
       if (!binary) {
         const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
@@ -177,109 +231,9 @@ export namespace LSPServer {
       }
 
       return {
-        process: spawn(binary),
-      }
-    },
-  }
-
-  export const Zls: Info = {
-    id: "zls",
-    extensions: [".zig", ".zon"],
-    async spawn() {
-      let bin = Bun.which("zls", {
-        PATH: process.env["PATH"] + ":" + Global.Path.bin,
-      })
-
-      if (!bin) {
-        const zig = Bun.which("zig")
-        if (!zig) {
-          log.error("Zig is required to use zls. Please install Zig first.")
-          return
-        }
-
-        log.info("downloading zls from GitHub releases")
-
-        const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
-        if (!releaseResponse.ok) {
-          log.error("Failed to fetch zls release info")
-          return
-        }
-
-        const release = await releaseResponse.json()
-
-        const platform = process.platform
-        const arch = process.arch
-        let assetName = ""
-
-        let zlsArch: string = arch
-        if (arch === "arm64") zlsArch = "aarch64"
-        else if (arch === "x64") zlsArch = "x86_64"
-        else if (arch === "ia32") zlsArch = "x86"
-
-        let zlsPlatform: string = platform
-        if (platform === "darwin") zlsPlatform = "macos"
-        else if (platform === "win32") zlsPlatform = "windows"
-
-        const ext = platform === "win32" ? "zip" : "tar.xz"
-
-        assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}`
-
-        const supportedCombos = [
-          "zls-x86_64-linux.tar.xz",
-          "zls-x86_64-macos.tar.xz",
-          "zls-x86_64-windows.zip",
-          "zls-aarch64-linux.tar.xz",
-          "zls-aarch64-macos.tar.xz",
-          "zls-aarch64-windows.zip",
-          "zls-x86-linux.tar.xz",
-          "zls-x86-windows.zip",
-        ]
-
-        if (!supportedCombos.includes(assetName)) {
-          log.error("Unsupported platform/architecture for zls", { platform, arch, assetName })
-          return
-        }
-
-        const asset = release.assets?.find((a: any) => a.name === assetName)
-
-        if (!asset) {
-          log.error("Could not find zls download for platform", { platform, arch, assetName })
-          return
-        }
-
-        const downloadUrl = asset.browser_download_url
-        log.info("downloading zls", { url: downloadUrl })
-
-        const response = await fetch(downloadUrl)
-        if (!response.ok) {
-          log.error("Failed to download zls")
-          return
-        }
-
-        const isZip = assetName.endsWith(".zip")
-        const archivePath = path.join(Global.Path.bin, isZip ? "zls.zip" : "zls.tar.xz")
-        await Bun.file(archivePath).write(response)
-
-        if (isZip) {
-          await $`unzip -o -q ${archivePath} -d ${Global.Path.bin}`.nothrow()
-        } else {
-          await $`tar -xf ${archivePath} -C ${Global.Path.bin}`.quiet()
-        }
-
-        await fs.rm(archivePath, { force: true })
-
-        if (platform !== "win32") {
-          bin = path.join(Global.Path.bin, "zls")
-          await $`chmod +x ${bin}`.quiet()
-        } else {
-          bin = path.join(Global.Path.bin, "zls.exe")
-        }
-
-        log.info("installed zls", { bin })
-      }
-
-      return {
-        process: spawn(bin!),
+        process: spawn(binary, {
+          cwd: root,
+        }),
       }
     },
   }

+ 1 - 1
packages/opencode/src/tool/glob.ts

@@ -27,7 +27,7 @@ export const GlobTool = Tool.define({
     let truncated = false
     for (const file of await Ripgrep.files({
       cwd: search,
-      glob: params.pattern,
+      glob: [params.pattern],
     })) {
       if (files.length >= limit) {
         truncated = true

+ 11 - 1
packages/opencode/src/util/filesystem.ts

@@ -1,7 +1,17 @@
 import { exists } from "fs/promises"
-import { dirname, join } from "path"
+import { dirname, join, relative } from "path"
 
 export namespace Filesystem {
+  export function overlaps(a: string, b: string) {
+    const relA = relative(a, b)
+    const relB = relative(b, a)
+    return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
+  }
+
+  export function contains(parent: string, child: string) {
+    return relative(parent, child).startsWith("..")
+  }
+
   export async function findUp(target: string, start: string, stop?: string) {
     let current = start
     const result = []