mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83f83ef29b | |||
| 7b38fb1e62 | |||
| bd5b892234 | |||
| 370e47a5d0 | |||
| 00120c32a8 | |||
| 6b7f34df20 | |||
| f3d1fd9ce8 | |||
| 280b9d4c80 | |||
| 0c1ffc6fa9 | |||
| 8d2d871a58 | |||
| 1eafb2160a | |||
| 2b73a08916 | |||
| 11c0ad24aa |
@@ -29,7 +29,7 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter<
|
|||||||
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
|
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
|
||||||
>((data) => {
|
>((data) => {
|
||||||
if (typeof data === "boolean") return undefined
|
if (typeof data === "boolean") return undefined
|
||||||
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
|
const serverIds = new Set(Object.values(LSPServer.Builtins).map((server) => server.id))
|
||||||
const ok = Object.entries(data).every(([id, config]) => {
|
const ok = Object.entries(data).every(([id, config]) => {
|
||||||
if ("disabled" in config && config.disabled) return true
|
if ("disabled" in config && config.disabled) return true
|
||||||
if (serverIds.has(id)) return true
|
if (serverIds.has(id)) return true
|
||||||
|
|||||||
+137
-125
@@ -4,13 +4,13 @@ import path from "path"
|
|||||||
import { pathToFileURL, fileURLToPath } from "url"
|
import { pathToFileURL, fileURLToPath } from "url"
|
||||||
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
|
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
|
||||||
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
|
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
import { Process } from "../util"
|
import { Process } from "../util"
|
||||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||||
import z from "zod"
|
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 { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Filesystem } from "../util"
|
import { Filesystem } from "../util"
|
||||||
|
|
||||||
@@ -18,7 +18,19 @@ const DIAGNOSTICS_DEBOUNCE_MS = 150
|
|||||||
|
|
||||||
const log = Log.create({ service: "lsp.client" })
|
const log = Log.create({ service: "lsp.client" })
|
||||||
|
|
||||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
type Connection = ReturnType<typeof createMessageConnection>
|
||||||
|
|
||||||
|
export interface Info {
|
||||||
|
readonly root: string
|
||||||
|
readonly serverID: string
|
||||||
|
readonly connection: Connection
|
||||||
|
readonly notify: {
|
||||||
|
readonly open: (input: { path: string }) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
readonly diagnostics: Map<string, Diagnostic[]>
|
||||||
|
readonly waitForDiagnostics: (input: { path: string }) => Effect.Effect<void>
|
||||||
|
readonly shutdown: () => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
export type Diagnostic = VSCodeDiagnostic
|
export type Diagnostic = VSCodeDiagnostic
|
||||||
|
|
||||||
@@ -39,7 +51,11 @@ export const Event = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
|
export const create = Effect.fn("LSPClient.create")(function* (input: {
|
||||||
|
serverID: string
|
||||||
|
server: LSPServer.Handle
|
||||||
|
root: 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")
|
||||||
|
|
||||||
@@ -64,10 +80,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
|
|||||||
l.info("window/workDoneProgress/create", params)
|
l.info("window/workDoneProgress/create", params)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
connection.onRequest("workspace/configuration", async () => {
|
connection.onRequest("workspace/configuration", async () => [input.server.initialization ?? {}])
|
||||||
// Return server initialization options
|
|
||||||
return [input.server.initialization ?? {}]
|
|
||||||
})
|
|
||||||
connection.onRequest("client/registerCapability", async () => {})
|
connection.onRequest("client/registerCapability", async () => {})
|
||||||
connection.onRequest("client/unregisterCapability", async () => {})
|
connection.onRequest("client/unregisterCapability", async () => {})
|
||||||
connection.onRequest("workspace/workspaceFolders", async () => [
|
connection.onRequest("workspace/workspaceFolders", async () => [
|
||||||
@@ -79,7 +92,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
|
|||||||
connection.listen()
|
connection.listen()
|
||||||
|
|
||||||
l.info("sending initialize")
|
l.info("sending initialize")
|
||||||
await withTimeout(
|
yield* Effect.tryPromise(() =>
|
||||||
connection.sendRequest("initialize", {
|
connection.sendRequest("initialize", {
|
||||||
rootUri: pathToFileURL(input.root).href,
|
rootUri: pathToFileURL(input.root).href,
|
||||||
processId: input.server.process.pid,
|
processId: input.server.process.pid,
|
||||||
@@ -113,30 +126,123 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
45_000,
|
).pipe(
|
||||||
).catch((err) => {
|
Effect.timeout(45_000),
|
||||||
l.error("initialize error", { error: err })
|
Effect.mapError((cause) => new InitializeError({ serverID: input.serverID }, { cause })),
|
||||||
throw new InitializeError(
|
Effect.tapError((error) => Effect.sync(() => l.error("initialize error", { error }))),
|
||||||
{ serverID: input.serverID },
|
)
|
||||||
{
|
|
||||||
cause: err,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
await connection.sendNotification("initialized", {})
|
yield* Effect.tryPromise(() => connection.sendNotification("initialized", {}))
|
||||||
|
|
||||||
if (input.server.initialization) {
|
if (input.server.initialization) {
|
||||||
await connection.sendNotification("workspace/didChangeConfiguration", {
|
yield* Effect.tryPromise(() =>
|
||||||
settings: input.server.initialization,
|
connection.sendNotification("workspace/didChangeConfiguration", {
|
||||||
})
|
settings: input.server.initialization,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const files: {
|
const files: Record<string, number> = {}
|
||||||
[path: string]: number
|
|
||||||
} = {}
|
|
||||||
|
|
||||||
const result = {
|
const open = Effect.fn("LSPClient.notify.open")(function* (next: { path: string }) {
|
||||||
|
next.path = path.isAbsolute(next.path) ? next.path : path.resolve(Instance.directory, next.path)
|
||||||
|
const text = yield* Effect.promise(() => Filesystem.readText(next.path)).pipe(Effect.orDie)
|
||||||
|
const extension = path.extname(next.path)
|
||||||
|
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||||
|
|
||||||
|
const version = files[next.path]
|
||||||
|
if (version !== undefined) {
|
||||||
|
log.info("workspace/didChangeWatchedFiles", next)
|
||||||
|
yield* Effect.tryPromise(() =>
|
||||||
|
connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
uri: pathToFileURL(next.path).href,
|
||||||
|
type: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const nextVersion = version + 1
|
||||||
|
files[next.path] = nextVersion
|
||||||
|
log.info("textDocument/didChange", {
|
||||||
|
path: next.path,
|
||||||
|
version: nextVersion,
|
||||||
|
})
|
||||||
|
yield* Effect.tryPromise(() =>
|
||||||
|
connection.sendNotification("textDocument/didChange", {
|
||||||
|
textDocument: {
|
||||||
|
uri: pathToFileURL(next.path).href,
|
||||||
|
version: nextVersion,
|
||||||
|
},
|
||||||
|
contentChanges: [{ text }],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.orDie)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("workspace/didChangeWatchedFiles", next)
|
||||||
|
yield* Effect.tryPromise(() =>
|
||||||
|
connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
uri: pathToFileURL(next.path).href,
|
||||||
|
type: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
log.info("textDocument/didOpen", next)
|
||||||
|
diagnostics.delete(next.path)
|
||||||
|
yield* Effect.tryPromise(() =>
|
||||||
|
connection.sendNotification("textDocument/didOpen", {
|
||||||
|
textDocument: {
|
||||||
|
uri: pathToFileURL(next.path).href,
|
||||||
|
languageId,
|
||||||
|
version: 0,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).pipe(Effect.orDie)
|
||||||
|
files[next.path] = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const waitForDiagnostics = Effect.fn("LSPClient.waitForDiagnostics")(function* (next: { path: string }) {
|
||||||
|
const normalizedPath = Filesystem.normalizePath(
|
||||||
|
path.isAbsolute(next.path) ? next.path : path.resolve(Instance.directory, next.path),
|
||||||
|
)
|
||||||
|
log.info("waiting for diagnostics", { path: normalizedPath })
|
||||||
|
yield* Effect.callback<void>((resume) => {
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||||
|
if (event.properties.path !== normalizedPath || event.properties.serverID !== input.serverID) return
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
log.info("got diagnostics", { path: normalizedPath })
|
||||||
|
resume(Effect.void)
|
||||||
|
}, DIAGNOSTICS_DEBOUNCE_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Effect.sync(() => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
unsub()
|
||||||
|
})
|
||||||
|
}).pipe(Effect.timeoutOption(3000), Effect.asVoid)
|
||||||
|
})
|
||||||
|
|
||||||
|
const shutdown = Effect.fn("LSPClient.shutdown")(function* () {
|
||||||
|
l.info("shutting down")
|
||||||
|
connection.end()
|
||||||
|
connection.dispose()
|
||||||
|
yield* Effect.promise(() => Process.stop(input.server.process)).pipe(Effect.orDie)
|
||||||
|
l.info("shutdown")
|
||||||
|
})
|
||||||
|
|
||||||
|
l.info("initialized")
|
||||||
|
|
||||||
|
return {
|
||||||
root: input.root,
|
root: input.root,
|
||||||
get serverID() {
|
get serverID() {
|
||||||
return input.serverID
|
return input.serverID
|
||||||
@@ -145,106 +251,12 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
|
|||||||
return connection
|
return connection
|
||||||
},
|
},
|
||||||
notify: {
|
notify: {
|
||||||
async open(input: { path: string }) {
|
open,
|
||||||
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)
|
|
||||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
|
||||||
|
|
||||||
const version = files[input.path]
|
|
||||||
if (version !== undefined) {
|
|
||||||
log.info("workspace/didChangeWatchedFiles", input)
|
|
||||||
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
|
||||||
changes: [
|
|
||||||
{
|
|
||||||
uri: pathToFileURL(input.path).href,
|
|
||||||
type: 2, // Changed
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const next = version + 1
|
|
||||||
files[input.path] = next
|
|
||||||
log.info("textDocument/didChange", {
|
|
||||||
path: input.path,
|
|
||||||
version: next,
|
|
||||||
})
|
|
||||||
await connection.sendNotification("textDocument/didChange", {
|
|
||||||
textDocument: {
|
|
||||||
uri: pathToFileURL(input.path).href,
|
|
||||||
version: next,
|
|
||||||
},
|
|
||||||
contentChanges: [{ text }],
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("workspace/didChangeWatchedFiles", input)
|
|
||||||
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
|
||||||
changes: [
|
|
||||||
{
|
|
||||||
uri: pathToFileURL(input.path).href,
|
|
||||||
type: 1, // Created
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
log.info("textDocument/didOpen", input)
|
|
||||||
diagnostics.delete(input.path)
|
|
||||||
await connection.sendNotification("textDocument/didOpen", {
|
|
||||||
textDocument: {
|
|
||||||
uri: pathToFileURL(input.path).href,
|
|
||||||
languageId,
|
|
||||||
version: 0,
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
files[input.path] = 0
|
|
||||||
return
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
get diagnostics() {
|
get diagnostics() {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
},
|
},
|
||||||
async waitForDiagnostics(input: { path: string }) {
|
waitForDiagnostics,
|
||||||
const normalizedPath = Filesystem.normalizePath(
|
shutdown,
|
||||||
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
|
} satisfies Info
|
||||||
)
|
})
|
||||||
log.info("waiting for diagnostics", { path: normalizedPath })
|
|
||||||
let unsub: () => void
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
return await withTimeout(
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
|
||||||
if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
|
|
||||||
// Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
|
||||||
debounceTimer = setTimeout(() => {
|
|
||||||
log.info("got diagnostics", { path: normalizedPath })
|
|
||||||
unsub?.()
|
|
||||||
resolve()
|
|
||||||
}, DIAGNOSTICS_DEBOUNCE_MS)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
3000,
|
|
||||||
)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
|
||||||
unsub?.()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async shutdown() {
|
|
||||||
l.info("shutting down")
|
|
||||||
connection.end()
|
|
||||||
connection.dispose()
|
|
||||||
await Process.stop(input.server.process)
|
|
||||||
l.info("shutdown")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
l.info("initialized")
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
+153
-148
@@ -11,7 +11,7 @@ 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, Fiber, Layer, Context, Scope } from "effect"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
|
|
||||||
const log = Log.create({ service: "lsp" })
|
const log = Log.create({ service: "lsp" })
|
||||||
@@ -134,7 +134,7 @@ interface State {
|
|||||||
clients: LSPClient.Info[]
|
clients: LSPClient.Info[]
|
||||||
servers: Record<string, LSPServer.Info>
|
servers: Record<string, LSPServer.Info>
|
||||||
broken: Set<string>
|
broken: Set<string>
|
||||||
spawning: Map<string, Promise<LSPClient.Info | undefined>>
|
spawning: Map<string, Effect.Effect<LSPClient.Info | undefined>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -160,6 +160,7 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("LSP.state")(function* () {
|
Effect.fn("LSP.state")(function* () {
|
||||||
@@ -170,7 +171,7 @@ export const layer = Layer.effect(
|
|||||||
if (!cfg.lsp) {
|
if (!cfg.lsp) {
|
||||||
log.info("all LSPs are disabled")
|
log.info("all LSPs are disabled")
|
||||||
} else {
|
} else {
|
||||||
for (const server of Object.values(LSPServer)) {
|
for (const server of Object.values(LSPServer.Builtins)) {
|
||||||
servers[server.id] = server
|
servers[server.id] = server
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,15 +188,16 @@ export const layer = Layer.effect(
|
|||||||
servers[name] = {
|
servers[name] = {
|
||||||
...existing,
|
...existing,
|
||||||
id: name,
|
id: name,
|
||||||
root: existing?.root ?? (async () => Instance.directory),
|
root: existing?.root ?? (() => Effect.succeed(Instance.directory)),
|
||||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||||
spawn: async (root) => ({
|
spawn: (root) =>
|
||||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
Effect.sync(() => ({
|
||||||
cwd: root,
|
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||||
env: { ...process.env, ...item.env },
|
cwd: root,
|
||||||
}),
|
env: { ...process.env, ...item.env },
|
||||||
initialization: item.initialization,
|
}),
|
||||||
}),
|
initialization: item.initialization,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,110 +217,121 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
Effect.promise(async () => {
|
Effect.forEach(s.clients, (client) => client.shutdown(), { concurrency: "unbounded", discard: true }),
|
||||||
await Promise.all(s.clients.map((client) => client.shutdown()))
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const request = Effect.fnUntraced(function* <A>(
|
||||||
|
client: LSPClient.Info,
|
||||||
|
method: string,
|
||||||
|
params: unknown,
|
||||||
|
fallback: A,
|
||||||
|
) {
|
||||||
|
return yield* (Effect.tryPromise(() => client.connection.sendRequest<A>(method, params)).pipe(
|
||||||
|
Effect.catch(() => Effect.succeed(fallback)),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
const scheduleClient = Effect.fnUntraced(function* (s: State, server: LSPServer.Info, root: string, key: string) {
|
||||||
|
const handle = yield* (server.spawn(root).pipe(
|
||||||
|
Effect.catch((error: unknown) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
s.broken.add(key)
|
||||||
|
log.error(`Failed to spawn LSP server ${server.id}`, { error })
|
||||||
|
}).pipe(Effect.as(undefined)),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
if (!handle) {
|
||||||
|
s.broken.add(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("spawned lsp server", { serverID: server.id, root })
|
||||||
|
|
||||||
|
const client = yield* LSPClient.create({
|
||||||
|
serverID: server.id,
|
||||||
|
server: handle,
|
||||||
|
root,
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch((error: unknown) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
s.broken.add(key)
|
||||||
|
yield* (Effect.promise(() => Process.stop(handle.process)).pipe(Effect.catch(() => Effect.void)))
|
||||||
|
log.error(`Failed to initialize LSP client ${server.id}`, { error })
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!client) return undefined
|
||||||
|
|
||||||
|
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||||
|
if (existing) {
|
||||||
|
yield* (Effect.promise(() => Process.stop(handle.process)).pipe(Effect.catch(() => Effect.void)))
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
s.clients.push(client)
|
||||||
|
return client
|
||||||
|
})
|
||||||
|
|
||||||
|
const awaitSpawn = Effect.fnUntraced(function* (s: State, server: LSPServer.Info, root: string, key: string) {
|
||||||
|
const inflight = s.spawning.get(key)
|
||||||
|
if (inflight) return yield* inflight
|
||||||
|
|
||||||
|
const task = yield* Effect.cached(scheduleClient(s, server, root, key))
|
||||||
|
s.spawning.set(key, task)
|
||||||
|
return yield* task.pipe(
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (s.spawning.get(key) === task) s.spawning.delete(key)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||||
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
|
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
return yield* Effect.promise(async () => {
|
const extension = path.parse(file).ext || file
|
||||||
const extension = path.parse(file).ext || file
|
const result: LSPClient.Info[] = []
|
||||||
const result: LSPClient.Info[] = []
|
|
||||||
|
|
||||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
for (const server of Object.values(s.servers)) {
|
||||||
const handle = await server
|
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||||
.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
|
const root = yield* server.root(file)
|
||||||
log.info("spawned lsp server", { serverID: server.id, root })
|
if (!root) continue
|
||||||
|
|
||||||
const client = await LSPClient.create({
|
const key = root + server.id
|
||||||
serverID: server.id,
|
if (s.broken.has(key)) continue
|
||||||
server: handle,
|
|
||||||
root,
|
|
||||||
}).catch(async (err) => {
|
|
||||||
s.broken.add(key)
|
|
||||||
await Process.stop(handle.process)
|
|
||||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!client) return undefined
|
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||||
|
if (match) {
|
||||||
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
result.push(match)
|
||||||
if (existing) {
|
continue
|
||||||
await Process.stop(handle.process)
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
s.clients.push(client)
|
|
||||||
return client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const server of Object.values(s.servers)) {
|
const hadInflight = s.spawning.has(key)
|
||||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
const client = yield* awaitSpawn(s, server, root, key)
|
||||||
|
if (!client) continue
|
||||||
|
|
||||||
const root = await server.root(file)
|
result.push(client)
|
||||||
if (!root) continue
|
if (!hadInflight) Bus.publish(Event.Updated, {})
|
||||||
if (s.broken.has(root + server.id)) continue
|
}
|
||||||
|
|
||||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
return result
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
|
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Effect.Effect<T>) {
|
||||||
const clients = yield* getClients(file)
|
const clients = yield* getClients(file)
|
||||||
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
|
return yield* Effect.forEach(clients, fn, { concurrency: "unbounded" })
|
||||||
})
|
})
|
||||||
|
|
||||||
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Effect.Effect<T>) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
|
return yield* Effect.forEach(s.clients, fn, { concurrency: "unbounded" })
|
||||||
})
|
})
|
||||||
|
|
||||||
const init = Effect.fn("LSP.init")(function* () {
|
const init = Effect.fn("LSP.init")(function* () {
|
||||||
@@ -341,38 +354,43 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
|
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
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 = yield* server.root(file)
|
||||||
const root = await server.root(file)
|
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
|
}
|
||||||
}
|
return false
|
||||||
return false
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
|
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
|
||||||
log.info("touching file", { file: input })
|
log.info("touching file", { file: input })
|
||||||
const clients = yield* getClients(input)
|
const clients = yield* getClients(input)
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.forEach(
|
||||||
Promise.all(
|
clients,
|
||||||
clients.map(async (client) => {
|
(client) =>
|
||||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
Effect.gen(function* () {
|
||||||
await client.notify.open({ path: input })
|
const waiting = waitForDiagnostics
|
||||||
return wait
|
? yield* client.waitForDiagnostics({ path: input }).pipe(Effect.forkIn(scope))
|
||||||
|
: undefined
|
||||||
|
yield* client.notify.open({ path: input })
|
||||||
|
if (waiting) yield* Fiber.join(waiting)
|
||||||
}),
|
}),
|
||||||
).catch((err) => {
|
{ concurrency: "unbounded", discard: true },
|
||||||
log.error("failed to touch file", { err, file: input })
|
).pipe(
|
||||||
}),
|
Effect.catch((err: unknown) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.error("failed to touch file", { err, file: input })
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
|
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
|
||||||
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
||||||
const all = yield* runAll(async (client) => client.diagnostics)
|
const all = yield* runAll((client) => Effect.succeed(client.diagnostics))
|
||||||
for (const result of all) {
|
for (const result of all) {
|
||||||
for (const [p, diags] of result.entries()) {
|
for (const [p, diags] of result.entries()) {
|
||||||
const arr = results[p] || []
|
const arr = results[p] || []
|
||||||
@@ -385,78 +403,65 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
|
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
|
||||||
return yield* run(input.file, (client) =>
|
return yield* run(input.file, (client) =>
|
||||||
client.connection
|
request(client, "textDocument/hover", {
|
||||||
.sendRequest("textDocument/hover", {
|
|
||||||
textDocument: { uri: pathToFileURL(input.file).href },
|
textDocument: { uri: pathToFileURL(input.file).href },
|
||||||
position: { line: input.line, character: input.character },
|
position: { line: input.line, character: input.character },
|
||||||
})
|
}, null),
|
||||||
.catch(() => null),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
|
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
|
||||||
const results = yield* run(input.file, (client) =>
|
const results = yield* run(input.file, (client) =>
|
||||||
client.connection
|
request(client, "textDocument/definition", {
|
||||||
.sendRequest("textDocument/definition", {
|
|
||||||
textDocument: { uri: pathToFileURL(input.file).href },
|
textDocument: { uri: pathToFileURL(input.file).href },
|
||||||
position: { line: input.line, character: input.character },
|
position: { line: input.line, character: input.character },
|
||||||
})
|
}, null),
|
||||||
.catch(() => null),
|
|
||||||
)
|
)
|
||||||
return results.flat().filter(Boolean)
|
return results.flat().filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
|
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
|
||||||
const results = yield* run(input.file, (client) =>
|
const results = yield* run(input.file, (client) =>
|
||||||
client.connection
|
request(client, "textDocument/references", {
|
||||||
.sendRequest("textDocument/references", {
|
|
||||||
textDocument: { uri: pathToFileURL(input.file).href },
|
textDocument: { uri: pathToFileURL(input.file).href },
|
||||||
position: { line: input.line, character: input.character },
|
position: { line: input.line, character: input.character },
|
||||||
context: { includeDeclaration: true },
|
context: { includeDeclaration: true },
|
||||||
})
|
}, [] as any[]),
|
||||||
.catch(() => []),
|
|
||||||
)
|
)
|
||||||
return results.flat().filter(Boolean)
|
return results.flat().filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
|
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
|
||||||
const results = yield* run(input.file, (client) =>
|
const results = yield* run(input.file, (client) =>
|
||||||
client.connection
|
request(client, "textDocument/implementation", {
|
||||||
.sendRequest("textDocument/implementation", {
|
|
||||||
textDocument: { uri: pathToFileURL(input.file).href },
|
textDocument: { uri: pathToFileURL(input.file).href },
|
||||||
position: { line: input.line, character: input.character },
|
position: { line: input.line, character: input.character },
|
||||||
})
|
}, null),
|
||||||
.catch(() => null),
|
|
||||||
)
|
)
|
||||||
return results.flat().filter(Boolean)
|
return results.flat().filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
|
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
|
||||||
const file = fileURLToPath(uri)
|
const file = fileURLToPath(uri)
|
||||||
const results = yield* run(file, (client) =>
|
const results = yield* run(file, (client) => request(client, "textDocument/documentSymbol", { textDocument: { uri } }, [] as any[]))
|
||||||
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
|
|
||||||
)
|
|
||||||
return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)
|
return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
||||||
const results = yield* runAll((client) =>
|
const results = yield* runAll((client) =>
|
||||||
client.connection
|
request(client, "workspace/symbol", { query }, [] as Symbol[]).pipe(
|
||||||
.sendRequest<Symbol[]>("workspace/symbol", { query })
|
Effect.map((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)),
|
||||||
.then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
|
),
|
||||||
.catch(() => [] as Symbol[]),
|
|
||||||
)
|
)
|
||||||
return results.flat()
|
return results.flat()
|
||||||
})
|
})
|
||||||
|
|
||||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||||
const results = yield* run(input.file, (client) =>
|
const results = yield* run(input.file, (client) =>
|
||||||
client.connection
|
request(client, "textDocument/prepareCallHierarchy", {
|
||||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
|
||||||
textDocument: { uri: pathToFileURL(input.file).href },
|
textDocument: { uri: pathToFileURL(input.file).href },
|
||||||
position: { line: input.line, character: input.character },
|
position: { line: input.line, character: input.character },
|
||||||
})
|
}, [] as any[]),
|
||||||
.catch(() => []),
|
|
||||||
)
|
)
|
||||||
return results.flat().filter(Boolean)
|
return results.flat().filter(Boolean)
|
||||||
})
|
})
|
||||||
@@ -465,16 +470,16 @@ export const layer = Layer.effect(
|
|||||||
input: LocInput,
|
input: LocInput,
|
||||||
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
|
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
|
||||||
) {
|
) {
|
||||||
const results = yield* run(input.file, async (client) => {
|
const results = yield* run(input.file, (client) =>
|
||||||
const items = await client.connection
|
Effect.gen(function* () {
|
||||||
.sendRequest<unknown[] | null>("textDocument/prepareCallHierarchy", {
|
const items = yield* request(client, "textDocument/prepareCallHierarchy", {
|
||||||
textDocument: { uri: pathToFileURL(input.file).href },
|
textDocument: { uri: pathToFileURL(input.file).href },
|
||||||
position: { line: input.line, character: input.character },
|
position: { line: input.line, character: input.character },
|
||||||
})
|
}, [] as unknown[])
|
||||||
.catch(() => [] as unknown[])
|
if (!items.length) return []
|
||||||
if (!items?.length) return []
|
return yield* request(client, direction, { item: items[0] }, [] as unknown[])
|
||||||
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
|
}),
|
||||||
})
|
)
|
||||||
return results.flat().filter(Boolean)
|
return results.flat().filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,9 @@ import { which } from "../util/which"
|
|||||||
import { Module } from "@opencode-ai/shared/util/module"
|
import { Module } from "@opencode-ai/shared/util/module"
|
||||||
import { spawn } from "./launch"
|
import { spawn } from "./launch"
|
||||||
import { Npm } from "../npm"
|
import { Npm } from "../npm"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
const log = Log.create({ service: "lsp.server" })
|
const log = Log.create({ service: "lsp.server" })
|
||||||
const pathExists = async (p: string) =>
|
|
||||||
fs
|
|
||||||
.stat(p)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
|
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
|
||||||
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
|
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
|
||||||
|
|
||||||
@@ -29,9 +25,10 @@ export interface Handle {
|
|||||||
initialization?: Record<string, any>
|
initialization?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
type RootFunction = (file: string) => Promise<string | undefined>
|
type RawRootFunction = (file: string) => Promise<string | undefined>
|
||||||
|
type RootFunction = (file: string) => Effect.Effect<string | undefined>
|
||||||
|
|
||||||
const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => {
|
const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RawRootFunction => {
|
||||||
return async (file) => {
|
return async (file) => {
|
||||||
if (excludePatterns) {
|
if (excludePatterns) {
|
||||||
const excludedFiles = Filesystem.up({
|
const excludedFiles = Filesystem.up({
|
||||||
@@ -55,15 +52,36 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawInfo {
|
||||||
|
id: string
|
||||||
|
extensions: string[]
|
||||||
|
global?: boolean
|
||||||
|
root: RawRootFunction
|
||||||
|
spawn(root: string): Promise<Handle | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
export interface Info {
|
export interface Info {
|
||||||
id: string
|
id: string
|
||||||
extensions: string[]
|
extensions: string[]
|
||||||
global?: boolean
|
global?: boolean
|
||||||
root: RootFunction
|
root: RootFunction
|
||||||
spawn(root: string): Promise<Handle | undefined>
|
spawn(root: string): Effect.Effect<Handle | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Deno: Info = {
|
const effectify = (info: RawInfo): Info => ({
|
||||||
|
...info,
|
||||||
|
root: (file) => Effect.promise(() => info.root(file)),
|
||||||
|
spawn: (root) => Effect.promise(() => info.spawn(root)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const effectifyAll = <T extends Record<string, RawInfo>>(infos: T): { [K in keyof T]: Info } =>
|
||||||
|
Object.fromEntries(Object.entries(infos).map(([key, value]) => [key, effectify(value)])) as { [K in keyof T]: Info }
|
||||||
|
|
||||||
|
// Temporary migration bridge: `Builtins` exposes Effect-shaped `root` / `spawn`
|
||||||
|
// while the per-server definitions still use their older Promise bodies.
|
||||||
|
// Follow-up: convert the individual server definitions in place and delete this wrapper.
|
||||||
|
|
||||||
|
export const Deno: RawInfo = {
|
||||||
id: "deno",
|
id: "deno",
|
||||||
root: async (file) => {
|
root: async (file) => {
|
||||||
const files = Filesystem.up({
|
const files = Filesystem.up({
|
||||||
@@ -91,7 +109,7 @@ export const Deno: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Typescript: Info = {
|
export const Typescript: RawInfo = {
|
||||||
id: "typescript",
|
id: "typescript",
|
||||||
root: NearestRoot(
|
root: NearestRoot(
|
||||||
["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"],
|
["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"],
|
||||||
@@ -121,7 +139,7 @@ export const Typescript: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Vue: Info = {
|
export const Vue: RawInfo = {
|
||||||
id: "vue",
|
id: "vue",
|
||||||
extensions: [".vue"],
|
extensions: [".vue"],
|
||||||
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"]),
|
||||||
@@ -150,7 +168,7 @@ export const Vue: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ESLint: Info = {
|
export const ESLint: RawInfo = {
|
||||||
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"],
|
||||||
@@ -207,7 +225,7 @@ export const ESLint: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Oxlint: Info = {
|
export const Oxlint: RawInfo = {
|
||||||
id: "oxlint",
|
id: "oxlint",
|
||||||
root: NearestRoot([
|
root: NearestRoot([
|
||||||
".oxlintrc.json",
|
".oxlintrc.json",
|
||||||
@@ -280,7 +298,7 @@ export const Oxlint: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Biome: Info = {
|
export const Biome: RawInfo = {
|
||||||
id: "biome",
|
id: "biome",
|
||||||
root: NearestRoot([
|
root: NearestRoot([
|
||||||
"biome.json",
|
"biome.json",
|
||||||
@@ -342,7 +360,7 @@ export const Biome: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Gopls: Info = {
|
export const Gopls: RawInfo = {
|
||||||
id: "gopls",
|
id: "gopls",
|
||||||
root: async (file) => {
|
root: async (file) => {
|
||||||
const work = await NearestRoot(["go.work"])(file)
|
const work = await NearestRoot(["go.work"])(file)
|
||||||
@@ -381,7 +399,7 @@ export const Gopls: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Rubocop: Info = {
|
export const Rubocop: RawInfo = {
|
||||||
id: "ruby-lsp",
|
id: "ruby-lsp",
|
||||||
root: NearestRoot(["Gemfile"]),
|
root: NearestRoot(["Gemfile"]),
|
||||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||||
@@ -419,7 +437,7 @@ export const Rubocop: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Ty: Info = {
|
export const Ty: RawInfo = {
|
||||||
id: "ty",
|
id: "ty",
|
||||||
extensions: [".py", ".pyi"],
|
extensions: [".py", ".pyi"],
|
||||||
root: NearestRoot([
|
root: NearestRoot([
|
||||||
@@ -481,7 +499,7 @@ export const Ty: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Pyright: Info = {
|
export const Pyright: RawInfo = {
|
||||||
id: "pyright",
|
id: "pyright",
|
||||||
extensions: [".py", ".pyi"],
|
extensions: [".py", ".pyi"],
|
||||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||||
@@ -525,7 +543,7 @@ export const Pyright: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ElixirLS: Info = {
|
export const ElixirLS: RawInfo = {
|
||||||
id: "elixir-ls",
|
id: "elixir-ls",
|
||||||
extensions: [".ex", ".exs"],
|
extensions: [".ex", ".exs"],
|
||||||
root: NearestRoot(["mix.exs", "mix.lock"]),
|
root: NearestRoot(["mix.exs", "mix.lock"]),
|
||||||
@@ -588,7 +606,7 @@ export const ElixirLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Zls: Info = {
|
export const Zls: RawInfo = {
|
||||||
id: "zls",
|
id: "zls",
|
||||||
extensions: [".zig", ".zon"],
|
extensions: [".zig", ".zon"],
|
||||||
root: NearestRoot(["build.zig"]),
|
root: NearestRoot(["build.zig"]),
|
||||||
@@ -700,7 +718,7 @@ export const Zls: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CSharp: Info = {
|
export const CSharp: RawInfo = {
|
||||||
id: "csharp",
|
id: "csharp",
|
||||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||||
extensions: [".cs"],
|
extensions: [".cs"],
|
||||||
@@ -737,7 +755,7 @@ export const CSharp: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FSharp: Info = {
|
export const FSharp: RawInfo = {
|
||||||
id: "fsharp",
|
id: "fsharp",
|
||||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||||
@@ -774,7 +792,7 @@ export const FSharp: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SourceKit: Info = {
|
export const SourceKit: RawInfo = {
|
||||||
id: "sourcekit-lsp",
|
id: "sourcekit-lsp",
|
||||||
extensions: [".swift", ".objc", "objcpp"],
|
extensions: [".swift", ".objc", "objcpp"],
|
||||||
root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]),
|
root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]),
|
||||||
@@ -808,7 +826,7 @@ export const SourceKit: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RustAnalyzer: Info = {
|
export const RustAnalyzer: RawInfo = {
|
||||||
id: "rust",
|
id: "rust",
|
||||||
root: async (root) => {
|
root: async (root) => {
|
||||||
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
|
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
|
||||||
@@ -854,7 +872,7 @@ export const RustAnalyzer: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Clangd: Info = {
|
export const Clangd: RawInfo = {
|
||||||
id: "clangd",
|
id: "clangd",
|
||||||
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
|
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
|
||||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||||
@@ -1000,7 +1018,7 @@ export const Clangd: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Svelte: Info = {
|
export const Svelte: RawInfo = {
|
||||||
id: "svelte",
|
id: "svelte",
|
||||||
extensions: [".svelte"],
|
extensions: [".svelte"],
|
||||||
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"]),
|
||||||
@@ -1027,7 +1045,7 @@ export const Svelte: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Astro: Info = {
|
export const Astro: RawInfo = {
|
||||||
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"]),
|
||||||
@@ -1065,7 +1083,7 @@ export const Astro: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JDTLS: Info = {
|
export const JDTLS: RawInfo = {
|
||||||
id: "jdtls",
|
id: "jdtls",
|
||||||
root: async (file) => {
|
root: async (file) => {
|
||||||
// Without exclusions, NearestRoot defaults to instance directory so we can't
|
// Without exclusions, NearestRoot defaults to instance directory so we can't
|
||||||
@@ -1108,7 +1126,7 @@ export const JDTLS: Info = {
|
|||||||
}
|
}
|
||||||
const distPath = path.join(Global.Path.bin, "jdtls")
|
const distPath = path.join(Global.Path.bin, "jdtls")
|
||||||
const launcherDir = path.join(distPath, "plugins")
|
const launcherDir = path.join(distPath, "plugins")
|
||||||
const installed = await pathExists(launcherDir)
|
const installed = await Filesystem.exists(launcherDir)
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
log.info("Downloading JDTLS LSP server.")
|
log.info("Downloading JDTLS LSP server.")
|
||||||
@@ -1140,7 +1158,7 @@ export const JDTLS: Info = {
|
|||||||
.find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
|
.find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
|
||||||
?.trim() ?? ""
|
?.trim() ?? ""
|
||||||
const launcherJar = path.join(launcherDir, jarFileName)
|
const launcherJar = path.join(launcherDir, jarFileName)
|
||||||
if (!(await pathExists(launcherJar))) {
|
if (!(await Filesystem.exists(launcherJar))) {
|
||||||
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
|
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1186,7 +1204,7 @@ export const JDTLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KotlinLS: Info = {
|
export const KotlinLS: RawInfo = {
|
||||||
id: "kotlin-ls",
|
id: "kotlin-ls",
|
||||||
extensions: [".kt", ".kts"],
|
extensions: [".kt", ".kts"],
|
||||||
root: async (file) => {
|
root: async (file) => {
|
||||||
@@ -1285,7 +1303,7 @@ export const KotlinLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const YamlLS: Info = {
|
export const YamlLS: RawInfo = {
|
||||||
id: "yaml-ls",
|
id: "yaml-ls",
|
||||||
extensions: [".yaml", ".yml"],
|
extensions: [".yaml", ".yml"],
|
||||||
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"]),
|
||||||
@@ -1311,7 +1329,7 @@ export const YamlLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LuaLS: Info = {
|
export const LuaLS: RawInfo = {
|
||||||
id: "lua-ls",
|
id: "lua-ls",
|
||||||
root: NearestRoot([
|
root: NearestRoot([
|
||||||
".luarc.json",
|
".luarc.json",
|
||||||
@@ -1452,7 +1470,7 @@ export const LuaLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PHPIntelephense: Info = {
|
export const PHPIntelephense: RawInfo = {
|
||||||
id: "php intelephense",
|
id: "php intelephense",
|
||||||
extensions: [".php"],
|
extensions: [".php"],
|
||||||
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
|
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
|
||||||
@@ -1483,7 +1501,7 @@ export const PHPIntelephense: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Prisma: Info = {
|
export const Prisma: RawInfo = {
|
||||||
id: "prisma",
|
id: "prisma",
|
||||||
extensions: [".prisma"],
|
extensions: [".prisma"],
|
||||||
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
|
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
|
||||||
@@ -1501,7 +1519,7 @@ export const Prisma: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dart: Info = {
|
export const Dart: RawInfo = {
|
||||||
id: "dart",
|
id: "dart",
|
||||||
extensions: [".dart"],
|
extensions: [".dart"],
|
||||||
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
|
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
|
||||||
@@ -1519,7 +1537,7 @@ export const Dart: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Ocaml: Info = {
|
export const Ocaml: RawInfo = {
|
||||||
id: "ocaml-lsp",
|
id: "ocaml-lsp",
|
||||||
extensions: [".ml", ".mli"],
|
extensions: [".ml", ".mli"],
|
||||||
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
|
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
|
||||||
@@ -1536,7 +1554,7 @@ export const Ocaml: Info = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
export const BashLS: Info = {
|
export const BashLS: RawInfo = {
|
||||||
id: "bash",
|
id: "bash",
|
||||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||||
root: async () => Instance.directory,
|
root: async () => Instance.directory,
|
||||||
@@ -1562,7 +1580,7 @@ export const BashLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerraformLS: Info = {
|
export const TerraformLS: RawInfo = {
|
||||||
id: "terraform",
|
id: "terraform",
|
||||||
extensions: [".tf", ".tfvars"],
|
extensions: [".tf", ".tfvars"],
|
||||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||||
@@ -1643,7 +1661,7 @@ export const TerraformLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TexLab: Info = {
|
export const TexLab: RawInfo = {
|
||||||
id: "texlab",
|
id: "texlab",
|
||||||
extensions: [".tex", ".bib"],
|
extensions: [".tex", ".bib"],
|
||||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||||
@@ -1731,7 +1749,7 @@ export const TexLab: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DockerfileLS: Info = {
|
export const DockerfileLS: RawInfo = {
|
||||||
id: "dockerfile",
|
id: "dockerfile",
|
||||||
extensions: [".dockerfile", "Dockerfile"],
|
extensions: [".dockerfile", "Dockerfile"],
|
||||||
root: async () => Instance.directory,
|
root: async () => Instance.directory,
|
||||||
@@ -1757,7 +1775,7 @@ export const DockerfileLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Gleam: Info = {
|
export const Gleam: RawInfo = {
|
||||||
id: "gleam",
|
id: "gleam",
|
||||||
extensions: [".gleam"],
|
extensions: [".gleam"],
|
||||||
root: NearestRoot(["gleam.toml"]),
|
root: NearestRoot(["gleam.toml"]),
|
||||||
@@ -1775,7 +1793,7 @@ export const Gleam: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Clojure: Info = {
|
export const Clojure: RawInfo = {
|
||||||
id: "clojure-lsp",
|
id: "clojure-lsp",
|
||||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||||
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
|
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
|
||||||
@@ -1796,7 +1814,7 @@ export const Clojure: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Nixd: Info = {
|
export const Nixd: RawInfo = {
|
||||||
id: "nixd",
|
id: "nixd",
|
||||||
extensions: [".nix"],
|
extensions: [".nix"],
|
||||||
root: async (file) => {
|
root: async (file) => {
|
||||||
@@ -1827,7 +1845,7 @@ export const Nixd: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tinymist: Info = {
|
export const Tinymist: RawInfo = {
|
||||||
id: "tinymist",
|
id: "tinymist",
|
||||||
extensions: [".typ", ".typc"],
|
extensions: [".typ", ".typc"],
|
||||||
root: NearestRoot(["typst.toml"]),
|
root: NearestRoot(["typst.toml"]),
|
||||||
@@ -1919,7 +1937,7 @@ export const Tinymist: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HLS: Info = {
|
export const HLS: RawInfo = {
|
||||||
id: "haskell-language-server",
|
id: "haskell-language-server",
|
||||||
extensions: [".hs", ".lhs"],
|
extensions: [".hs", ".lhs"],
|
||||||
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
|
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
|
||||||
@@ -1937,7 +1955,7 @@ export const HLS: Info = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JuliaLS: Info = {
|
export const JuliaLS: RawInfo = {
|
||||||
id: "julials",
|
id: "julials",
|
||||||
extensions: [".jl"],
|
extensions: [".jl"],
|
||||||
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
|
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
|
||||||
@@ -1954,3 +1972,43 @@ export const JuliaLS: Info = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Builtins = effectifyAll({
|
||||||
|
Deno,
|
||||||
|
Typescript,
|
||||||
|
Vue,
|
||||||
|
ESLint,
|
||||||
|
Oxlint,
|
||||||
|
Biome,
|
||||||
|
Gopls,
|
||||||
|
Rubocop,
|
||||||
|
Ty,
|
||||||
|
Pyright,
|
||||||
|
ElixirLS,
|
||||||
|
Zls,
|
||||||
|
CSharp,
|
||||||
|
FSharp,
|
||||||
|
SourceKit,
|
||||||
|
RustAnalyzer,
|
||||||
|
Clangd,
|
||||||
|
Svelte,
|
||||||
|
Astro,
|
||||||
|
JDTLS,
|
||||||
|
KotlinLS,
|
||||||
|
YamlLS,
|
||||||
|
LuaLS,
|
||||||
|
PHPIntelephense,
|
||||||
|
Prisma,
|
||||||
|
Dart,
|
||||||
|
Ocaml,
|
||||||
|
BashLS,
|
||||||
|
TerraformLS,
|
||||||
|
TexLab,
|
||||||
|
DockerfileLS,
|
||||||
|
Gleam,
|
||||||
|
Clojure,
|
||||||
|
Nixd,
|
||||||
|
Tinymist,
|
||||||
|
HLS,
|
||||||
|
JuliaLS,
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, test, beforeEach } from "bun:test"
|
import { describe, expect, test, beforeEach } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
import { LSPClient } from "../../src/lsp"
|
import { LSPClient } from "../../src/lsp"
|
||||||
import { LSPServer } from "../../src/lsp"
|
import { LSPServer } from "../../src/lsp"
|
||||||
import { Instance } from "../../src/project/instance"
|
|
||||||
import { Log } from "../../src/util"
|
import { Log } from "../../src/util"
|
||||||
|
import { provideInstance } from "../fixture/fixture"
|
||||||
|
|
||||||
// Minimal fake LSP server that speaks JSON-RPC over stdio
|
// Minimal fake LSP server that speaks JSON-RPC over stdio
|
||||||
function spawnFakeServer() {
|
function spawnFakeServer() {
|
||||||
@@ -16,23 +18,27 @@ function spawnFakeServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createClient() {
|
||||||
|
const handle = spawnFakeServer() as any
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const client = await Effect.runPromise(
|
||||||
|
LSPClient.create({
|
||||||
|
serverID: "fake",
|
||||||
|
server: handle as unknown as LSPServer.Handle,
|
||||||
|
root: cwd,
|
||||||
|
}).pipe(provideInstance(cwd)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { client, cwd }
|
||||||
|
}
|
||||||
|
|
||||||
describe("LSPClient interop", () => {
|
describe("LSPClient interop", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await Log.init({ print: true })
|
await Log.init({ print: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles workspace/workspaceFolders request", async () => {
|
test("handles workspace/workspaceFolders request", async () => {
|
||||||
const handle = spawnFakeServer() as any
|
const { client } = await createClient()
|
||||||
|
|
||||||
const client = await Instance.provide({
|
|
||||||
directory: process.cwd(),
|
|
||||||
fn: () =>
|
|
||||||
LSPClient.create({
|
|
||||||
serverID: "fake",
|
|
||||||
server: handle as unknown as LSPServer.Handle,
|
|
||||||
root: process.cwd(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
await client.connection.sendNotification("test/trigger", {
|
await client.connection.sendNotification("test/trigger", {
|
||||||
method: "workspace/workspaceFolders",
|
method: "workspace/workspaceFolders",
|
||||||
@@ -42,21 +48,11 @@ describe("LSPClient interop", () => {
|
|||||||
|
|
||||||
expect(client.connection).toBeDefined()
|
expect(client.connection).toBeDefined()
|
||||||
|
|
||||||
await client.shutdown()
|
await Effect.runPromise(client.shutdown())
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles client/registerCapability request", async () => {
|
test("handles client/registerCapability request", async () => {
|
||||||
const handle = spawnFakeServer() as any
|
const { client } = await createClient()
|
||||||
|
|
||||||
const client = await Instance.provide({
|
|
||||||
directory: process.cwd(),
|
|
||||||
fn: () =>
|
|
||||||
LSPClient.create({
|
|
||||||
serverID: "fake",
|
|
||||||
server: handle as unknown as LSPServer.Handle,
|
|
||||||
root: process.cwd(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
await client.connection.sendNotification("test/trigger", {
|
await client.connection.sendNotification("test/trigger", {
|
||||||
method: "client/registerCapability",
|
method: "client/registerCapability",
|
||||||
@@ -66,21 +62,11 @@ describe("LSPClient interop", () => {
|
|||||||
|
|
||||||
expect(client.connection).toBeDefined()
|
expect(client.connection).toBeDefined()
|
||||||
|
|
||||||
await client.shutdown()
|
await Effect.runPromise(client.shutdown())
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles client/unregisterCapability request", async () => {
|
test("handles client/unregisterCapability request", async () => {
|
||||||
const handle = spawnFakeServer() as any
|
const { client } = await createClient()
|
||||||
|
|
||||||
const client = await Instance.provide({
|
|
||||||
directory: process.cwd(),
|
|
||||||
fn: () =>
|
|
||||||
LSPClient.create({
|
|
||||||
serverID: "fake",
|
|
||||||
server: handle as unknown as LSPServer.Handle,
|
|
||||||
root: process.cwd(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
await client.connection.sendNotification("test/trigger", {
|
await client.connection.sendNotification("test/trigger", {
|
||||||
method: "client/unregisterCapability",
|
method: "client/unregisterCapability",
|
||||||
@@ -90,6 +76,32 @@ describe("LSPClient interop", () => {
|
|||||||
|
|
||||||
expect(client.connection).toBeDefined()
|
expect(client.connection).toBeDefined()
|
||||||
|
|
||||||
await client.shutdown()
|
await Effect.runPromise(client.shutdown())
|
||||||
|
})
|
||||||
|
|
||||||
|
test("waitForDiagnostics() resolves when a matching diagnostic event is published", async () => {
|
||||||
|
const { client, cwd } = await createClient()
|
||||||
|
const file = path.join(cwd, "fixture.ts")
|
||||||
|
|
||||||
|
const waiting = Effect.runPromise(client.waitForDiagnostics({ path: file }).pipe(provideInstance(cwd)))
|
||||||
|
|
||||||
|
await Effect.runPromise(Effect.sleep(20))
|
||||||
|
await Effect.runPromise(Effect.promise(() => Bus.publish(LSPClient.Event.Diagnostics, { path: file, serverID: "fake" })).pipe(provideInstance(cwd)))
|
||||||
|
await waiting
|
||||||
|
|
||||||
|
await Effect.runPromise(client.shutdown())
|
||||||
|
})
|
||||||
|
|
||||||
|
test("waitForDiagnostics() times out without throwing when no event arrives", async () => {
|
||||||
|
const { client, cwd } = await createClient()
|
||||||
|
const started = Date.now()
|
||||||
|
|
||||||
|
await Effect.runPromise(client.waitForDiagnostics({ path: path.join(cwd, "never.ts") }).pipe(provideInstance(cwd)))
|
||||||
|
|
||||||
|
const elapsed = Date.now() - started
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(2900)
|
||||||
|
expect(elapsed).toBeLessThan(5000)
|
||||||
|
|
||||||
|
await Effect.runPromise(client.shutdown())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Fiber, Layer, Scope } from "effect"
|
||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { LSPServer } from "../../src/lsp"
|
import { LSPServer } from "../../src/lsp"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
@@ -153,6 +153,35 @@ describe("LSP service lifecycle", () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("touchFile() dedupes concurrent spawn attempts for the same file", () =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
LSP.Service.use((lsp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const gate = Promise.withResolvers<void>()
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
const file = path.join(dir, "src", "inside.ts")
|
||||||
|
|
||||||
|
spawnSpy.mockImplementation(async () => {
|
||||||
|
await gate.promise
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const fiber = yield* Effect.all([lsp.touchFile(file, false), lsp.touchFile(file, false)], {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
}).pipe(Effect.forkIn(scope))
|
||||||
|
|
||||||
|
yield* Effect.sleep(20)
|
||||||
|
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
gate.resolve()
|
||||||
|
yield* Fiber.join(fiber)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ config: { lsp: true } },
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("LSP.Diagnostic", () => {
|
describe("LSP.Diagnostic", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user