refactor(lsp): effectify client and server boundaries

This commit is contained in:
Kit Langton
2026-04-17 20:40:26 -04:00
parent 6b7f34df20
commit 00120c32a8
5 changed files with 460 additions and 356 deletions

View File

@@ -29,7 +29,7 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter<
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
>((data) => {
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]) => {
if ("disabled" in config && config.disabled) return true
if (serverIds.has(id)) return true

View File

@@ -4,6 +4,7 @@ import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { Effect } from "effect"
import { Log } from "../util"
import { Process } from "../util"
import { LANGUAGE_EXTENSIONS } from "./language"
@@ -18,7 +19,19 @@ const DIAGNOSTICS_DEBOUNCE_MS = 150
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
@@ -39,7 +52,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)
l.info("starting client")
@@ -64,10 +81,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
l.info("window/workDoneProgress/create", params)
return null
})
connection.onRequest("workspace/configuration", async () => {
// Return server initialization options
return [input.server.initialization ?? {}]
})
connection.onRequest("workspace/configuration", async () => [input.server.initialization ?? {}])
connection.onRequest("client/registerCapability", async () => {})
connection.onRequest("client/unregisterCapability", async () => {})
connection.onRequest("workspace/workspaceFolders", async () => [
@@ -79,145 +93,144 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
connection.listen()
l.info("sending initialize")
await withTimeout(
connection.sendRequest("initialize", {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: pathToFileURL(input.root).href,
},
],
initializationOptions: {
...input.server.initialization,
},
capabilities: {
window: {
workDoneProgress: true,
},
workspace: {
configuration: true,
didChangeWatchedFiles: {
dynamicRegistration: true,
},
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
publishDiagnostics: {
versionSupport: true,
},
},
},
}),
45_000,
).catch((err) => {
l.error("initialize error", { error: err })
throw new InitializeError(
{ serverID: input.serverID },
{
cause: err,
},
)
})
await connection.sendNotification("initialized", {})
if (input.server.initialization) {
await connection.sendNotification("workspace/didChangeConfiguration", {
settings: input.server.initialization,
})
}
const files: {
[path: string]: number
} = {}
const result = {
root: input.root,
get serverID() {
return input.serverID
},
get connection() {
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)
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: [
yield* Effect.tryPromise({
try: () =>
withTimeout(
connection.sendRequest("initialize", {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
workspaceFolders: [
{
uri: pathToFileURL(input.path).href,
type: 1, // Created
name: "workspace",
uri: pathToFileURL(input.root).href,
},
],
})
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(input.path).href,
languageId,
version: 0,
text,
initializationOptions: {
...input.server.initialization,
},
})
files[input.path] = 0
return
},
},
get diagnostics() {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
capabilities: {
window: {
workDoneProgress: true,
},
workspace: {
configuration: true,
didChangeWatchedFiles: {
dynamicRegistration: true,
},
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
publishDiagnostics: {
versionSupport: true,
},
},
},
}),
45_000,
),
catch: (error) => {
l.error("initialize error", { error })
return new InitializeError(
{ serverID: input.serverID },
{
cause: error,
},
)
log.info("waiting for diagnostics", { path: normalizedPath })
let unsub: () => void
let debounceTimer: ReturnType<typeof setTimeout> | undefined
return await withTimeout(
},
})
yield* Effect.tryPromise(() => connection.sendNotification("initialized", {}))
if (input.server.initialization) {
yield* Effect.tryPromise(() =>
connection.sendNotification("workspace/didChangeConfiguration", {
settings: input.server.initialization,
}),
)
}
const files: Record<string, number> = {}
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 })
let unsub: (() => void) | undefined
let debounceTimer: ReturnType<typeof setTimeout> | undefined
yield* Effect.promise(() =>
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 (event.properties.path === normalizedPath && event.properties.serverID === input.serverID) {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
log.info("got diagnostics", { path: normalizedPath })
@@ -228,23 +241,43 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
})
}),
3000,
)
.catch(() => {})
.finally(() => {
),
).pipe(
Effect.catch(() => Effect.void),
Effect.ensuring(
Effect.sync(() => {
if (debounceTimer) clearTimeout(debounceTimer)
unsub?.()
})
},
async shutdown() {
l.info("shutting down")
connection.end()
connection.dispose()
await Process.stop(input.server.process)
l.info("shutdown")
},
}
}),
),
)
})
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 result
}
return {
root: input.root,
get serverID() {
return input.serverID
},
get connection() {
return connection
},
notify: {
open,
},
get diagnostics() {
return diagnostics
},
waitForDiagnostics,
shutdown,
} satisfies Info
})

