feat(lsp): add extension inference and improve diagnostics
🤖 Generated with assistance of OhMyOpenCode
This commit is contained in:
@@ -4,57 +4,42 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
|
||||
import { DEFAULT_MAX_DIAGNOSTICS } from "./constants"
|
||||
import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics"
|
||||
import { inferExtensionFromDirectory } from "./infer-extension"
|
||||
import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters"
|
||||
import { isDirectoryPath, withLspClient } from "./lsp-client-wrapper"
|
||||
import type { Diagnostic } from "./types"
|
||||
|
||||
export const lsp_diagnostics: ToolDefinition = tool({
|
||||
description:
|
||||
'Get errors, warnings, hints from language server BEFORE running build. Use filePath for a single file, or filePath with extension for a directory. Do NOT pass both filePath and directory — use filePath for everything.',
|
||||
'Get errors, warnings, hints from language server BEFORE running build. Works for both single files and directories — file extension is auto-detected for directories.',
|
||||
args: {
|
||||
filePath: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("File or directory path to check diagnostics for"),
|
||||
directory: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Alias for filePath when checking a directory. Do NOT provide both filePath and directory."),
|
||||
severity: tool.schema
|
||||
.enum(["error", "warning", "information", "hint", "all"])
|
||||
.optional()
|
||||
.describe("Filter by severity level"),
|
||||
extension: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Required if target is a directory. E.g., '.ts', '.py', '.go', '.java'"),
|
||||
},
|
||||
execute: async (args, _context) => {
|
||||
try {
|
||||
// Accept either filePath or directory (treat directory as alias for filePath)
|
||||
const targetPath = args.filePath || args.directory
|
||||
if (!targetPath) {
|
||||
throw new Error("Provide either 'filePath' or 'directory' parameter.")
|
||||
if (!args.filePath) {
|
||||
throw new Error("'filePath' parameter is required.")
|
||||
}
|
||||
if (args.filePath && args.directory) {
|
||||
// Instead of erroring, just use filePath and ignore directory
|
||||
// This prevents model confusion from causing hard failures
|
||||
}
|
||||
const absPath = resolve(targetPath)
|
||||
const absPath = resolve(args.filePath)
|
||||
|
||||
if (isDirectoryPath(absPath)) {
|
||||
if (!args.extension) {
|
||||
const extension = inferExtensionFromDirectory(absPath)
|
||||
if (!extension) {
|
||||
throw new Error(
|
||||
`Directory path requires 'extension' parameter.\n\n` +
|
||||
`Example: lsp_diagnostics(filePath="src", extension=".ts")\n\n` +
|
||||
`Supported extensions: .ts, .tsx, .js, .py, .go, etc.`
|
||||
`No supported source files found in directory: ${absPath}`
|
||||
)
|
||||
}
|
||||
return await aggregateDiagnosticsForDirectory(absPath, args.extension, args.severity)
|
||||
return await aggregateDiagnosticsForDirectory(absPath, extension, args.severity)
|
||||
}
|
||||
|
||||
const result = await withLspClient(targetPath, async (client) => {
|
||||
return (await client.diagnostics(targetPath)) as { items?: Diagnostic[] } | Diagnostic[] | null
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
|
||||
})
|
||||
|
||||
let diagnostics: Diagnostic[] = []
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import os from "os"
|
||||
|
||||
import { inferExtensionFromDirectory } from "./infer-extension"
|
||||
|
||||
describe("inferExtensionFromDirectory", () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(os.tmpdir(), "omo-infer-ext-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("#given a directory with TypeScript files", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "index.ts"), "export const a = 1")
|
||||
writeFileSync(join(tmpDir, "utils.ts"), "export const b = 2")
|
||||
writeFileSync(join(tmpDir, "app.tsx"), "export const c = 3")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then returns .ts as the most common extension", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBe(".ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a directory with mixed file types where Python dominates", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "main.py"), "x = 1")
|
||||
writeFileSync(join(tmpDir, "utils.py"), "y = 2")
|
||||
writeFileSync(join(tmpDir, "helper.py"), "z = 3")
|
||||
writeFileSync(join(tmpDir, "config.ts"), "export default {}")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then returns .py as the most common extension", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBe(".py")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an empty directory", () => {
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then returns null", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a directory with only unsupported files", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "data.csv"), "a,b,c")
|
||||
writeFileSync(join(tmpDir, "image.png"), "fake")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then returns null", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a directory with nested subdirectories", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "root.go"), "package main")
|
||||
const sub = join(tmpDir, "pkg")
|
||||
mkdirSync(sub)
|
||||
writeFileSync(join(sub, "handler.go"), "package pkg")
|
||||
writeFileSync(join(sub, "model.go"), "package pkg")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then counts files recursively", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBe(".go")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a directory with node_modules", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "index.ts"), "export {}")
|
||||
const nm = join(tmpDir, "node_modules", "pkg")
|
||||
mkdirSync(nm, { recursive: true })
|
||||
writeFileSync(join(nm, "a.js"), "module.exports = {}")
|
||||
writeFileSync(join(nm, "b.js"), "module.exports = {}")
|
||||
writeFileSync(join(nm, "c.js"), "module.exports = {}")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then skips node_modules and returns .ts", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBe(".ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readdirSync, lstatSync } from "fs"
|
||||
import { extname, join } from "path"
|
||||
|
||||
import { EXT_TO_LANG } from "./language-mappings"
|
||||
|
||||
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"])
|
||||
const MAX_SCAN_ENTRIES = 500
|
||||
|
||||
export function inferExtensionFromDirectory(directory: string): string | null {
|
||||
const extensionCounts = new Map<string, number>()
|
||||
let scanned = 0
|
||||
|
||||
function walk(dir: string): void {
|
||||
if (scanned >= MAX_SCAN_ENTRIES) return
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (scanned >= MAX_SCAN_ENTRIES) return
|
||||
|
||||
const fullPath = join(dir, entry)
|
||||
|
||||
let stat: ReturnType<typeof lstatSync> | undefined
|
||||
try {
|
||||
stat = lstatSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) continue
|
||||
scanned++
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (!SKIP_DIRECTORIES.has(entry)) {
|
||||
walk(fullPath)
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const ext = extname(fullPath)
|
||||
if (ext && ext in EXT_TO_LANG) {
|
||||
extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(directory)
|
||||
|
||||
if (extensionCounts.size === 0) return null
|
||||
|
||||
let maxExt = ""
|
||||
let maxCount = 0
|
||||
for (const [ext, count] of extensionCounts) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
maxExt = ext
|
||||
}
|
||||
}
|
||||
|
||||
return maxExt || null
|
||||
}
|
||||
@@ -52,6 +52,9 @@ class LSPServerManager {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupIdleClients();
|
||||
}, 60000);
|
||||
if (typeof this.cleanupInterval === "object" && "unref" in this.cleanupInterval) {
|
||||
this.cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupIdleClients(): void {
|
||||
|
||||
Reference in New Issue
Block a user