summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-06-30 22:46:42 -0400
committerDax Raad <[email protected]>2025-06-30 22:48:32 -0400
commitde15e67834d89334be89901657b4a1290db2c05d (patch)
treec2858afab501ca101c63574d871c88e62614fc65
parentfea56d8de6385d707c688c432d64f107a6d3e4ac (diff)
downloadopencode-de15e67834d89334be89901657b4a1290db2c05d.tar.gz
opencode-de15e67834d89334be89901657b4a1290db2c05d.zip
fix lsp diagnostic accurancy
-rw-r--r--packages/opencode/src/app/app.ts15
-rw-r--r--packages/opencode/src/cli/cmd/debug.ts90
-rw-r--r--packages/opencode/src/file/index.ts25
-rw-r--r--packages/opencode/src/file/ripgrep.ts118
-rw-r--r--packages/opencode/src/lsp/client.ts66
-rw-r--r--packages/opencode/src/lsp/index.ts73
-rw-r--r--packages/opencode/src/tool/read.ts2
-rw-r--r--packages/opencode/src/util/timeout.ts14
8 files changed, 299 insertions, 104 deletions
diff --git a/packages/opencode/src/app/app.ts b/packages/opencode/src/app/app.ts
index 7358b2273..95b17abb3 100644
--- a/packages/opencode/src/app/app.ts
+++ b/packages/opencode/src/app/app.ts
@@ -96,13 +96,16 @@ export namespace App {
}
return ctx.provide(app, async () => {
- const result = await cb(app.info)
- for (const [key, entry] of app.services.entries()) {
- if (!entry.shutdown) continue
- log.info("shutdown", { name: key })
- await entry.shutdown?.(await entry.state)
+ try {
+ const result = await cb(app.info)
+ return result
+ } finally {
+ for (const [key, entry] of app.services.entries()) {
+ if (!entry.shutdown) continue
+ log.info("shutdown", { name: key })
+ await entry.shutdown?.(await entry.state)
+ }
}
- return result
})
}
diff --git a/packages/opencode/src/cli/cmd/debug.ts b/packages/opencode/src/cli/cmd/debug.ts
index 23d080e18..6d218733b 100644
--- a/packages/opencode/src/cli/cmd/debug.ts
+++ b/packages/opencode/src/cli/cmd/debug.ts
@@ -1,18 +1,20 @@
import { App } from "../../app/app"
import { Ripgrep } from "../../file/ripgrep"
+import { File } from "../../file"
import { LSP } from "../../lsp"
import { Log } from "../../util/log"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
+import path from "path"
export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(DiagnosticsCommand)
- .command(TreeCommand)
+ .command(RipgrepCommand)
.command(SymbolsCommand)
- .command(FilesCommand)
+ .command(FileReadCommand)
.demandCommand(),
async handler() {},
})
@@ -24,25 +26,12 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
+ await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
-const TreeCommand = cmd({
- command: "tree",
- builder: (yargs) =>
- yargs.option("limit", {
- type: "number",
- }),
- async handler(args) {
- await bootstrap({ cwd: process.cwd() }, async () => {
- const app = App.info()
- console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
- })
- },
-})
-
const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
@@ -57,6 +46,31 @@ const SymbolsCommand = cmd({
},
})
+const RipgrepCommand = cmd({
+ command: "rg",
+ builder: (yargs) =>
+ yargs
+ .command(TreeCommand)
+ .command(FilesCommand)
+ .command(SearchCommand)
+ .demandCommand(),
+ async handler() {},
+})
+
+const TreeCommand = cmd({
+ command: "tree",
+ builder: (yargs) =>
+ yargs.option("limit", {
+ type: "number",
+ }),
+ async handler(args) {
+ await bootstrap({ cwd: process.cwd() }, async () => {
+ const app = App.info()
+ console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
+ })
+ },
+})
+
const FilesCommand = cmd({
command: "files",
builder: (yargs) =>
@@ -86,3 +100,47 @@ const FilesCommand = cmd({
})
},
})
+
+const SearchCommand = cmd({
+ command: "search <pattern>",
+ builder: (yargs) =>
+ yargs
+ .positional("pattern", {
+ type: "string",
+ demandOption: true,
+ description: "Search pattern",
+ })
+ .option("glob", {
+ type: "array",
+ description: "File glob patterns",
+ })
+ .option("limit", {
+ type: "number",
+ description: "Limit number of results",
+ }),
+ async handler(args) {
+ const results = await Ripgrep.search({
+ cwd: process.cwd(),
+ pattern: args.pattern,
+ glob: args.glob as string[] | undefined,
+ limit: args.limit,
+ })
+ console.log(JSON.stringify(results, null, 2))
+ },
+})
+
+const FileReadCommand = cmd({
+ command: "file-read <path>",
+ builder: (yargs) =>
+ yargs.positional("path", {
+ type: "string",
+ demandOption: true,
+ description: "File path to read",
+ }),
+ async handler(args) {
+ await bootstrap({ cwd: process.cwd() }, async () => {
+ const content = await File.read(path.resolve(args.path))
+ console.log(content)
+ })
+ },
+})
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 7b5beab4d..ead4a2904 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -1,5 +1,8 @@
import { z } from "zod"
import { Bus } from "../bus"
+import { $ } from "bun"
+import { createPatch } from "diff"
+import path from "path"
export namespace File {
export const Event = {
@@ -10,4 +13,26 @@ export namespace File {
}),
),
}
+
+ export async function read(file: string) {
+ const content = await Bun.file(file).text()
+ const gitDiff = await $`git diff HEAD -- ${file}`
+ .cwd(path.dirname(file))
+ .quiet()
+ .nothrow()
+ .text()
+ if (gitDiff.trim()) {
+ const relativePath = path.relative(process.cwd(), file)
+ const originalContent = await $`git show HEAD:./${relativePath}`
+ .cwd(process.cwd())
+ .quiet()
+ .nothrow()
+ .text()
+ if (originalContent.trim()) {
+ const patch = createPatch(file, originalContent, content)
+ return patch
+ }
+ }
+ return content.trim()
+ }
}
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index 280e03b01..5ebc2b43e 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -1,3 +1,4 @@
+// Ripgrep utility functions
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
@@ -8,6 +9,82 @@ import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
+ const Stats = z.object({
+ elapsed: z.object({
+ secs: z.number(),
+ nanos: z.number(),
+ human: z.string(),
+ }),
+ searches: z.number(),
+ searches_with_match: z.number(),
+ bytes_searched: z.number(),
+ bytes_printed: z.number(),
+ matched_lines: z.number(),
+ matches: z.number(),
+ })
+
+ const Begin = z.object({
+ type: z.literal("begin"),
+ data: z.object({
+ path: z.object({
+ text: z.string(),
+ }),
+ }),
+ })
+
+ const Match = z.object({
+ type: z.literal("match"),
+ data: z.object({
+ path: z.object({
+ text: z.string(),
+ }),
+ lines: z.object({
+ text: z.string(),
+ }),
+ line_number: z.number(),
+ absolute_offset: z.number(),
+ submatches: z.array(
+ z.object({
+ match: z.object({
+ text: z.string(),
+ }),
+ start: z.number(),
+ end: z.number(),
+ }),
+ ),
+ }),
+ })
+
+ const End = z.object({
+ type: z.literal("end"),
+ data: z.object({
+ path: z.object({
+ text: z.string(),
+ }),
+ binary_offset: z.number().nullable(),
+ stats: Stats,
+ }),
+ })
+
+ const Summary = z.object({
+ type: z.literal("summary"),
+ data: z.object({
+ elapsed_total: z.object({
+ human: z.string(),
+ nanos: z.number(),
+ secs: z.number(),
+ }),
+ stats: Stats,
+ }),
+ })
+
+ const Result = z.union([Begin, Match, End, Summary])
+
+ export type Result = z.infer<typeof Result>
+ export type Match = z.infer<typeof Match>
+ export type Begin = z.infer<typeof Begin>
+ export type End = z.infer<typeof End>
+ export type Summary = z.infer<typeof Summary>
const PLATFORM = {
darwin: { platform: "apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
@@ -229,4 +306,45 @@ export namespace Ripgrep {
return lines.join("\n")
}
+
+ export async function search(input: {
+ cwd: string
+ pattern: string
+ glob?: string[]
+ limit?: number
+ }) {
+ const args = [
+ `${await filepath()}`,
+ "--json",
+ "--hidden",
+ "--glob='!.git/*'",
+ ]
+
+ if (input.glob) {
+ for (const g of input.glob) {
+ args.push(`--glob=${g}`)
+ }
+ }
+
+ if (input.limit) {
+ args.push(`--max-count=${input.limit}`)
+ }
+
+ args.push(input.pattern)
+
+ const command = args.join(" ")
+ const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
+ if (result.exitCode !== 0) {
+ return []
+ }
+
+ const lines = result.text().trim().split("\n").filter(Boolean)
+ // Parse JSON lines from ripgrep output
+
+ return lines
+ .map((line) => JSON.parse(line))
+ .map((parsed) => Result.parse(parsed))
+ .filter((r) => r.type === "match")
+ .map((r) => r.data)
+ }
}
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index 38d8b3505..b1da5af3f 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -12,6 +12,7 @@ import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
+import { withTimeout } from "../util/timeout"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -52,7 +53,9 @@ export namespace LSPClient {
log.info("textDocument/publishDiagnostics", {
path,
})
+ const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
+ if (!exists && serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID })
})
connection.onRequest("workspace/configuration", async () => {
@@ -61,7 +64,7 @@ export namespace LSPClient {
connection.listen()
log.info("sending initialize", { id: serverID })
- await Promise.race([
+ await withTimeout(
connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
@@ -88,12 +91,10 @@ export namespace LSPClient {
},
},
}),
- new Promise((_, reject) => {
- setTimeout(() => {
- reject(new InitializeError({ serverID }))
- }, 5_000)
- }),
- ])
+ 5_000,
+ ).catch(() => {
+ throw new InitializeError({ serverID })
+ })
await connection.sendNotification("initialized", {})
log.info("initialized")
@@ -116,36 +117,28 @@ export namespace LSPClient {
const file = Bun.file(input.path)
const text = await file.text()
const version = files[input.path]
- if (version === undefined) {
- log.info("textDocument/didOpen", input)
+ if (version !== undefined) {
diagnostics.delete(input.path)
- const extension = path.extname(input.path)
- const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
- await connection.sendNotification("textDocument/didOpen", {
+ await connection.sendNotification("textDocument/didClose", {
textDocument: {
uri: `file://` + input.path,
- languageId,
- version: 0,
- text,
},
})
- files[input.path] = 0
- return
}
-
- log.info("textDocument/didChange", input)
+ log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
- await connection.sendNotification("textDocument/didChange", {
+ const extension = path.extname(input.path)
+ const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
+ await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
- version: ++files[input.path],
+ languageId,
+ version: 0,
+ text,
},
- contentChanges: [
- {
- text,
- },
- ],
})
+ files[input.path] = 0
+ return
},
},
get diagnostics() {
@@ -157,35 +150,30 @@ export namespace LSPClient {
: path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
- let timeout: NodeJS.Timeout
- return await Promise.race([
- new Promise<void>(async (resolve) => {
+ return await withTimeout(
+ new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.serverID
) {
log.info("got diagnostics", input)
- clearTimeout(timeout)
unsub?.()
resolve()
}
})
}),
- new Promise<void>((resolve) => {
- timeout = setTimeout(() => {
- log.info("timed out refreshing diagnostics", input)
- unsub?.()
- resolve()
- }, 5000)
- }),
- ])
+ 5000,
+ ).finally(() => {
+ unsub?.()
+ })
},
async shutdown() {
- log.info("shutting down")
+ log.info("shutting down", { serverID })
connection.end()
connection.dispose()
server.process.kill("SIGTERM")
+ log.info("shutdown", { serverID })
},
}
diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts
index 35224edfb..e4280bf2f 100644
--- a/packages/opencode/src/lsp/index.ts
+++ b/packages/opencode/src/lsp/index.ts
@@ -10,13 +10,29 @@ export namespace LSP {
const state = App.state(
"lsp",
- async () => {
+ async (app) => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
- const skip = new Set<string>()
+ for (const server of Object.values(LSPServer)) {
+ for (const extension of server.extensions) {
+ const [file] = await Ripgrep.files({
+ cwd: app.path.cwd,
+ glob: "*" + extension,
+ })
+ if (!file) continue
+ const handle = await server.spawn(App.info())
+ if (!handle) break
+ const client = await LSPClient.create(server.id, handle).catch(
+ () => {},
+ )
+ if (!client) break
+ clients.set(server.id, client)
+ break
+ }
+ }
+ log.info("initialized")
return {
clients,
- skip,
}
},
async (state) => {
@@ -27,49 +43,22 @@ export namespace LSP {
)
export async function init() {
- log.info("init")
- const app = App.info()
- const result = Object.values(LSPServer).map(async (x) => {
- for (const extension of x.extensions) {
- const [file] = await Ripgrep.files({
- cwd: app.path.cwd,
- glob: "*" + extension,
- })
- if (!file) continue
- await LSP.touchFile(file, true)
- break
- }
- })
- return Promise.all(result)
+ return state()
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext
- const s = await state()
- const matches = Object.values(LSPServer).filter((x) =>
- x.extensions.includes(extension),
- )
- for (const match of matches) {
- const existing = s.clients.get(match.id)
- if (existing) continue
- if (s.skip.has(match.id)) continue
- s.skip.add(match.id)
- const handle = await match.spawn(App.info())
- if (!handle) continue
- const client = await LSPClient.create(match.id, handle).catch(() => {})
- if (!client) {
- s.skip.add(match.id)
- continue
- }
- s.clients.set(match.id, client)
- }
- if (waitForDiagnostics) {
- await run(async (client) => {
- const wait = client.waitForDiagnostics({ path: input })
- await client.notify.open({ path: input })
- return wait
- })
- }
+ const matches = Object.values(LSPServer)
+ .filter((x) => x.extensions.includes(extension))
+ .map((x) => x.id)
+ await run(async (client) => {
+ if (!matches.includes(client.serverID)) return
+ const wait = waitForDiagnostics
+ ? client.waitForDiagnostics({ path: input })
+ : Promise.resolve()
+ await client.notify.open({ path: input })
+ return wait
+ })
}
export async function diagnostics() {
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index 3691459db..f524389ec 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -89,7 +89,7 @@ export const ReadTool = Tool.define({
output += "\n</file>"
// just warms the lsp client
- await LSP.touchFile(filePath, true)
+ await LSP.touchFile(filePath, false)
FileTime.read(ctx.sessionID, filePath)
return {
diff --git a/packages/opencode/src/util/timeout.ts b/packages/opencode/src/util/timeout.ts
new file mode 100644
index 000000000..877996552
--- /dev/null
+++ b/packages/opencode/src/util/timeout.ts
@@ -0,0 +1,14 @@
+export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
+ let timeout: NodeJS.Timeout
+ return Promise.race([
+ promise.then((result) => {
+ clearTimeout(timeout)
+ return result
+ }),
+ new Promise<never>((_, reject) => {
+ timeout = setTimeout(() => {
+ reject(new Error(`Operation timed out after ${ms}ms`))
+ }, ms)
+ }),
+ ])
+}