View File

@@ -134,7 +134,7 @@ interface State {
clients: LSPClient.Info[]
servers: Record<string, LSPServer.Info>
broken: Set<string>
spawning: Map<string, Promise<LSPClient.Info | undefined>>
spawning: Map<string, Effect.Effect<LSPClient.Info | undefined>>
}
export interface Interface {
@@ -170,7 +170,7 @@ export const layer = Layer.effect(
if (!cfg.lsp) {
log.info("all LSPs are disabled")
} else {
for (const server of Object.values(LSPServer)) {
for (const server of Object.values(LSPServer.Builtins)) {
servers[server.id] = server
}
@@ -187,15 +187,16 @@ export const layer = Layer.effect(
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
root: existing?.root ?? (() => Effect.succeed(Instance.directory)),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
cwd: root,
env: { ...process.env, ...item.env },
}),
initialization: item.initialization,
}),
spawn: (root) =>
Effect.sync(() => ({
process: lspspawn(item.command[0], item.command.slice(1), {
cwd: root,
env: { ...process.env, ...item.env },
}),
initialization: item.initialization,
})),
}
}
}
@@ -215,110 +216,121 @@ export const layer = Layer.effect(
}
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await Promise.all(s.clients.map((client) => client.shutdown()))
}),
Effect.forEach(s.clients, (client) => client.shutdown(), { concurrency: "unbounded", discard: true }),
)
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) {
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root)
.then((value) => {
if (!value) s.broken.add(key)
return value
})
.catch((err) => {
s.broken.add(key)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
if (!handle) return undefined
log.info("spawned lsp server", { serverID: server.id, root })
const root = yield* server.root(file)
if (!root) continue
const client = await LSPClient.create({
serverID: server.id,
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
})
const key = root + server.id
if (s.broken.has(key)) continue
if (!client) return undefined
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (existing) {
await Process.stop(handle.process)
return existing
}
s.clients.push(client)
return client
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (match) {
result.push(match)
continue
}
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const hadInflight = s.spawning.has(key)
const client = yield* awaitSpawn(s, server, root, key)
if (!client) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
result.push(client)
if (!hadInflight) Bus.publish(Event.Updated, {})
}
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
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
})
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)
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)
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* () {
@@ -341,38 +353,40 @@ export const layer = Layer.effect(
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
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)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
}
return false
})
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 = yield* server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
}
return false
})
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
log.info("touching file", { file: input })
const clients = yield* getClients(input)
yield* Effect.promise(() =>
yield* Effect.tryPromise(() =>
Promise.all(
clients.map(async (client) => {
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
const wait = waitForDiagnostics ? Effect.runPromise(client.waitForDiagnostics({ path: input })) : Promise.resolve()
await Effect.runPromise(client.notify.open({ path: input }))
return wait
}),
).catch((err) => {
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 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 [p, diags] of result.entries()) {
const arr = results[p] || []
@@ -385,78 +399,65 @@ export const layer = Layer.effect(
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
return yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/hover", {
request(client, "textDocument/hover", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
}, null),
)
})
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/definition", {
request(client, "textDocument/definition", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
}, null),
)
return results.flat().filter(Boolean)
})
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/references", {
request(client, "textDocument/references", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
context: { includeDeclaration: true },
})
.catch(() => []),
}, [] as any[]),
)
return results.flat().filter(Boolean)
})
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/implementation", {
request(client, "textDocument/implementation", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
}, null),
)
return results.flat().filter(Boolean)
})
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
const file = fileURLToPath(uri)
const results = yield* run(file, (client) =>
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
)
const results = yield* run(file, (client) => request(client, "textDocument/documentSymbol", { textDocument: { uri } }, [] as any[]))
return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)
})
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
const results = yield* runAll((client) =>
client.connection
.sendRequest<Symbol[]>("workspace/symbol", { query })
.then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
.catch(() => [] as Symbol[]),
request(client, "workspace/symbol", { query }, [] as Symbol[]).pipe(
Effect.map((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)),
),
)
return results.flat()
})
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
request(client, "textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => []),
}, [] as any[]),
)
return results.flat().filter(Boolean)
})
@@ -465,16 +466,16 @@ export const layer = Layer.effect(
input: LocInput,
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
) {
const results = yield* run(input.file, async (client) => {
const items = await client.connection
.sendRequest<unknown[] | null>("textDocument/prepareCallHierarchy", {
const results = yield* run(input.file, (client) =>
Effect.gen(function* () {
const items = yield* request(client, "textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => [] as unknown[])
if (!items?.length) return []
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
})
}, [] as unknown[])
if (!items.length) return []
return yield* request(client, direction, { item: items[0] }, [] as unknown[])
}),
)
return results.flat().filter(Boolean)
})

