chore: initialize qiming workspace repository
This commit is contained in:
695
qimingcode/packages/opencode/src/lsp/client.ts
Normal file
695
qimingcode/packages/opencode/src/lsp/client.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
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 { Log } from "../util"
|
||||
import { Process } from "../util"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import type * as LSPServer from "./server"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
import { Filesystem } from "../util"
|
||||
|
||||
const DIAGNOSTICS_DEBOUNCE_MS = 150
|
||||
const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000
|
||||
const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000
|
||||
const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000
|
||||
|
||||
const INITIALIZE_TIMEOUT_MS = 45_000
|
||||
|
||||
// LSP spec constants
|
||||
const FILE_CHANGE_CREATED = 1
|
||||
const FILE_CHANGE_CHANGED = 2
|
||||
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
|
||||
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
|
||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
||||
|
||||
export type Diagnostic = VSCodeDiagnostic
|
||||
|
||||
export const InitializeError = NamedError.create(
|
||||
"LSPInitializeError",
|
||||
z.object({
|
||||
serverID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const Event = {
|
||||
Diagnostics: BusEvent.define(
|
||||
"lsp.client.diagnostics",
|
||||
Schema.Struct({
|
||||
serverID: Schema.String,
|
||||
path: Schema.String,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
type DocumentDiagnosticReport = {
|
||||
items?: Diagnostic[]
|
||||
relatedDocuments?: Record<string, DocumentDiagnosticReport>
|
||||
}
|
||||
|
||||
type WorkspaceDiagnosticReport = {
|
||||
items?: {
|
||||
uri?: string
|
||||
items?: Diagnostic[]
|
||||
}[]
|
||||
}
|
||||
|
||||
type DiagnosticRequestResult = {
|
||||
handled: boolean
|
||||
matched: boolean
|
||||
byFile: Map<string, Diagnostic[]>
|
||||
}
|
||||
|
||||
type CapabilityRegistration = {
|
||||
id: string
|
||||
method: string
|
||||
registerOptions?: {
|
||||
identifier?: string
|
||||
workspaceDiagnostics?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type ServerCapabilities = {
|
||||
textDocumentSync?:
|
||||
| number
|
||||
| {
|
||||
change?: number
|
||||
}
|
||||
diagnosticProvider?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function getFilePath(uri: string) {
|
||||
if (!uri.startsWith("file://")) return
|
||||
return Filesystem.normalizePath(fileURLToPath(uri))
|
||||
}
|
||||
|
||||
function getSyncKind(capabilities?: ServerCapabilities) {
|
||||
if (!capabilities) return
|
||||
const sync = capabilities.textDocumentSync
|
||||
if (typeof sync === "number") return sync
|
||||
return sync?.change
|
||||
}
|
||||
|
||||
function endPosition(text: string) {
|
||||
const lines = text.split(/\r\n|\r|\n/)
|
||||
return {
|
||||
line: lines.length - 1,
|
||||
character: lines.at(-1)?.length ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeDiagnostics(items: Diagnostic[]) {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
const key = JSON.stringify({
|
||||
code: item.code,
|
||||
severity: item.severity,
|
||||
message: item.message,
|
||||
source: item.source,
|
||||
range: item.range,
|
||||
})
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function configurationValue(settings: unknown, section?: string) {
|
||||
if (!section) return settings ?? null
|
||||
const result = section.split(".").reduce<unknown>((acc, key) => {
|
||||
if (!acc || typeof acc !== "object" || !(key in acc)) return undefined
|
||||
return (acc as Record<string, unknown>)[key]
|
||||
}, settings)
|
||||
return result ?? null
|
||||
}
|
||||
|
||||
// TypeScript's built-in LSP pushes diagnostics aggressively on first open.
|
||||
// We seed the push cache on the very first publish so waitForFreshPush can
|
||||
// resolve immediately instead of waiting for a second debounced push.
|
||||
function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
|
||||
return serverID === "typescript"
|
||||
}
|
||||
|
||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
|
||||
const logger = log.clone().tag("serverID", input.serverID)
|
||||
logger.info("starting client")
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(input.server.process.stdout as any),
|
||||
new StreamMessageWriter(input.server.process.stdin as any),
|
||||
)
|
||||
// Server stderr can contain both real errors and routine informational logs,
|
||||
// which is normal stderr practice for some tools. Keep the raw stream at
|
||||
// debug so users can opt in with --print-logs --log-level DEBUG without
|
||||
// polluting normal logs.
|
||||
input.server.process.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString().trim()
|
||||
if (text) logger.debug("server stderr", { text: text.slice(0, 1000) })
|
||||
})
|
||||
|
||||
// --- Connection state ---
|
||||
|
||||
const pushDiagnostics = new Map<string, Diagnostic[]>()
|
||||
const pullDiagnostics = new Map<string, Diagnostic[]>()
|
||||
const published = new Map<string, { at: number; version?: number }>()
|
||||
const diagnosticRegistrations = new Map<string, CapabilityRegistration>()
|
||||
const registrationListeners = new Set<() => void>()
|
||||
const mergedDiagnostics = (filePath: string) =>
|
||||
dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
|
||||
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
|
||||
pushDiagnostics.set(filePath, next)
|
||||
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
|
||||
}
|
||||
const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
|
||||
pullDiagnostics.set(filePath, next)
|
||||
}
|
||||
const emitRegistrationChange = () => {
|
||||
for (const listener of [...registrationListeners]) listener()
|
||||
}
|
||||
|
||||
// --- LSP connection handlers ---
|
||||
|
||||
connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
const filePath = getFilePath(params.uri)
|
||||
if (!filePath) return
|
||||
logger.info("textDocument/publishDiagnostics", {
|
||||
path: filePath,
|
||||
count: params.diagnostics.length,
|
||||
version: params.version,
|
||||
})
|
||||
published.set(filePath, {
|
||||
at: Date.now(),
|
||||
version: typeof params.version === "number" ? params.version : undefined,
|
||||
})
|
||||
if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) {
|
||||
pushDiagnostics.set(filePath, params.diagnostics)
|
||||
return
|
||||
}
|
||||
updatePushDiagnostics(filePath, params.diagnostics)
|
||||
})
|
||||
connection.onRequest("window/workDoneProgress/create", (params) => {
|
||||
logger.info("window/workDoneProgress/create", params)
|
||||
return null
|
||||
})
|
||||
connection.onRequest("workspace/configuration", async (params) => {
|
||||
const items = (params as { items?: { section?: string }[] }).items ?? []
|
||||
return items.map((item) => configurationValue(input.server.initialization, item.section))
|
||||
})
|
||||
connection.onRequest("client/registerCapability", async (params) => {
|
||||
const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? []
|
||||
let changed = false
|
||||
for (const registration of registrations) {
|
||||
if (registration.method !== "textDocument/diagnostic") continue
|
||||
diagnosticRegistrations.set(registration.id, registration)
|
||||
changed = true
|
||||
}
|
||||
if (changed) emitRegistrationChange()
|
||||
})
|
||||
connection.onRequest("client/unregisterCapability", async (params) => {
|
||||
const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? []
|
||||
let changed = false
|
||||
for (const registration of registrations) {
|
||||
if (registration.method !== "textDocument/diagnostic") continue
|
||||
diagnosticRegistrations.delete(registration.id)
|
||||
changed = true
|
||||
}
|
||||
if (changed) emitRegistrationChange()
|
||||
})
|
||||
connection.onRequest("workspace/workspaceFolders", async () => [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: pathToFileURL(input.root).href,
|
||||
},
|
||||
])
|
||||
connection.onRequest("workspace/diagnostic/refresh", async () => null)
|
||||
connection.listen()
|
||||
|
||||
// --- Initialize handshake ---
|
||||
|
||||
logger.info("sending initialize")
|
||||
const initialized = await withTimeout(
|
||||
connection.sendRequest<{ capabilities?: ServerCapabilities }>("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,
|
||||
},
|
||||
diagnostics: {
|
||||
refreshSupport: false,
|
||||
},
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didOpen: true,
|
||||
didChange: true,
|
||||
},
|
||||
diagnostic: {
|
||||
dynamicRegistration: true,
|
||||
relatedDocumentSupport: true,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
versionSupport: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
INITIALIZE_TIMEOUT_MS,
|
||||
).catch((err) => {
|
||||
logger.error("initialize error", { error: err })
|
||||
throw new InitializeError(
|
||||
{ serverID: input.serverID },
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const syncKind = getSyncKind(initialized.capabilities)
|
||||
const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider)
|
||||
|
||||
await connection.sendNotification("initialized", {})
|
||||
|
||||
if (input.server.initialization) {
|
||||
await connection.sendNotification("workspace/didChangeConfiguration", {
|
||||
settings: input.server.initialization,
|
||||
})
|
||||
}
|
||||
|
||||
const files: Record<string, { version: number; text: string }> = {}
|
||||
|
||||
// --- Diagnostic helpers ---
|
||||
|
||||
const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
|
||||
const handled = results.some((result) => result.handled)
|
||||
const matched = results.some((result) => result.matched)
|
||||
if (!handled) return { handled: false, matched: false }
|
||||
|
||||
const merged = new Map<string, Diagnostic[]>()
|
||||
for (const result of results) {
|
||||
for (const [target, items] of result.byFile.entries()) {
|
||||
const existing = merged.get(target) ?? []
|
||||
merged.set(target, existing.concat(items))
|
||||
}
|
||||
}
|
||||
|
||||
if (matched && !merged.has(filePath)) merged.set(filePath, [])
|
||||
for (const [target, items] of merged.entries()) {
|
||||
updatePullDiagnostics(target, dedupeDiagnostics(items))
|
||||
}
|
||||
|
||||
return { handled, matched }
|
||||
}
|
||||
|
||||
async function requestDiagnosticReport(filePath: string, identifier?: string): Promise<DiagnosticRequestResult> {
|
||||
const report = await withTimeout(
|
||||
connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
|
||||
...(identifier ? { identifier } : {}),
|
||||
textDocument: {
|
||||
uri: pathToFileURL(filePath).href,
|
||||
},
|
||||
}),
|
||||
DIAGNOSTICS_REQUEST_TIMEOUT_MS,
|
||||
).catch(() => null)
|
||||
if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() }
|
||||
|
||||
const byFile = new Map<string, Diagnostic[]>()
|
||||
const push = (target: string, items: Diagnostic[]) => {
|
||||
const existing = byFile.get(target) ?? []
|
||||
byFile.set(target, existing.concat(items))
|
||||
}
|
||||
|
||||
let handled = false
|
||||
let matched = false
|
||||
if (Array.isArray(report.items)) {
|
||||
push(filePath, report.items)
|
||||
handled = true
|
||||
matched = true
|
||||
}
|
||||
for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
|
||||
const relatedPath = getFilePath(uri)
|
||||
if (!relatedPath || !Array.isArray(related.items)) continue
|
||||
push(relatedPath, related.items)
|
||||
handled = true
|
||||
matched = matched || relatedPath === filePath
|
||||
}
|
||||
|
||||
return { handled, matched, byFile }
|
||||
}
|
||||
|
||||
async function requestWorkspaceDiagnosticReport(
|
||||
filePath: string,
|
||||
identifier?: string,
|
||||
): Promise<DiagnosticRequestResult> {
|
||||
const report = await withTimeout(
|
||||
connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
|
||||
...(identifier ? { identifier } : {}),
|
||||
previousResultIds: [],
|
||||
}),
|
||||
DIAGNOSTICS_REQUEST_TIMEOUT_MS,
|
||||
).catch(() => null)
|
||||
if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() }
|
||||
|
||||
const byFile = new Map<string, Diagnostic[]>()
|
||||
let matched = false
|
||||
for (const item of report.items ?? []) {
|
||||
const relatedPath = item.uri ? getFilePath(item.uri) : undefined
|
||||
if (!relatedPath || !Array.isArray(item.items)) continue
|
||||
const existing = byFile.get(relatedPath) ?? []
|
||||
byFile.set(relatedPath, existing.concat(item.items))
|
||||
matched = matched || relatedPath === filePath
|
||||
}
|
||||
|
||||
return { handled: true, matched, byFile }
|
||||
}
|
||||
|
||||
function documentPullState() {
|
||||
const documentRegistrations = [...diagnosticRegistrations.values()].filter(
|
||||
(registration) => registration.registerOptions?.workspaceDiagnostics !== true,
|
||||
)
|
||||
return {
|
||||
documentIdentifiers: [
|
||||
...new Set(documentRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])),
|
||||
],
|
||||
supported: hasStaticPullDiagnostics || documentRegistrations.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
function workspacePullState() {
|
||||
const workspaceRegistrations = [...diagnosticRegistrations.values()].filter(
|
||||
(registration) => registration.registerOptions?.workspaceDiagnostics === true,
|
||||
)
|
||||
return {
|
||||
workspaceIdentifiers: [
|
||||
...new Set(workspaceRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])),
|
||||
],
|
||||
supported: workspaceRegistrations.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
|
||||
results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0)
|
||||
|
||||
async function requestDiagnostics(
|
||||
filePath: string,
|
||||
requests: Promise<DiagnosticRequestResult>[],
|
||||
done: (results: DiagnosticRequestResult[]) => boolean,
|
||||
) {
|
||||
if (!requests.length) return { handled: false, matched: false }
|
||||
|
||||
const results: DiagnosticRequestResult[] = []
|
||||
return new Promise<{ handled: boolean; matched: boolean }>((resolve) => {
|
||||
let pending = requests.length
|
||||
let resolved = false
|
||||
const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
|
||||
if (resolved) return
|
||||
if (!force && !done(results)) return
|
||||
resolved = true
|
||||
resolve(merged)
|
||||
}
|
||||
|
||||
for (const request of requests) {
|
||||
request.then((result) => {
|
||||
results.push(result)
|
||||
pending -= 1
|
||||
const merged = mergeResults(filePath, results)
|
||||
finish(merged)
|
||||
if (pending === 0) finish(merged, true)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one
|
||||
// batch already produced diagnostics for the current file. Let slower pulls keep
|
||||
// merging in the background; do not sequence identifier-by-identifier, and do
|
||||
// not add a post-match settle/debounce delay. See PR #23771.
|
||||
async function requestDocumentDiagnostics(filePath: string) {
|
||||
const state = documentPullState()
|
||||
if (!state.supported) return { handled: false, matched: false }
|
||||
return requestDiagnostics(
|
||||
filePath,
|
||||
[
|
||||
requestDiagnosticReport(filePath),
|
||||
...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
|
||||
],
|
||||
(results) => hasCurrentFileDiagnostics(filePath, results),
|
||||
)
|
||||
}
|
||||
|
||||
async function requestFullDiagnostics(filePath: string) {
|
||||
const documentState = documentPullState()
|
||||
const workspaceState = workspacePullState()
|
||||
if (!documentState.supported && !workspaceState.supported) return { handled: false, matched: false }
|
||||
return mergeResults(
|
||||
filePath,
|
||||
await Promise.all([
|
||||
...(documentState.supported ? [requestDiagnosticReport(filePath)] : []),
|
||||
...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
|
||||
...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
|
||||
...workspaceState.workspaceIdentifiers.map((identifier) =>
|
||||
requestWorkspaceDiagnosticReport(filePath, identifier),
|
||||
),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function waitForRegistrationChange(timeout: number) {
|
||||
if (timeout <= 0) return Promise.resolve(false)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let finished = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const finish = (result: boolean) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
if (timer) clearTimeout(timer)
|
||||
registrationListeners.delete(listener)
|
||||
resolve(result)
|
||||
}
|
||||
const listener = () => finish(true)
|
||||
registrationListeners.add(listener)
|
||||
timer = setTimeout(() => finish(false), timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) {
|
||||
if (request.timeout <= 0) return Promise.resolve(false)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let finished = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let unsub: (() => void) | undefined
|
||||
const finish = (result: boolean) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
unsub?.()
|
||||
resolve(result)
|
||||
}
|
||||
const schedule = () => {
|
||||
const hit = published.get(request.path)
|
||||
if (!hit) return
|
||||
if (typeof hit.version === "number" && hit.version !== request.version) return
|
||||
if (hit.at < request.after && hit.version !== request.version) return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)))
|
||||
}
|
||||
|
||||
timeoutTimer = setTimeout(() => finish(false), request.timeout)
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
|
||||
schedule()
|
||||
})
|
||||
schedule()
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) {
|
||||
const startedAt = request.after ?? Date.now()
|
||||
const pushWait = waitForFreshPush({
|
||||
path: request.path,
|
||||
version: request.version,
|
||||
after: startedAt,
|
||||
timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {
|
||||
const result = await requestDocumentDiagnostics(request.path)
|
||||
if (result.matched) return
|
||||
const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
|
||||
if (remaining <= 0) return
|
||||
const next = await Promise.race([
|
||||
pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
|
||||
waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),
|
||||
])
|
||||
if (next !== "registration") return
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) {
|
||||
const startedAt = request.after ?? Date.now()
|
||||
const pushWait = waitForFreshPush({
|
||||
path: request.path,
|
||||
version: request.version,
|
||||
after: startedAt,
|
||||
timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) {
|
||||
const result = await requestFullDiagnostics(request.path)
|
||||
if (result.handled || result.matched) return
|
||||
const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
|
||||
if (remaining <= 0) return
|
||||
const next = await Promise.race([
|
||||
pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
|
||||
waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),
|
||||
])
|
||||
if (next !== "registration") return
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
const result = {
|
||||
root: input.root,
|
||||
get serverID() {
|
||||
return input.serverID
|
||||
},
|
||||
get connection() {
|
||||
return connection
|
||||
},
|
||||
notify: {
|
||||
async open(request: { path: string }) {
|
||||
request.path = Filesystem.normalizePath(
|
||||
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
|
||||
)
|
||||
const text = await Filesystem.readText(request.path)
|
||||
const extension = path.extname(request.path)
|
||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||
|
||||
const document = files[request.path]
|
||||
if (document !== undefined) {
|
||||
// Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only
|
||||
// re-emit diagnostics when the content actually changes, so clearing
|
||||
// here would lose errors for no-op touchFile calls. Let the server's
|
||||
// next push/pull overwrite naturally.
|
||||
logger.info("workspace/didChangeWatchedFiles", request)
|
||||
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||
changes: [
|
||||
{
|
||||
uri: pathToFileURL(request.path).href,
|
||||
type: FILE_CHANGE_CHANGED,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const next = document.version + 1
|
||||
files[request.path] = { version: next, text }
|
||||
logger.info("textDocument/didChange", {
|
||||
path: request.path,
|
||||
version: next,
|
||||
})
|
||||
await connection.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri: pathToFileURL(request.path).href,
|
||||
version: next,
|
||||
},
|
||||
contentChanges:
|
||||
syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
|
||||
? [
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: endPosition(document.text),
|
||||
},
|
||||
text,
|
||||
},
|
||||
]
|
||||
: [{ text }],
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
logger.info("workspace/didChangeWatchedFiles", request)
|
||||
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||
changes: [
|
||||
{
|
||||
uri: pathToFileURL(request.path).href,
|
||||
type: FILE_CHANGE_CREATED,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
logger.info("textDocument/didOpen", request)
|
||||
pushDiagnostics.delete(request.path)
|
||||
pullDiagnostics.delete(request.path)
|
||||
await connection.sendNotification("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri: pathToFileURL(request.path).href,
|
||||
languageId,
|
||||
version: 0,
|
||||
text,
|
||||
},
|
||||
})
|
||||
files[request.path] = { version: 0, text }
|
||||
return 0
|
||||
},
|
||||
},
|
||||
get diagnostics() {
|
||||
const result = new Map<string, Diagnostic[]>()
|
||||
for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
|
||||
result.set(key, mergedDiagnostics(key))
|
||||
}
|
||||
return result
|
||||
},
|
||||
async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number }) {
|
||||
const normalizedPath = Filesystem.normalizePath(
|
||||
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
|
||||
)
|
||||
logger.info("waiting for diagnostics", {
|
||||
path: normalizedPath,
|
||||
mode: request.mode ?? "full",
|
||||
version: request.version,
|
||||
})
|
||||
if (request.mode === "document") {
|
||||
await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
|
||||
return
|
||||
}
|
||||
await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
|
||||
},
|
||||
async shutdown() {
|
||||
logger.info("shutting down")
|
||||
connection.end()
|
||||
connection.dispose()
|
||||
await Process.stop(input.server.process)
|
||||
logger.info("shutdown")
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("initialized")
|
||||
|
||||
return result
|
||||
}
|
||||
29
qimingcode/packages/opencode/src/lsp/diagnostic.ts
Normal file
29
qimingcode/packages/opencode/src/lsp/diagnostic.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as LSPClient from "./client"
|
||||
|
||||
const MAX_PER_FILE = 20
|
||||
|
||||
export function pretty(diagnostic: LSPClient.Diagnostic) {
|
||||
const severityMap = {
|
||||
1: "ERROR",
|
||||
2: "WARN",
|
||||
3: "INFO",
|
||||
4: "HINT",
|
||||
}
|
||||
|
||||
const severity = severityMap[diagnostic.severity || 1]
|
||||
const line = diagnostic.range.start.line + 1
|
||||
const col = diagnostic.range.start.character + 1
|
||||
|
||||
return `${severity} [${line}:${col}] ${diagnostic.message}`
|
||||
}
|
||||
|
||||
export function report(file: string, issues: LSPClient.Diagnostic[]) {
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) return ""
|
||||
const limited = errors.slice(0, MAX_PER_FILE)
|
||||
const more = errors.length - MAX_PER_FILE
|
||||
const suffix = more > 0 ? `\n... and ${more} more` : ""
|
||||
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
|
||||
export * as Diagnostic from "./diagnostic"
|
||||
3
qimingcode/packages/opencode/src/lsp/index.ts
Normal file
3
qimingcode/packages/opencode/src/lsp/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * as LSP from "./lsp"
|
||||
export * as LSPClient from "./client"
|
||||
export * as LSPServer from "./server"
|
||||
121
qimingcode/packages/opencode/src/lsp/language.ts
Normal file
121
qimingcode/packages/opencode/src/lsp/language.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".abap": "abap",
|
||||
".bat": "bat",
|
||||
".bib": "bibtex",
|
||||
".bibtex": "bibtex",
|
||||
".clj": "clojure",
|
||||
".cljs": "clojure",
|
||||
".cljc": "clojure",
|
||||
".edn": "clojure",
|
||||
".coffee": "coffeescript",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".cxx": "cpp",
|
||||
".cc": "cpp",
|
||||
".c++": "cpp",
|
||||
".cs": "csharp",
|
||||
".csx": "csharp",
|
||||
".css": "css",
|
||||
".d": "d",
|
||||
".pas": "pascal",
|
||||
".pascal": "pascal",
|
||||
".diff": "diff",
|
||||
".patch": "diff",
|
||||
".dart": "dart",
|
||||
".dockerfile": "dockerfile",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".erl": "erlang",
|
||||
".ets": "typescript",
|
||||
".hrl": "erlang",
|
||||
".fs": "fsharp",
|
||||
".fsi": "fsharp",
|
||||
".fsx": "fsharp",
|
||||
".fsscript": "fsharp",
|
||||
".gitcommit": "git-commit",
|
||||
".gitrebase": "git-rebase",
|
||||
".go": "go",
|
||||
".groovy": "groovy",
|
||||
".gleam": "gleam",
|
||||
".hbs": "handlebars",
|
||||
".handlebars": "handlebars",
|
||||
".hs": "haskell",
|
||||
".lhs": "haskell",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".ini": "ini",
|
||||
".java": "java",
|
||||
".jl": "julia",
|
||||
".js": "javascript",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".jsx": "javascriptreact",
|
||||
".json": "json",
|
||||
".tex": "latex",
|
||||
".latex": "latex",
|
||||
".less": "less",
|
||||
".lua": "lua",
|
||||
".makefile": "makefile",
|
||||
makefile: "makefile",
|
||||
".md": "markdown",
|
||||
".markdown": "markdown",
|
||||
".m": "objective-c",
|
||||
".mm": "objective-cpp",
|
||||
".pl": "perl",
|
||||
".pm": "perl",
|
||||
".pm6": "perl6",
|
||||
".php": "php",
|
||||
".ps1": "powershell",
|
||||
".psm1": "powershell",
|
||||
".pug": "jade",
|
||||
".jade": "jade",
|
||||
".py": "python",
|
||||
".r": "r",
|
||||
".cshtml": "razor",
|
||||
".razor": "razor",
|
||||
".rb": "ruby",
|
||||
".rake": "ruby",
|
||||
".gemspec": "ruby",
|
||||
".ru": "ruby",
|
||||
".erb": "erb",
|
||||
".html.erb": "erb",
|
||||
".js.erb": "erb",
|
||||
".css.erb": "erb",
|
||||
".json.erb": "erb",
|
||||
".rs": "rust",
|
||||
".scss": "scss",
|
||||
".sass": "sass",
|
||||
".scala": "scala",
|
||||
".shader": "shaderlab",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".zsh": "shellscript",
|
||||
".ksh": "shellscript",
|
||||
".sql": "sql",
|
||||
".svelte": "svelte",
|
||||
".swift": "swift",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".mtsx": "typescriptreact",
|
||||
".ctsx": "typescriptreact",
|
||||
".xml": "xml",
|
||||
".xsl": "xsl",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".vue": "vue",
|
||||
".zig": "zig",
|
||||
".zon": "zig",
|
||||
".astro": "astro",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".tf": "terraform",
|
||||
".tfvars": "terraform-vars",
|
||||
".hcl": "hcl",
|
||||
".nix": "nix",
|
||||
".typ": "typst",
|
||||
".typc": "typst",
|
||||
} as const
|
||||
21
qimingcode/packages/opencode/src/lsp/launch.ts
Normal file
21
qimingcode/packages/opencode/src/lsp/launch.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "child_process"
|
||||
import { Process } from "../util"
|
||||
|
||||
type Child = Process.Child & ChildProcessWithoutNullStreams
|
||||
|
||||
export function spawn(cmd: string, args: string[], opts?: Process.Options): Child
|
||||
export function spawn(cmd: string, opts?: Process.Options): Child
|
||||
export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) {
|
||||
const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : []
|
||||
const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts
|
||||
const proc = Process.spawn([cmd, ...args], {
|
||||
...cfg,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}) as Child
|
||||
|
||||
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
|
||||
|
||||
return proc
|
||||
}
|
||||
520
qimingcode/packages/opencode/src/lsp/lsp.ts
Normal file
520
qimingcode/packages/opencode/src/lsp/lsp.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Log } from "../util"
|
||||
import * as LSPClient from "./client"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import * as LSPServer from "./server"
|
||||
import z from "zod"
|
||||
import { Config } from "../config"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Process } from "../util"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("lsp.updated", Schema.Struct({})),
|
||||
}
|
||||
|
||||
const Position = Schema.Struct({
|
||||
line: Schema.Number,
|
||||
character: Schema.Number,
|
||||
})
|
||||
|
||||
export const Range = Schema.Struct({
|
||||
start: Position,
|
||||
end: Position,
|
||||
})
|
||||
.annotate({ identifier: "Range" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Range = typeof Range.Type
|
||||
|
||||
export const Symbol = Schema.Struct({
|
||||
name: Schema.String,
|
||||
kind: Schema.Number,
|
||||
location: Schema.Struct({
|
||||
uri: Schema.String,
|
||||
range: Range,
|
||||
}),
|
||||
})
|
||||
.annotate({ identifier: "Symbol" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Symbol = typeof Symbol.Type
|
||||
|
||||
export const DocumentSymbol = Schema.Struct({
|
||||
name: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
kind: Schema.Number,
|
||||
range: Range,
|
||||
selectionRange: Range,
|
||||
})
|
||||
.annotate({ identifier: "DocumentSymbol" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type DocumentSymbol = typeof DocumentSymbol.Type
|
||||
|
||||
export const Status = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
root: Schema.String,
|
||||
status: Schema.Literals(["connected", "error"]).annotate({
|
||||
[ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
|
||||
}),
|
||||
})
|
||||
.annotate({ identifier: "LSPStatus" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
enum SymbolKind {
|
||||
File = 1,
|
||||
Module = 2,
|
||||
Namespace = 3,
|
||||
Package = 4,
|
||||
Class = 5,
|
||||
Method = 6,
|
||||
Property = 7,
|
||||
Field = 8,
|
||||
Constructor = 9,
|
||||
Enum = 10,
|
||||
Interface = 11,
|
||||
Function = 12,
|
||||
Variable = 13,
|
||||
Constant = 14,
|
||||
String = 15,
|
||||
Number = 16,
|
||||
Boolean = 17,
|
||||
Array = 18,
|
||||
Object = 19,
|
||||
Key = 20,
|
||||
Null = 21,
|
||||
EnumMember = 22,
|
||||
Struct = 23,
|
||||
Event = 24,
|
||||
Operator = 25,
|
||||
TypeParameter = 26,
|
||||
}
|
||||
|
||||
const kinds = [
|
||||
SymbolKind.Class,
|
||||
SymbolKind.Function,
|
||||
SymbolKind.Method,
|
||||
SymbolKind.Interface,
|
||||
SymbolKind.Variable,
|
||||
SymbolKind.Constant,
|
||||
SymbolKind.Struct,
|
||||
SymbolKind.Enum,
|
||||
]
|
||||
|
||||
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
|
||||
if (servers["pyright"]) {
|
||||
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
|
||||
delete servers["pyright"]
|
||||
}
|
||||
} else {
|
||||
if (servers["ty"]) {
|
||||
delete servers["ty"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LocInput = { file: string; line: number; character: number }
|
||||
|
||||
interface State {
|
||||
clients: LSPClient.Info[]
|
||||
servers: Record<string, LSPServer.Info>
|
||||
broken: Set<string>
|
||||
spawning: Map<string, Promise<LSPClient.Info | undefined>>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly hasClients: (file: string) => Effect.Effect<boolean>
|
||||
readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect<void>
|
||||
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
|
||||
readonly hover: (input: LocInput) => Effect.Effect<any>
|
||||
readonly definition: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly references: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly implementation: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]>
|
||||
readonly workspaceSymbol: (query: string) => Effect.Effect<Symbol[]>
|
||||
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
|
||||
if (!cfg.lsp) {
|
||||
log.info("all LSPs are disabled")
|
||||
} else {
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
servers[server.id] = server
|
||||
}
|
||||
|
||||
filterExperimentalServers(servers)
|
||||
|
||||
if (cfg.lsp !== true) {
|
||||
for (const [name, item] of Object.entries(cfg.lsp)) {
|
||||
const existing = servers[name]
|
||||
if (item.disabled) {
|
||||
log.info(`LSP server ${name} is disabled`)
|
||||
delete servers[name]
|
||||
continue
|
||||
}
|
||||
servers[name] = {
|
||||
...existing,
|
||||
id: name,
|
||||
root: existing?.root ?? (async (_file, ctx) => ctx.directory),
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => ({
|
||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...item.env },
|
||||
}),
|
||||
initialization: item.initialization,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("enabled LSP servers", {
|
||||
serverIds: Object.values(servers)
|
||||
.map((server) => server.id)
|
||||
.join(", "),
|
||||
})
|
||||
}
|
||||
|
||||
const s: State = {
|
||||
clients: [],
|
||||
servers,
|
||||
broken: new Set(),
|
||||
spawning: new Map(),
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(s.clients.map((client) => client.shutdown()))
|
||||
}),
|
||||
)
|
||||
|
||||
return s
|
||||
}),
|
||||
)
|
||||
|
||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (
|
||||
!AppFileSystem.contains(ctx.directory, file) &&
|
||||
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
|
||||
) {
|
||||
return [] as LSPClient.Info[]
|
||||
}
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
||||
const handle = await server
|
||||
.spawn(root, ctx)
|
||||
.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
|
||||
log.info("spawned lsp server", { serverID: server.id, root })
|
||||
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
directory: ctx.directory,
|
||||
}).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 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
|
||||
}
|
||||
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
|
||||
const root = await server.root(file, ctx)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const clients = yield* getClients(file)
|
||||
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const init = Effect.fn("LSP.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
const status = Effect.fn("LSP.status")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result: Status[] = []
|
||||
for (const client of s.clients) {
|
||||
result.push({
|
||||
id: client.serverID,
|
||||
name: s.servers[client.serverID].id,
|
||||
root: path.relative(ctx.directory, client.root),
|
||||
status: "connected",
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file, ctx)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {
|
||||
log.info("touching file", { file: input })
|
||||
const clients = yield* getClients(input)
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
clients.map(async (client) => {
|
||||
const after = Date.now()
|
||||
const version = await client.notify.open({ path: input })
|
||||
if (!diagnostics) return
|
||||
return client.waitForDiagnostics({
|
||||
path: input,
|
||||
version,
|
||||
mode: diagnostics,
|
||||
after,
|
||||
})
|
||||
}),
|
||||
).catch((err) => {
|
||||
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)
|
||||
for (const result of all) {
|
||||
for (const [p, diags] of result.entries()) {
|
||||
const arr = results[p] || []
|
||||
arr.push(...diags)
|
||||
results[p] = arr
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
|
||||
return yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/hover", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
})
|
||||
|
||||
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => 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", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
context: { includeDeclaration: true },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
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", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => 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(() => []),
|
||||
)
|
||||
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[]),
|
||||
)
|
||||
return results.flat()
|
||||
})
|
||||
|
||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const callHierarchyRequest = Effect.fnUntraced(function* (
|
||||
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", {
|
||||
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(() => [])
|
||||
})
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
|
||||
})
|
||||
|
||||
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init,
|
||||
status,
|
||||
hasClients,
|
||||
touchFile,
|
||||
diagnostics,
|
||||
hover,
|
||||
definition,
|
||||
references,
|
||||
implementation,
|
||||
documentSymbol,
|
||||
workspaceSymbol,
|
||||
prepareCallHierarchy,
|
||||
incomingCalls,
|
||||
outgoingCalls,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
export * as Diagnostic from "./diagnostic"
|
||||
2064
qimingcode/packages/opencode/src/lsp/server.ts
Normal file
2064
qimingcode/packages/opencode/src/lsp/server.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user