From 5ec1940552a5a9c5935bdebb6c7b04cfa0e402cf Mon Sep 17 00:00:00 2001 From: Error Date: Thu, 30 Oct 2025 10:21:59 -0500 Subject: [PATCH] feat: enhance LSP diagnostics with better error handling and server identification - Add robust file reading error handling in LSP client - Introduce diagnostic formatting with server identification - Create new diagnostic utility for formatting multiple server diagnostics - Improve error handling for missing files during LSP operations --- packages/opencode/src/lsp/client.ts | 19 ++- packages/opencode/src/lsp/index.ts | 20 +++ packages/opencode/src/util/diagnostic.ts | 183 +++++++++++++++++++++++ 3 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/util/diagnostic.ts diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 2d36f454b19..8e9c6933a93 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -129,9 +129,24 @@ export namespace LSPClient { }, notify: { async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) + input.path = path.isAbsolute(input.path) + ? input.path + : path.resolve(Instance.directory, input.path) const file = Bun.file(input.path) - const text = await file.text() + let text = "" + try { + text = await file.text() + } catch (error) { + // Check if it's a file not found error + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + // File doesn't exist, use empty content + text = "" + } else { + // Log other I/O errors and rethrow + log.error(`Failed to read file ${input.path}:`, { error }) + throw error + } + } const extension = path.extname(input.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index d533815fe00..34f92fcf2b5 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -7,6 +7,7 @@ import { Config } from "../config/config" import { spawn } from "child_process" import { Instance } from "../project/instance" +import { formatDiagnosticsWithServers as formatDiagnosticsWithServersUtil } from "../util/diagnostic" export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -284,5 +285,24 @@ export namespace LSP { return `${severity} [${line}:${col}] ${diagnostic.message}` } + + export function prettyWithServer(diagnostic: LSPClient.Diagnostic, serverID: string) { + const sanitizedServerID = serverID + .replace(/[\x00-\x1F\x7F]/g, "") // Remove control characters + .replace(/[\r\n]/g, "") // Remove newlines + .replace(/[\[\]]/g, "") // Remove square brackets + .replace(/[{}|\\]/g, "") // Remove other potentially problematic chars + .replace(/[^a-zA-Z0-9_-]/g, "_") // Replace other unsafe chars with underscore + .substring(0, 50) // Limit length + .trim() || "Unknown" + + return `${pretty(diagnostic)} [${sanitizedServerID}]` + } + + export function formatDiagnosticsWithServers( + diagnostics: Array<{ diagnostic: LSPClient.Diagnostic; serverID: string }>, + ): string { + return formatDiagnosticsWithServersUtil(diagnostics) + } } } diff --git a/packages/opencode/src/util/diagnostic.ts b/packages/opencode/src/util/diagnostic.ts new file mode 100644 index 00000000000..99f39a128d6 --- /dev/null +++ b/packages/opencode/src/util/diagnostic.ts @@ -0,0 +1,183 @@ +import { LSPClient } from "../lsp/client" +import type { Diagnostic } from "vscode-languageserver-types" + +/** + * Type guard to check if diagnostics are in the new multi-server format + */ +export function isMultiServerFormat( + diagnostics: Diagnostic[] | Array<{ diagnostic: Diagnostic; serverID: string }>, +): diagnostics is Array<{ diagnostic: Diagnostic; serverID: string }> { + return diagnostics.length > 0 && "diagnostic" in diagnostics[0] +} + +/** + * Sanitize server ID for safe display in UI + */ +export function sanitizeServerID(serverID: string): string { + if (typeof serverID !== "string") return "Unknown" + + let result = serverID + + // Handle special case: single tag like -> typescript + const singleTagMatch = result.match(/^<(\w+)>$/) + if (singleTagMatch) { + result = singleTagMatch[1] + } else { + // Remove HTML tags completely for other cases + result = result.replace(/<[^>]*>/g, "") + } + + return result + .replace(/javascript:/gi, "") // Remove javascript protocol + .replace(/data:/gi, "") // Remove data protocol + .replace(/vbscript:/gi, "") // Remove vbscript protocol + .replace(/on\w+=/gi, "") // Remove event handlers + .trim() + .substring(0, 100) // Limit length +} + +/** + * Group diagnostics by server ID for efficient processing + * Optimized for large datasets using Map and batch operations + */ +export function groupDiagnosticsByServer( + diagnostics: Array<{ diagnostic: Diagnostic; serverID: string }>, +): Record { + if (!Array.isArray(diagnostics) || diagnostics.length === 0) { + return {} + } + + const grouped = new Map() + + // Batch process for better performance + for (const item of diagnostics) { + if (!validateDiagnosticItem(item)) continue + + const serverID = sanitizeServerID(item.serverID || "Unknown") + const existing = grouped.get(serverID) + + if (existing) { + existing.push(item.diagnostic) + } else { + grouped.set(serverID, [item.diagnostic]) + } + } + + return Object.fromEntries(grouped) +} + +/** + * Sanitize diagnostic message for safe display + */ +export function sanitizeDiagnosticMessage(message: string): string { + if (typeof message !== "string") return "" + + return message + .replace(/[<>]/g, "") // Remove HTML tags + .replace(/javascript:/gi, "") // Remove javascript protocol + .replace(/data:/gi, "") // Remove data protocol + .replace(/vbscript:/gi, "") // Remove vbscript protocol + .replace(/on\w+=/gi, "") // Remove event handlers + .trim() + .substring(0, 1000) // Limit length +} + +/** + * Format a single diagnostic with server information + */ +export function formatDiagnosticWithServer(diagnostic: Diagnostic, serverID: string): string { + if (!validateDiagnostic(diagnostic)) { + return "ERROR [Invalid diagnostic]" + } + + const severityMap: Record = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "HINT", + } + + const severity = severityMap[diagnostic.severity || 1] + const line = Math.max(0, diagnostic.range.start.line + 1) + const col = Math.max(0, diagnostic.range.start.character + 1) + const sanitizedMessage = sanitizeDiagnosticMessage(diagnostic.message) + + return `${severity} [${line}:${col}] ${sanitizedMessage}` +} + +/** + * Format diagnostics with server grouping and separators + */ +export function formatDiagnosticsWithServers(diagnostics: Array<{ diagnostic: Diagnostic; serverID: string }>): string { + if (!Array.isArray(diagnostics)) return "" + + const validDiagnostics = diagnostics.filter(validateDiagnosticItem) + if (validDiagnostics.length === 0) return "" + + const grouped = groupDiagnosticsByServer(validDiagnostics) + const serverNames = Object.keys(grouped) + + if (serverNames.length === 1) { + // Only one server, no need for separators + return grouped[serverNames[0]].map((d) => formatDiagnosticWithServer(d, serverNames[0])).join("\n") + } + + // Multiple servers, add separators + const result: string[] = [] + for (const [serverID, serverDiagnostics] of Object.entries(grouped)) { + if (result.length > 0) { + result.push("") // Add empty line for spacing + } + result.push(`--- ${sanitizeServerID(serverID).toUpperCase()} ---`) + result.push(...serverDiagnostics.map((d) => formatDiagnosticWithServer(d, serverID))) + } + return result.join("\n") +} + +/** + * Filter diagnostics by severity level + * Optimized with early validation + */ +export function filterDiagnosticsBySeverity( + diagnostics: Array<{ diagnostic: Diagnostic; serverID: string }>, + severity: number, +): Array<{ diagnostic: Diagnostic; serverID: string }> { + if (!Array.isArray(diagnostics) || typeof severity !== "number") { + return [] + } + + return diagnostics.filter((item) => validateDiagnosticItem(item) && item.diagnostic.severity === severity) +} + +/** + * Validate diagnostic object structure + */ +export function validateDiagnostic(diagnostic: any): diagnostic is Diagnostic { + return !!( + diagnostic != null && + typeof diagnostic === "object" && + typeof diagnostic.message === "string" && + diagnostic.range && + typeof diagnostic.range.start === "object" && + typeof diagnostic.range.start.line === "number" && + typeof diagnostic.range.start.character === "number" + ) +} + +/** + * Get error diagnostics (severity 1) from multi-server format + */ +export function getErrorDiagnostics( + diagnostics: Array<{ diagnostic: Diagnostic; serverID: string }>, +): Array<{ diagnostic: Diagnostic; serverID: string }> { + return filterDiagnosticsBySeverity(diagnostics, 1) +} + +/** + * Validate multi-server diagnostic item structure + */ +export function validateDiagnosticItem(item: any): item is { diagnostic: Diagnostic; serverID: string } { + return ( + item != null && typeof item === "object" && validateDiagnostic(item.diagnostic) && typeof item.serverID === "string" + ) +}