View File

@@ -14,6 +14,7 @@ import { which } from "../util/which"
import { Module } from "@opencode-ai/shared/util/module"
import { spawn } from "./launch"
import { Npm } from "../npm"
import { Effect } from "effect"
const log = Log.create({ service: "lsp.server" })
const pathExists = async (p: string) =>
@@ -29,9 +30,10 @@ export interface Handle {
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) => {
if (excludePatterns) {
const excludedFiles = Filesystem.up({
@@ -55,15 +57,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 {
id: string
extensions: string[]
global?: boolean
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",
root: async (file) => {
const files = Filesystem.up({
@@ -91,7 +114,7 @@ export const Deno: Info = {
},
}
export const Typescript: Info = {
export const Typescript: RawInfo = {
id: "typescript",
root: NearestRoot(
["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"],
@@ -121,7 +144,7 @@ export const Typescript: Info = {
},
}
export const Vue: Info = {
export const Vue: RawInfo = {
id: "vue",
extensions: [".vue"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
@@ -150,7 +173,7 @@ export const Vue: Info = {
},
}
export const ESLint: Info = {
export const ESLint: RawInfo = {
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"],
@@ -207,7 +230,7 @@ export const ESLint: Info = {
},
}
export const Oxlint: Info = {
export const Oxlint: RawInfo = {
id: "oxlint",
root: NearestRoot([
".oxlintrc.json",
@@ -280,7 +303,7 @@ export const Oxlint: Info = {
},
}
export const Biome: Info = {
export const Biome: RawInfo = {
id: "biome",
root: NearestRoot([
"biome.json",
@@ -342,7 +365,7 @@ export const Biome: Info = {
},
}
export const Gopls: Info = {
export const Gopls: RawInfo = {
id: "gopls",
root: async (file) => {
const work = await NearestRoot(["go.work"])(file)
@@ -381,7 +404,7 @@ export const Gopls: Info = {
},
}
export const Rubocop: Info = {
export const Rubocop: RawInfo = {
id: "ruby-lsp",
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
@@ -419,7 +442,7 @@ export const Rubocop: Info = {
},
}
export const Ty: Info = {
export const Ty: RawInfo = {
id: "ty",
extensions: [".py", ".pyi"],
root: NearestRoot([
@@ -481,7 +504,7 @@ export const Ty: Info = {
},
}
export const Pyright: Info = {
export const Pyright: RawInfo = {
id: "pyright",
extensions: [".py", ".pyi"],
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
@@ -525,7 +548,7 @@ export const Pyright: Info = {
},
}
export const ElixirLS: Info = {
export const ElixirLS: RawInfo = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
root: NearestRoot(["mix.exs", "mix.lock"]),
@@ -588,7 +611,7 @@ export const ElixirLS: Info = {
},
}
export const Zls: Info = {
export const Zls: RawInfo = {
id: "zls",
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
@@ -700,7 +723,7 @@ export const Zls: Info = {
},
}
export const CSharp: Info = {
export const CSharp: RawInfo = {
id: "csharp",
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
@@ -737,7 +760,7 @@ export const CSharp: Info = {
},
}
export const FSharp: Info = {
export const FSharp: RawInfo = {
id: "fsharp",
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
@@ -774,7 +797,7 @@ export const FSharp: Info = {
},
}
export const SourceKit: Info = {
export const SourceKit: RawInfo = {
id: "sourcekit-lsp",
extensions: [".swift", ".objc", "objcpp"],
root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]),
@@ -808,7 +831,7 @@ export const SourceKit: Info = {
},
}
export const RustAnalyzer: Info = {
export const RustAnalyzer: RawInfo = {
id: "rust",
root: async (root) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
@@ -854,7 +877,7 @@ export const RustAnalyzer: Info = {
},
}
export const Clangd: Info = {
export const Clangd: RawInfo = {
id: "clangd",
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
@@ -1000,7 +1023,7 @@ export const Clangd: Info = {
},
}
export const Svelte: Info = {
export const Svelte: RawInfo = {
id: "svelte",
extensions: [".svelte"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
@@ -1027,7 +1050,7 @@ export const Svelte: Info = {
},
}
export const Astro: Info = {
export const Astro: RawInfo = {
id: "astro",
extensions: [".astro"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
@@ -1065,7 +1088,7 @@ export const Astro: Info = {
},
}
export const JDTLS: Info = {
export const JDTLS: RawInfo = {
id: "jdtls",
root: async (file) => {
// Without exclusions, NearestRoot defaults to instance directory so we can't
@@ -1186,7 +1209,7 @@ export const JDTLS: Info = {
},
}
export const KotlinLS: Info = {
export const KotlinLS: RawInfo = {
id: "kotlin-ls",
extensions: [".kt", ".kts"],
root: async (file) => {
@@ -1285,7 +1308,7 @@ export const KotlinLS: Info = {
},
}
export const YamlLS: Info = {
export const YamlLS: RawInfo = {
id: "yaml-ls",
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
@@ -1311,7 +1334,7 @@ export const YamlLS: Info = {
},
}
export const LuaLS: Info = {
export const LuaLS: RawInfo = {
id: "lua-ls",
root: NearestRoot([
".luarc.json",
@@ -1452,7 +1475,7 @@ export const LuaLS: Info = {
},
}
export const PHPIntelephense: Info = {
export const PHPIntelephense: RawInfo = {
id: "php intelephense",
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
@@ -1483,7 +1506,7 @@ export const PHPIntelephense: Info = {
},
}
export const Prisma: Info = {
export const Prisma: RawInfo = {
id: "prisma",
extensions: [".prisma"],
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
@@ -1501,7 +1524,7 @@ export const Prisma: Info = {
},
}
export const Dart: Info = {
export const Dart: RawInfo = {
id: "dart",
extensions: [".dart"],
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
@@ -1519,7 +1542,7 @@ export const Dart: Info = {
},
}
export const Ocaml: Info = {
export const Ocaml: RawInfo = {
id: "ocaml-lsp",
extensions: [".ml", ".mli"],
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
@@ -1536,7 +1559,7 @@ export const Ocaml: Info = {
}
},
}
export const BashLS: Info = {
export const BashLS: RawInfo = {
id: "bash",
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory,
@@ -1562,7 +1585,7 @@ export const BashLS: Info = {
},
}
export const TerraformLS: Info = {
export const TerraformLS: RawInfo = {
id: "terraform",
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
@@ -1643,7 +1666,7 @@ export const TerraformLS: Info = {
},
}
export const TexLab: Info = {
export const TexLab: RawInfo = {
id: "texlab",
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
@@ -1731,7 +1754,7 @@ export const TexLab: Info = {
},
}
export const DockerfileLS: Info = {
export const DockerfileLS: RawInfo = {
id: "dockerfile",
extensions: [".dockerfile", "Dockerfile"],
root: async () => Instance.directory,
@@ -1757,7 +1780,7 @@ export const DockerfileLS: Info = {
},
}
export const Gleam: Info = {
export const Gleam: RawInfo = {
id: "gleam",
extensions: [".gleam"],
root: NearestRoot(["gleam.toml"]),
@@ -1775,7 +1798,7 @@ export const Gleam: Info = {
},
}
export const Clojure: Info = {
export const Clojure: RawInfo = {
id: "clojure-lsp",
extensions: [".clj", ".cljs", ".cljc", ".edn"],
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
@@ -1796,7 +1819,7 @@ export const Clojure: Info = {
},
}
export const Nixd: Info = {
export const Nixd: RawInfo = {
id: "nixd",
extensions: [".nix"],
root: async (file) => {
@@ -1827,7 +1850,7 @@ export const Nixd: Info = {
},
}
export const Tinymist: Info = {
export const Tinymist: RawInfo = {
id: "tinymist",
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
@@ -1919,7 +1942,7 @@ export const Tinymist: Info = {
},
}
export const HLS: Info = {
export const HLS: RawInfo = {
id: "haskell-language-server",
extensions: [".hs", ".lhs"],
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
@@ -1937,7 +1960,7 @@ export const HLS: Info = {
},
}
export const JuliaLS: Info = {
export const JuliaLS: RawInfo = {
id: "julials",
extensions: [".jl"],
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
@@ -1954,3 +1977,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,
})

View File

@@ -1,5 +1,6 @@
import { describe, expect, test, beforeEach } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { LSPClient } from "../../src/lsp"
import { LSPServer } from "../../src/lsp"
import { Instance } from "../../src/project/instance"
@@ -27,11 +28,13 @@ describe("LSPClient interop", () => {
const client = await Instance.provide({
directory: process.cwd(),
fn: () =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
}),
Effect.runPromise(
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
}),
),
})
await client.connection.sendNotification("test/trigger", {
@@ -42,7 +45,7 @@ describe("LSPClient interop", () => {
expect(client.connection).toBeDefined()
await client.shutdown()
await Effect.runPromise(client.shutdown())
})
test("handles client/registerCapability request", async () => {
@@ -51,11 +54,13 @@ describe("LSPClient interop", () => {
const client = await Instance.provide({
directory: process.cwd(),
fn: () =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
}),
Effect.runPromise(
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
}),
),
})
await client.connection.sendNotification("test/trigger", {
@@ -66,7 +71,7 @@ describe("LSPClient interop", () => {
expect(client.connection).toBeDefined()
await client.shutdown()
await Effect.runPromise(client.shutdown())
})
test("handles client/unregisterCapability request", async () => {
@@ -75,11 +80,13 @@ describe("LSPClient interop", () => {
const client = await Instance.provide({
directory: process.cwd(),
fn: () =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
}),
Effect.runPromise(
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
}),
),
})
await client.connection.sendNotification("test/trigger", {
@@ -90,6 +97,6 @@ describe("LSPClient interop", () => {
expect(client.connection).toBeDefined()
await client.shutdown()
await Effect.runPromise(client.shutdown())
})
})