From 94a2b8ec2cf38d7664b76894e5f801a7bf701dc6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 31 Mar 2026 17:33:42 -0700 Subject: [PATCH] feat(lsp): add extension inference and improve diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with assistance of OhMyOpenCode --- src/tools/lsp/diagnostics-tool.ts | 37 +++------ src/tools/lsp/infer-extension.test.ts | 107 ++++++++++++++++++++++++++ src/tools/lsp/infer-extension.ts | 65 ++++++++++++++++ src/tools/lsp/lsp-server.ts | 3 + 4 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 src/tools/lsp/infer-extension.test.ts create mode 100644 src/tools/lsp/infer-extension.ts diff --git a/src/tools/lsp/diagnostics-tool.ts b/src/tools/lsp/diagnostics-tool.ts index 5303f0c06..d0eae90f3 100644 --- a/src/tools/lsp/diagnostics-tool.ts +++ b/src/tools/lsp/diagnostics-tool.ts @@ -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[] = [] diff --git a/src/tools/lsp/infer-extension.test.ts b/src/tools/lsp/infer-extension.test.ts new file mode 100644 index 000000000..0453e7e69 --- /dev/null +++ b/src/tools/lsp/infer-extension.test.ts @@ -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") + }) + }) + }) +}) diff --git a/src/tools/lsp/infer-extension.ts b/src/tools/lsp/infer-extension.ts new file mode 100644 index 000000000..79259a782 --- /dev/null +++ b/src/tools/lsp/infer-extension.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() + 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 | 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 +} diff --git a/src/tools/lsp/lsp-server.ts b/src/tools/lsp/lsp-server.ts index 69a004edc..4a70a8855 100644 --- a/src/tools/lsp/lsp-server.ts +++ b/src/tools/lsp/lsp-server.ts @@ -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 {