2
0
Эх сурвалжийг харах

refactor: remove ambient instance reads from lsp (#23023)

Kit Langton 19 цаг өмнө
parent
commit
e6fd57165e

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

@@ -11,7 +11,6 @@ import z from "zod"
 import type * as LSPServer from "./server"
 import { NamedError } from "@opencode-ai/shared/util/error"
 import { withTimeout } from "../util/timeout"
-import { Instance } from "../project/instance"
 import { Filesystem } from "../util"
 
 const DIAGNOSTICS_DEBOUNCE_MS = 150
@@ -39,7 +38,7 @@ export const Event = {
   ),
 }
 
-export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
+export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
   const l = log.clone().tag("serverID", input.serverID)
   l.info("starting client")
 
@@ -145,33 +144,33 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
       return connection
     },
     notify: {
-      async open(input: { path: string }) {
-        input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
-        const text = await Filesystem.readText(input.path)
-        const extension = path.extname(input.path)
+      async open(request: { path: string }) {
+        request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
+        const text = await Filesystem.readText(request.path)
+        const extension = path.extname(request.path)
         const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
 
-        const version = files[input.path]
+        const version = files[request.path]
         if (version !== undefined) {
-          log.info("workspace/didChangeWatchedFiles", input)
+          log.info("workspace/didChangeWatchedFiles", request)
           await connection.sendNotification("workspace/didChangeWatchedFiles", {
             changes: [
               {
-                uri: pathToFileURL(input.path).href,
+                uri: pathToFileURL(request.path).href,
                 type: 2, // Changed
               },
             ],
           })
 
           const next = version + 1
-          files[input.path] = next
+          files[request.path] = next
           log.info("textDocument/didChange", {
-            path: input.path,
+            path: request.path,
             version: next,
           })
           await connection.sendNotification("textDocument/didChange", {
             textDocument: {
-              uri: pathToFileURL(input.path).href,
+              uri: pathToFileURL(request.path).href,
               version: next,
             },
             contentChanges: [{ text }],
@@ -179,36 +178,36 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
           return
         }
 
-        log.info("workspace/didChangeWatchedFiles", input)
+        log.info("workspace/didChangeWatchedFiles", request)
         await connection.sendNotification("workspace/didChangeWatchedFiles", {
           changes: [
             {
-              uri: pathToFileURL(input.path).href,
+              uri: pathToFileURL(request.path).href,
               type: 1, // Created
             },
           ],
         })
 
-        log.info("textDocument/didOpen", input)
-        diagnostics.delete(input.path)
+        log.info("textDocument/didOpen", request)
+        diagnostics.delete(request.path)
         await connection.sendNotification("textDocument/didOpen", {
           textDocument: {
-            uri: pathToFileURL(input.path).href,
+            uri: pathToFileURL(request.path).href,
             languageId,
             version: 0,
             text,
           },
         })
-        files[input.path] = 0
+        files[request.path] = 0
         return
       },
     },
     get diagnostics() {
       return diagnostics
     },
-    async waitForDiagnostics(input: { path: string }) {
+    async waitForDiagnostics(request: { path: string }) {
       const normalizedPath = Filesystem.normalizePath(
-        path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
+        path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
       )
       log.info("waiting for diagnostics", { path: normalizedPath })
       let unsub: () => void

+ 14 - 8
packages/opencode/src/lsp/lsp.ts

@@ -7,12 +7,12 @@ import { pathToFileURL, fileURLToPath } from "url"
 import * as LSPServer from "./server"
 import z from "zod"
 import { Config } from "../config"
-import { Instance } from "../project/instance"
 import { Flag } from "@/flag/flag"
 import { Process } from "../util"
 import { spawn as lspspawn } from "./launch"
 import { Effect, Layer, Context } from "effect"
 import { InstanceState } from "@/effect"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 
 const log = Log.create({ service: "lsp" })
 
@@ -162,7 +162,7 @@ export const layer = Layer.effect(
     const config = yield* Config.Service
 
     const state = yield* InstanceState.make<State>(
-      Effect.fn("LSP.state")(function* () {
+      Effect.fn("LSP.state")(function* (ctx) {
         const cfg = yield* config.get()
 
         const servers: Record<string, LSPServer.Info> = {}
@@ -187,7 +187,7 @@ export const layer = Layer.effect(
               servers[name] = {
                 ...existing,
                 id: name,
-                root: existing?.root ?? (async () => Instance.directory),
+                root: existing?.root ?? (async (_file, ctx) => ctx.directory),
                 extensions: item.extensions ?? existing?.extensions ?? [],
                 spawn: async (root) => ({
                   process: lspspawn(item.command[0], item.command.slice(1), {
@@ -225,7 +225,10 @@ export const layer = Layer.effect(
     )
 
     const getClients = Effect.fnUntraced(function* (file: string) {
-      if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
+      const ctx = yield* InstanceState.context
+      if (!AppFileSystem.contains(ctx.directory, file) && (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))) {
+        return [] as LSPClient.Info[]
+      }
       const s = yield* InstanceState.get(state)
       return yield* Effect.promise(async () => {
         const extension = path.parse(file).ext || file
@@ -233,7 +236,7 @@ export const layer = Layer.effect(
 
         async function schedule(server: LSPServer.Info, root: string, key: string) {
           const handle = await server
-            .spawn(root)
+            .spawn(root, ctx)
             .then((value) => {
               if (!value) s.broken.add(key)
               return value
@@ -251,6 +254,7 @@ export const layer = Layer.effect(
             serverID: server.id,
             server: handle,
             root,
+            directory: ctx.directory,
           }).catch(async (err) => {
             s.broken.add(key)
             await Process.stop(handle.process)
@@ -273,7 +277,7 @@ export const layer = Layer.effect(
         for (const server of Object.values(s.servers)) {
           if (server.extensions.length && !server.extensions.includes(extension)) continue
 
-          const root = await server.root(file)
+          const root = await server.root(file, ctx)
           if (!root) continue
           if (s.broken.has(root + server.id)) continue
 
@@ -326,13 +330,14 @@ export const layer = Layer.effect(
     })
 
     const status = Effect.fn("LSP.status")(function* () {
+      const ctx = yield* InstanceState.context
       const s = yield* InstanceState.get(state)
       const result: Status[] = []
       for (const client of s.clients) {
         result.push({
           id: client.serverID,
           name: s.servers[client.serverID].id,
-          root: path.relative(Instance.directory, client.root),
+          root: path.relative(ctx.directory, client.root),
           status: "connected",
         })
       }
@@ -340,12 +345,13 @@ export const layer = Layer.effect(
     })
 
     const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
+      const ctx = yield* InstanceState.context
       const s = yield* InstanceState.get(state)
       return yield* Effect.promise(async () => {
         const extension = path.parse(file).ext || file
         for (const server of Object.values(s.servers)) {
           if (server.extensions.length && !server.extensions.includes(extension)) continue
-          const root = await server.root(file)
+          const root = await server.root(file, ctx)
           if (!root) continue
           if (s.broken.has(root + server.id)) continue
           return true

+ 39 - 39
packages/opencode/src/lsp/server.ts

@@ -6,7 +6,7 @@ import { Log } from "../util"
 import { text } from "node:stream/consumers"
 import fs from "fs/promises"
 import { Filesystem } from "../util"
-import { Instance } from "../project/instance"
+import type { InstanceContext } from "../project/instance"
 import { Flag } from "../flag/flag"
 import { Archive } from "../util"
 import { Process } from "../util"
@@ -29,15 +29,15 @@ export interface Handle {
   initialization?: Record<string, any>
 }
 
-type RootFunction = (file: string) => Promise<string | undefined>
+type RootFunction = (file: string, ctx: InstanceContext) => Promise<string | undefined>
 
 const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => {
-  return async (file) => {
+  return async (file, ctx) => {
     if (excludePatterns) {
       const excludedFiles = Filesystem.up({
         targets: excludePatterns,
         start: path.dirname(file),
-        stop: Instance.directory,
+        stop: ctx.directory,
       })
       const excluded = await excludedFiles.next()
       await excludedFiles.return()
@@ -46,11 +46,11 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
     const files = Filesystem.up({
       targets: includePatterns,
       start: path.dirname(file),
-      stop: Instance.directory,
+      stop: ctx.directory,
     })
     const first = await files.next()
     await files.return()
-    if (!first.value) return Instance.directory
+    if (!first.value) return ctx.directory
     return path.dirname(first.value)
   }
 }
@@ -60,16 +60,16 @@ export interface Info {
   extensions: string[]
   global?: boolean
   root: RootFunction
-  spawn(root: string): Promise<Handle | undefined>
+  spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
 }
 
 export const Deno: Info = {
   id: "deno",
-  root: async (file) => {
+  root: async (file, ctx) => {
     const files = Filesystem.up({
       targets: ["deno.json", "deno.jsonc"],
       start: path.dirname(file),
-      stop: Instance.directory,
+      stop: ctx.directory,
     })
     const first = await files.next()
     await files.return()
@@ -98,8 +98,8 @@ export const Typescript: Info = {
     ["deno.json", "deno.jsonc"],
   ),
   extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
-  async spawn(root) {
-    const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
+  async spawn(root, ctx) {
+    const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
     log.info("typescript server", { tsserver })
     if (!tsserver) return
     const bin = await Npm.which("typescript-language-server")
@@ -154,8 +154,8 @@ export const ESLint: Info = {
   id: "eslint",
   root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
   extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
-  async spawn(root) {
-    const eslint = Module.resolve("eslint", Instance.directory)
+  async spawn(root, ctx) {
+    const eslint = Module.resolve("eslint", ctx.directory)
     if (!eslint) return
     log.info("spawning eslint server")
     const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -219,7 +219,7 @@ export const Oxlint: Info = {
     "package.json",
   ]),
   extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
-  async spawn(root) {
+  async spawn(root, ctx) {
     const ext = process.platform === "win32" ? ".cmd" : ""
 
     const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext)
@@ -232,7 +232,7 @@ export const Oxlint: Info = {
       const candidates = Filesystem.up({
         targets: [target],
         start: root,
-        stop: Instance.worktree,
+        stop: ctx.worktree,
       })
       const first = await candidates.next()
       await candidates.return()
@@ -344,10 +344,10 @@ export const Biome: Info = {
 
 export const Gopls: Info = {
   id: "gopls",
-  root: async (file) => {
-    const work = await NearestRoot(["go.work"])(file)
+  root: async (file, ctx) => {
+    const work = await NearestRoot(["go.work"])(file, ctx)
     if (work) return work
-    return NearestRoot(["go.mod", "go.sum"])(file)
+    return NearestRoot(["go.mod", "go.sum"])(file, ctx)
   },
   extensions: [".go"],
   async spawn(root) {
@@ -810,8 +810,8 @@ export const SourceKit: Info = {
 
 export const RustAnalyzer: Info = {
   id: "rust",
-  root: async (root) => {
-    const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
+  root: async (file, ctx) => {
+    const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx)
     if (crateRoot === undefined) {
       return undefined
     }
@@ -834,7 +834,7 @@ export const RustAnalyzer: Info = {
       currentDir = parentDir
 
       // Stop if we've gone above the app root
-      if (!currentDir.startsWith(Instance.worktree)) break
+      if (!currentDir.startsWith(ctx.worktree)) break
     }
 
     return crateRoot
@@ -1031,8 +1031,8 @@ export const Astro: Info = {
   id: "astro",
   extensions: [".astro"],
   root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
-  async spawn(root) {
-    const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
+  async spawn(root, ctx) {
+    const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
     if (!tsserver) {
       log.info("typescript not found, required for Astro language server")
       return
@@ -1067,7 +1067,7 @@ export const Astro: Info = {
 
 export const JDTLS: Info = {
   id: "jdtls",
-  root: async (file) => {
+  root: async (file, ctx) => {
     // Without exclusions, NearestRoot defaults to instance directory so we can't
     // distinguish between a) no project found and b) project found at instance dir.
     // So we can't choose the root from (potential) monorepo markers first.
@@ -1080,9 +1080,9 @@ export const JDTLS: Info = {
       NearestRoot(
         ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
         exclusionsForMonorepos,
-      )(file),
-      NearestRoot(gradleMarkers, settingsMarkers)(file),
-      NearestRoot(settingsMarkers)(file),
+      )(file, ctx),
+      NearestRoot(gradleMarkers, settingsMarkers)(file, ctx),
+      NearestRoot(settingsMarkers)(file, ctx),
     ])
 
     // If projectRoot is undefined we know we are in a monorepo or no project at all.
@@ -1189,18 +1189,18 @@ export const JDTLS: Info = {
 export const KotlinLS: Info = {
   id: "kotlin-ls",
   extensions: [".kt", ".kts"],
-  root: async (file) => {
+  root: async (file, ctx) => {
     // 1) Nearest Gradle root (multi-project or included build)
-    const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file)
+    const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file, ctx)
     if (settingsRoot) return settingsRoot
     // 2) Gradle wrapper (strong root signal)
-    const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file)
+    const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file, ctx)
     if (wrapperRoot) return wrapperRoot
     // 3) Single-project or module-level build
-    const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file)
+    const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file, ctx)
     if (buildRoot) return buildRoot
     // 4) Maven fallback
-    return NearestRoot(["pom.xml"])(file)
+    return NearestRoot(["pom.xml"])(file, ctx)
   },
   async spawn(root) {
     const distPath = path.join(Global.Path.bin, "kotlin-ls")
@@ -1539,7 +1539,7 @@ export const Ocaml: Info = {
 export const BashLS: Info = {
   id: "bash",
   extensions: [".sh", ".bash", ".zsh", ".ksh"],
-  root: async () => Instance.directory,
+  root: async (_file, ctx) => ctx.directory,
   async spawn(root) {
     let binary = which("bash-language-server")
     const args: string[] = []
@@ -1734,7 +1734,7 @@ export const TexLab: Info = {
 export const DockerfileLS: Info = {
   id: "dockerfile",
   extensions: [".dockerfile", "Dockerfile"],
-  root: async () => Instance.directory,
+  root: async (_file, ctx) => ctx.directory,
   async spawn(root) {
     let binary = which("docker-langserver")
     const args: string[] = []
@@ -1799,16 +1799,16 @@ export const Clojure: Info = {
 export const Nixd: Info = {
   id: "nixd",
   extensions: [".nix"],
-  root: async (file) => {
+  root: async (file, ctx) => {
     // First, look for flake.nix - the most reliable Nix project root indicator
-    const flakeRoot = await NearestRoot(["flake.nix"])(file)
-    if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot
+    const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx)
+    if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot
 
     // If no flake.nix, fall back to git repository root
-    if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree
+    if (ctx.worktree && ctx.worktree !== ctx.directory) return ctx.worktree
 
     // Finally, use the instance directory as fallback
-    return Instance.directory
+    return ctx.directory
   },
   async spawn(root) {
     const nixd = which("nixd")

+ 3 - 0
packages/opencode/test/lsp/client.test.ts

@@ -31,6 +31,7 @@ describe("LSPClient interop", () => {
           serverID: "fake",
           server: handle as unknown as LSPServer.Handle,
           root: process.cwd(),
+          directory: process.cwd(),
         }),
     })
 
@@ -55,6 +56,7 @@ describe("LSPClient interop", () => {
           serverID: "fake",
           server: handle as unknown as LSPServer.Handle,
           root: process.cwd(),
+          directory: process.cwd(),
         }),
     })
 
@@ -79,6 +81,7 @@ describe("LSPClient interop", () => {
           serverID: "fake",
           server: handle as unknown as LSPServer.Handle,
           root: process.cwd(),
+          directory: process.cwd(),
         }),
     })