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

This commit is contained in:
Kit Langton
2026-04-17 21:47:59 -04:00
committed by GitHub
parent a5d99e7a3c
commit e6fd57165e
4 changed files with 75 additions and 67 deletions
+19 -20
View File
@@ -11,7 +11,6 @@ import z from "zod"
import type * as LSPServer from "./server" import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
import { withTimeout } from "../util/timeout" import { withTimeout } from "../util/timeout"
import { Instance } from "../project/instance"
import { Filesystem } from "../util" import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150 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) const l = log.clone().tag("serverID", input.serverID)
l.info("starting client") l.info("starting client")
@@ -145,33 +144,33 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return connection return connection
}, },
notify: { notify: {
async open(input: { path: string }) { async open(request: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
const text = await Filesystem.readText(input.path) const text = await Filesystem.readText(request.path)
const extension = path.extname(input.path) const extension = path.extname(request.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
const version = files[input.path] const version = files[request.path]
if (version !== undefined) { if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", input) log.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", { await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [ changes: [
{ {
uri: pathToFileURL(input.path).href, uri: pathToFileURL(request.path).href,
type: 2, // Changed type: 2, // Changed
}, },
], ],
}) })
const next = version + 1 const next = version + 1
files[input.path] = next files[request.path] = next
log.info("textDocument/didChange", { log.info("textDocument/didChange", {
path: input.path, path: request.path,
version: next, version: next,
}) })
await connection.sendNotification("textDocument/didChange", { await connection.sendNotification("textDocument/didChange", {
textDocument: { textDocument: {
uri: pathToFileURL(input.path).href, uri: pathToFileURL(request.path).href,
version: next, version: next,
}, },
contentChanges: [{ text }], contentChanges: [{ text }],
@@ -179,36 +178,36 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return return
} }
log.info("workspace/didChangeWatchedFiles", input) log.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", { await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [ changes: [
{ {
uri: pathToFileURL(input.path).href, uri: pathToFileURL(request.path).href,
type: 1, // Created type: 1, // Created
}, },
], ],
}) })
log.info("textDocument/didOpen", input) log.info("textDocument/didOpen", request)
diagnostics.delete(input.path) diagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", { await connection.sendNotification("textDocument/didOpen", {
textDocument: { textDocument: {
uri: pathToFileURL(input.path).href, uri: pathToFileURL(request.path).href,
languageId, languageId,
version: 0, version: 0,
text, text,
}, },
}) })
files[input.path] = 0 files[request.path] = 0
return return
}, },
}, },
get diagnostics() { get diagnostics() {
return diagnostics return diagnostics
}, },
async waitForDiagnostics(input: { path: string }) { async waitForDiagnostics(request: { path: string }) {
const normalizedPath = Filesystem.normalizePath( 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 }) log.info("waiting for diagnostics", { path: normalizedPath })
let unsub: () => void let unsub: () => void
+14 -8
View File
@@ -7,12 +7,12 @@ import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server" import * as LSPServer from "./server"
import z from "zod" import z from "zod"
import { Config } from "../config" import { Config } from "../config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Process } from "../util" import { Process } from "../util"
import { spawn as lspspawn } from "./launch" import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
const log = Log.create({ service: "lsp" }) const log = Log.create({ service: "lsp" })
@@ -162,7 +162,7 @@ export const layer = Layer.effect(
const config = yield* Config.Service const config = yield* Config.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* () { Effect.fn("LSP.state")(function* (ctx) {
const cfg = yield* config.get() const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {} const servers: Record<string, LSPServer.Info> = {}
@@ -187,7 +187,7 @@ export const layer = Layer.effect(
servers[name] = { servers[name] = {
...existing, ...existing,
id: name, id: name,
root: existing?.root ?? (async () => Instance.directory), root: existing?.root ?? (async (_file, ctx) => ctx.directory),
extensions: item.extensions ?? existing?.extensions ?? [], extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({ spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), { 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) { 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) const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file 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) { async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server const handle = await server
.spawn(root) .spawn(root, ctx)
.then((value) => { .then((value) => {
if (!value) s.broken.add(key) if (!value) s.broken.add(key)
return value return value
@@ -251,6 +254,7 @@ export const layer = Layer.effect(
serverID: server.id, serverID: server.id,
server: handle, server: handle,
root, root,
directory: ctx.directory,
}).catch(async (err) => { }).catch(async (err) => {
s.broken.add(key) s.broken.add(key)
await Process.stop(handle.process) await Process.stop(handle.process)
@@ -273,7 +277,7 @@ export const layer = Layer.effect(
for (const server of Object.values(s.servers)) { for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue 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 (!root) continue
if (s.broken.has(root + server.id)) 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 status = Effect.fn("LSP.status")(function* () {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const result: Status[] = [] const result: Status[] = []
for (const client of s.clients) { for (const client of s.clients) {
result.push({ result.push({
id: client.serverID, id: client.serverID,
name: s.servers[client.serverID].id, name: s.servers[client.serverID].id,
root: path.relative(Instance.directory, client.root), root: path.relative(ctx.directory, client.root),
status: "connected", status: "connected",
}) })
} }
@@ -340,12 +345,13 @@ export const layer = Layer.effect(
}) })
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) { for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue 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 (!root) continue
if (s.broken.has(root + server.id)) continue if (s.broken.has(root + server.id)) continue
return true return true
+39 -39
View File
@@ -6,7 +6,7 @@ import { Log } from "../util"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../util" import { Filesystem } from "../util"
import { Instance } from "../project/instance" import type { InstanceContext } from "../project/instance"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Archive } from "../util" import { Archive } from "../util"
import { Process } from "../util" import { Process } from "../util"
@@ -29,15 +29,15 @@ export interface Handle {
initialization?: Record<string, any> 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 => { const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => {
return async (file) => { return async (file, ctx) => {
if (excludePatterns) { if (excludePatterns) {
const excludedFiles = Filesystem.up({ const excludedFiles = Filesystem.up({
targets: excludePatterns, targets: excludePatterns,
start: path.dirname(file), start: path.dirname(file),
stop: Instance.directory, stop: ctx.directory,
}) })
const excluded = await excludedFiles.next() const excluded = await excludedFiles.next()
await excludedFiles.return() await excludedFiles.return()
@@ -46,11 +46,11 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
const files = Filesystem.up({ const files = Filesystem.up({
targets: includePatterns, targets: includePatterns,
start: path.dirname(file), start: path.dirname(file),
stop: Instance.directory, stop: ctx.directory,
}) })
const first = await files.next() const first = await files.next()
await files.return() await files.return()
if (!first.value) return Instance.directory if (!first.value) return ctx.directory
return path.dirname(first.value) return path.dirname(first.value)
} }
} }
@@ -60,16 +60,16 @@ export interface Info {
extensions: string[] extensions: string[]
global?: boolean global?: boolean
root: RootFunction root: RootFunction
spawn(root: string): Promise<Handle | undefined> spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
} }
export const Deno: Info = { export const Deno: Info = {
id: "deno", id: "deno",
root: async (file) => { root: async (file, ctx) => {
const files = Filesystem.up({ const files = Filesystem.up({
targets: ["deno.json", "deno.jsonc"], targets: ["deno.json", "deno.jsonc"],
start: path.dirname(file), start: path.dirname(file),
stop: Instance.directory, stop: ctx.directory,
}) })
const first = await files.next() const first = await files.next()
await files.return() await files.return()
@@ -98,8 +98,8 @@ export const Typescript: Info = {
["deno.json", "deno.jsonc"], ["deno.json", "deno.jsonc"],
), ),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(root) { async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
log.info("typescript server", { tsserver }) log.info("typescript server", { tsserver })
if (!tsserver) return if (!tsserver) return
const bin = await Npm.which("typescript-language-server") const bin = await Npm.which("typescript-language-server")
@@ -154,8 +154,8 @@ export const ESLint: Info = {
id: "eslint", id: "eslint",
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
async spawn(root) { async spawn(root, ctx) {
const eslint = Module.resolve("eslint", Instance.directory) const eslint = Module.resolve("eslint", ctx.directory)
if (!eslint) return if (!eslint) return
log.info("spawning eslint server") log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -219,7 +219,7 @@ export const Oxlint: Info = {
"package.json", "package.json",
]), ]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], 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 ext = process.platform === "win32" ? ".cmd" : ""
const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext)
@@ -232,7 +232,7 @@ export const Oxlint: Info = {
const candidates = Filesystem.up({ const candidates = Filesystem.up({
targets: [target], targets: [target],
start: root, start: root,
stop: Instance.worktree, stop: ctx.worktree,
}) })
const first = await candidates.next() const first = await candidates.next()
await candidates.return() await candidates.return()
@@ -344,10 +344,10 @@ export const Biome: Info = {
export const Gopls: Info = { export const Gopls: Info = {
id: "gopls", id: "gopls",
root: async (file) => { root: async (file, ctx) => {
const work = await NearestRoot(["go.work"])(file) const work = await NearestRoot(["go.work"])(file, ctx)
if (work) return work if (work) return work
return NearestRoot(["go.mod", "go.sum"])(file) return NearestRoot(["go.mod", "go.sum"])(file, ctx)
}, },
extensions: [".go"], extensions: [".go"],
async spawn(root) { async spawn(root) {
@@ -810,8 +810,8 @@ export const SourceKit: Info = {
export const RustAnalyzer: Info = { export const RustAnalyzer: Info = {
id: "rust", id: "rust",
root: async (root) => { root: async (file, ctx) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx)
if (crateRoot === undefined) { if (crateRoot === undefined) {
return undefined return undefined
} }
@@ -834,7 +834,7 @@ export const RustAnalyzer: Info = {
currentDir = parentDir currentDir = parentDir
// Stop if we've gone above the app root // Stop if we've gone above the app root
if (!currentDir.startsWith(Instance.worktree)) break if (!currentDir.startsWith(ctx.worktree)) break
} }
return crateRoot return crateRoot
@@ -1031,8 +1031,8 @@ export const Astro: Info = {
id: "astro", id: "astro",
extensions: [".astro"], extensions: [".astro"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) { async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
if (!tsserver) { if (!tsserver) {
log.info("typescript not found, required for Astro language server") log.info("typescript not found, required for Astro language server")
return return
@@ -1067,7 +1067,7 @@ export const Astro: Info = {
export const JDTLS: Info = { export const JDTLS: Info = {
id: "jdtls", id: "jdtls",
root: async (file) => { root: async (file, ctx) => {
// Without exclusions, NearestRoot defaults to instance directory so we can't // Without exclusions, NearestRoot defaults to instance directory so we can't
// distinguish between a) no project found and b) project found at instance dir. // 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. // So we can't choose the root from (potential) monorepo markers first.
@@ -1080,9 +1080,9 @@ export const JDTLS: Info = {
NearestRoot( NearestRoot(
["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
exclusionsForMonorepos, exclusionsForMonorepos,
)(file), )(file, ctx),
NearestRoot(gradleMarkers, settingsMarkers)(file), NearestRoot(gradleMarkers, settingsMarkers)(file, ctx),
NearestRoot(settingsMarkers)(file), NearestRoot(settingsMarkers)(file, ctx),
]) ])
// If projectRoot is undefined we know we are in a monorepo or no project at all. // 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 = { export const KotlinLS: Info = {
id: "kotlin-ls", id: "kotlin-ls",
extensions: [".kt", ".kts"], extensions: [".kt", ".kts"],
root: async (file) => { root: async (file, ctx) => {
// 1) Nearest Gradle root (multi-project or included build) // 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 if (settingsRoot) return settingsRoot
// 2) Gradle wrapper (strong root signal) // 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 if (wrapperRoot) return wrapperRoot
// 3) Single-project or module-level build // 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 if (buildRoot) return buildRoot
// 4) Maven fallback // 4) Maven fallback
return NearestRoot(["pom.xml"])(file) return NearestRoot(["pom.xml"])(file, ctx)
}, },
async spawn(root) { async spawn(root) {
const distPath = path.join(Global.Path.bin, "kotlin-ls") const distPath = path.join(Global.Path.bin, "kotlin-ls")
@@ -1539,7 +1539,7 @@ export const Ocaml: Info = {
export const BashLS: Info = { export const BashLS: Info = {
id: "bash", id: "bash",
extensions: [".sh", ".bash", ".zsh", ".ksh"], extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory, root: async (_file, ctx) => ctx.directory,
async spawn(root) { async spawn(root) {
let binary = which("bash-language-server") let binary = which("bash-language-server")
const args: string[] = [] const args: string[] = []
@@ -1734,7 +1734,7 @@ export const TexLab: Info = {
export const DockerfileLS: Info = { export const DockerfileLS: Info = {
id: "dockerfile", id: "dockerfile",
extensions: [".dockerfile", "Dockerfile"], extensions: [".dockerfile", "Dockerfile"],
root: async () => Instance.directory, root: async (_file, ctx) => ctx.directory,
async spawn(root) { async spawn(root) {
let binary = which("docker-langserver") let binary = which("docker-langserver")
const args: string[] = [] const args: string[] = []
@@ -1799,16 +1799,16 @@ export const Clojure: Info = {
export const Nixd: Info = { export const Nixd: Info = {
id: "nixd", id: "nixd",
extensions: [".nix"], extensions: [".nix"],
root: async (file) => { root: async (file, ctx) => {
// First, look for flake.nix - the most reliable Nix project root indicator // First, look for flake.nix - the most reliable Nix project root indicator
const flakeRoot = await NearestRoot(["flake.nix"])(file) const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx)
if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot
// If no flake.nix, fall back to git repository root // 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 // Finally, use the instance directory as fallback
return Instance.directory return ctx.directory
}, },
async spawn(root) { async spawn(root) {
const nixd = which("nixd") const nixd = which("nixd")
@@ -31,6 +31,7 @@ describe("LSPClient interop", () => {
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: process.cwd(), root: process.cwd(),
directory: process.cwd(),
}), }),
}) })
@@ -55,6 +56,7 @@ describe("LSPClient interop", () => {
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: process.cwd(), root: process.cwd(),
directory: process.cwd(),
}), }),
}) })
@@ -79,6 +81,7 @@ describe("LSPClient interop", () => {
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: process.cwd(), root: process.cwd(),
directory: process.cwd(),
}), }),
}) })