export { truncate } from "@oh-my-pi/pi-utils"; import * as fs from "node:fs/promises"; import path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { type Theme, theme } from "../modes/theme/theme"; import { formatGroupedFiles } from "../tools/grouped-file-output"; import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils"; import type { CodeAction, Command, Diagnostic, DiagnosticSeverity, DocumentSymbol, Location, SymbolInformation, SymbolKind, TextEdit, WorkspaceEdit, } from "./types"; export { detectLanguageId } from "../utils/lang-from-path"; // ============================================================================= // URI Handling (Cross-Platform) // ============================================================================= /** * Convert a file path to a file:// URI. * Handles Windows drive letters correctly. */ export function fileToUri(filePath: string): string { const resolved = path.resolve(filePath); if (process.platform === "win32") { // Windows: file:///C:/path/to/file return `file:///${resolved.replace(/\\/g, "/")}`; } // Unix: file:///path/to/file return `file://${resolved}`; } /** * Convert a file:// URI to a file path. * Handles Windows drive letters correctly. */ export function uriToFile(uri: string): string { if (!uri.startsWith("file://")) { return uri; } let filePath = decodeURIComponent(uri.slice(7)); // Windows: file:///C:/path → C:/path (strip leading slash before drive letter) if (process.platform === "win32" && filePath.startsWith("/") && /^[A-Za-z]:/.test(filePath.slice(1))) { filePath = filePath.slice(1); } return filePath; } // ============================================================================= // Diagnostic Formatting // ============================================================================= const SEVERITY_NAMES: Record = { 1: "error", 2: "warning", 3: "info", 4: "hint", }; /** * Convert diagnostic severity number to string name. */ export function severityToString(severity?: DiagnosticSeverity): string { return SEVERITY_NAMES[severity ?? 1] ?? "unknown"; } /** * Sort diagnostics by severity, then by location and message. */ export function sortDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] { return diagnostics.sort((a, b) => { const aSeverity = a.severity ?? 1; const bSeverity = b.severity ?? 1; if (aSeverity !== bSeverity) return aSeverity - bSeverity; const aLine = a.range.start.line; const bLine = b.range.start.line; if (aLine !== bLine) return aLine - bLine; const aCol = a.range.start.character; const bCol = b.range.start.character; if (aCol !== bCol) return aCol - bCol; return a.message.localeCompare(b.message); }); } /** * Get icon for diagnostic severity. */ export function severityToIcon(severity?: DiagnosticSeverity): string { const currentTheme = theme as Theme | undefined; const fallback = currentTheme?.format?.bullet ?? "*"; const status = currentTheme?.status; switch (severity ?? 1) { case 1: return status?.error ?? fallback; case 2: return status?.warning ?? fallback; case 3: return status?.info ?? fallback; case 4: return currentTheme?.format?.bullet ?? fallback; default: return status?.error ?? fallback; } } /** * Strip noise from diagnostic messages (clippy URLs, override hints). */ function stripDiagnosticNoise(message: string): string { return message .split("\n") .filter(line => { const trimmed = line.trim(); // Skip "for further information visit " lines if (trimmed.startsWith("for further information visit")) return false; // Skip bare URLs if (/^https?:\/\//.test(trimmed)) return false; return true; }) .join("\n") .trim(); } /** * Format a diagnostic as a human-readable string. */ export function formatDiagnostic(diagnostic: Diagnostic, filePath: string): string { const severity = severityToString(diagnostic.severity); const line = diagnostic.range.start.line + 1; const col = diagnostic.range.start.character + 1; const source = diagnostic.source ? `[${diagnostic.source}] ` : ""; const code = diagnostic.code ? ` (${diagnostic.code})` : ""; const message = stripDiagnosticNoise(diagnostic.message); return `${filePath}:${line}:${col} [${severity}] ${source}${message}${code}`; } // Regex: split on the first `:digits:digits` boundary to separate path from the rest const DIAG_PATH_RE = /^(.+?):(\d+:\d+\s+.*)$/; /** * Reformat pre-formatted diagnostic messages into grep-style directory/file groups. * Input: ["path:line:col [sev] msg", ...] * Output: "# dir/\n## file.ts\n line:col [sev] msg" * * Messages that don't match the expected format are appended ungrouped at the end. */ export function formatGroupedDiagnosticMessages(messages: string[]): string { const diagnosticsByFile = new Map(); const fileOrder: string[] = []; const ungrouped: string[] = []; for (const msg of messages) { const match = DIAG_PATH_RE.exec(msg); if (!match) { ungrouped.push(msg); continue; } const [, rawFilePath, rest] = match; const filePath = rawFilePath.replace(/\\/g, "/"); if (!diagnosticsByFile.has(filePath)) { diagnosticsByFile.set(filePath, []); fileOrder.push(filePath); } diagnosticsByFile.get(filePath)?.push(rest); } if (diagnosticsByFile.size === 0) { return ungrouped.join("\n"); } const grouped = formatGroupedFiles(fileOrder, filePath => ({ modelLines: (diagnosticsByFile.get(filePath) ?? []).map(diagnostic => ` ${diagnostic}`), })); const lines: string[] = grouped.model; if (ungrouped.length > 0) { lines.push(""); for (const msg of ungrouped) { lines.push(msg); } } return lines.join("\n"); } /** * Format diagnostics grouped by severity. */ export function formatDiagnosticsSummary(diagnostics: Diagnostic[]): string { const counts = { error: 0, warning: 0, info: 0, hint: 0 }; for (const d of diagnostics) { const sev = severityToString(d.severity); if (sev in counts) { counts[sev as keyof typeof counts]++; } } const parts: string[] = []; if (counts.error > 0) parts.push(`${counts.error} error(s)`); if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`); if (counts.info > 0) parts.push(`${counts.info} info(s)`); if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`); return parts.length > 0 ? parts.join(", ") : "no issues"; } // ============================================================================= // Location Formatting // ============================================================================= /** * Format a location as file:line:col relative to cwd. */ export function formatLocation(location: Location, cwd: string): string { const file = formatPathRelativeToCwd(uriToFile(location.uri), cwd); const line = location.range.start.line + 1; const col = location.range.start.character + 1; return `${file}:${line}:${col}`; } /** * Format a position as line:col. */ export function formatPosition(line: number, col: number): string { return `${line}:${col}`; } // ============================================================================= // WorkspaceEdit Formatting // ============================================================================= /** * Format a workspace edit as a summary of changes. */ export function formatWorkspaceEdit(edit: WorkspaceEdit, cwd: string): string[] { const results: string[] = []; // Handle changes map (legacy format) if (edit.changes) { for (const [uri, textEdits] of Object.entries(edit.changes)) { const file = formatPathRelativeToCwd(uriToFile(uri), cwd); results.push(`${file}: ${textEdits.length} edit${textEdits.length > 1 ? "s" : ""}`); } } // Handle documentChanges array (modern format) if (edit.documentChanges) { for (const change of edit.documentChanges) { if ("edits" in change && change.textDocument) { const file = formatPathRelativeToCwd(uriToFile(change.textDocument.uri), cwd); results.push(`${file}: ${change.edits.length} edit${change.edits.length > 1 ? "s" : ""}`); } else if ("kind" in change) { switch (change.kind) { case "create": results.push(`CREATE: ${formatPathRelativeToCwd(uriToFile(change.uri), cwd)}`); break; case "rename": results.push( `RENAME: ${formatPathRelativeToCwd(uriToFile(change.oldUri), cwd)} ${theme.nav.cursor} ${formatPathRelativeToCwd(uriToFile(change.newUri), cwd)}`, ); break; case "delete": results.push(`DELETE: ${formatPathRelativeToCwd(uriToFile(change.uri), cwd)}`); break; } } } } return results; } /** * Format a text edit as a preview. */ export function formatTextEdit(edit: TextEdit, maxLength = 50): string { const range = `${edit.range.start.line + 1}:${edit.range.start.character + 1}`; const preview = edit.newText.length > maxLength ? `${edit.newText.slice(0, maxLength).replace(/\n/g, "\\n")}…` : edit.newText.replace(/\n/g, "\\n"); return `line ${range} ${theme.nav.cursor} "${preview}"`; } // ============================================================================= // Symbol Formatting // ============================================================================= function getSymbolKindIcons(): Record { const currentTheme = theme as Theme | undefined; const fallback = currentTheme?.format?.bullet ?? "*"; const dash = currentTheme?.format?.dash ?? fallback; const icon = currentTheme?.icon; const file = icon?.file ?? fallback; const folder = icon?.folder ?? fallback; const pkg = icon?.package ?? folder; const model = icon?.model ?? fallback; const func = icon?.auto ?? dash; return { 1: file, // File 2: folder, // Module 3: folder, // Namespace 4: pkg, // Package 5: model, // Class 6: func, // Method 7: fallback, // Property 8: fallback, // Field 9: func, // Constructor 10: fallback, // Enum 11: model, // Interface 12: func, // Function 13: fallback, // Variable 14: fallback, // Constant 15: fallback, // String 16: fallback, // Number 17: fallback, // Boolean 18: fallback, // Array 19: fallback, // Object 20: fallback, // Key 21: fallback, // Null 22: fallback, // EnumMember 23: folder, // Struct 24: fallback, // Event 25: fallback, // Operator 26: fallback, // TypeParameter }; } /** * Get icon for symbol kind. */ export function symbolKindToIcon(kind: SymbolKind): string { const currentTheme = theme as Theme | undefined; const bullet = currentTheme?.format?.bullet ?? "*"; return getSymbolKindIcons()[kind] ?? bullet; } /** * Get name for symbol kind. */ export function symbolKindToName(kind: SymbolKind): string { const names: Record = { 1: "File", 2: "Module", 3: "Namespace", 4: "Package", 5: "Class", 6: "Method", 7: "Property", 8: "Field", 9: "Constructor", 10: "Enum", 11: "Interface", 12: "Function", 13: "Variable", 14: "Constant", 15: "String", 16: "Number", 17: "Boolean", 18: "Array", 19: "Object", 20: "Key", 21: "Null", 22: "EnumMember", 23: "Struct", 24: "Event", 25: "Operator", 26: "TypeParameter", }; return names[kind] ?? "Unknown"; } /** * Format a document symbol with optional hierarchy. */ export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string[] { const prefix = " ".repeat(indent); const icon = symbolKindToIcon(symbol.kind); const line = symbol.range.start.line + 1; const detail = symbol.detail ? ` ${symbol.detail}` : ""; const results = [`${prefix}${icon} ${symbol.name}${detail} @ line ${line}`]; if (symbol.children) { for (const child of symbol.children) { results.push(...formatDocumentSymbol(child, indent + 1)); } } return results; } /** * Format a symbol information (flat format). */ export function formatSymbolInformation(symbol: SymbolInformation, cwd: string): string { const icon = symbolKindToIcon(symbol.kind); const location = formatLocation(symbol.location, cwd); const container = symbol.containerName ? ` (${symbol.containerName})` : ""; return `${icon} ${symbol.name}${container} @ ${location}`; } export function filterWorkspaceSymbols(symbols: SymbolInformation[], query: string): SymbolInformation[] { const needle = query.trim().toLowerCase(); if (!needle) return symbols; return symbols.filter(symbol => { const fields = [symbol.name, symbol.containerName ?? "", uriToFile(symbol.location.uri)]; return fields.some(field => field.toLowerCase().includes(needle)); }); } export function dedupeWorkspaceSymbols(symbols: SymbolInformation[]): SymbolInformation[] { const seen = new Set(); const unique: SymbolInformation[] = []; for (const symbol of symbols) { const key = [ symbol.name, symbol.containerName ?? "", symbol.kind, symbol.location.uri, symbol.location.range.start.line, symbol.location.range.start.character, ].join(":"); if (seen.has(key)) continue; seen.add(key); unique.push(symbol); } return unique; } export function formatCodeAction(action: CodeAction | Command, index: number): string { const kind = "kind" in action && action.kind ? action.kind : "action"; const preferred = "isPreferred" in action && action.isPreferred ? " (preferred)" : ""; const disabled = "disabled" in action && action.disabled ? ` (disabled: ${action.disabled.reason})` : ""; return `${index}: [${kind}] ${action.title}${preferred}${disabled}`; } export interface CodeActionApplyDependencies { resolveCodeAction?: (action: CodeAction) => Promise; applyWorkspaceEdit: (edit: WorkspaceEdit) => Promise; executeCommand: (command: Command) => Promise; } export interface AppliedCodeActionResult { title: string; edits: string[]; executedCommands: string[]; } function isCommandItem(action: CodeAction | Command): action is Command { return typeof action.command === "string"; } export async function applyCodeAction( action: CodeAction | Command, dependencies: CodeActionApplyDependencies, ): Promise { if (isCommandItem(action)) { await dependencies.executeCommand(action); return { title: action.title, edits: [], executedCommands: [action.command] }; } let resolvedAction = action; if (!resolvedAction.edit && dependencies.resolveCodeAction) { try { resolvedAction = await dependencies.resolveCodeAction(resolvedAction); } catch { // Resolve is optional; continue with unresolved action. } } const edits = resolvedAction.edit ? await dependencies.applyWorkspaceEdit(resolvedAction.edit) : []; const executedCommands: string[] = []; if (resolvedAction.command) { await dependencies.executeCommand(resolvedAction.command); executedCommands.push(resolvedAction.command.command); } if (edits.length === 0 && executedCommands.length === 0) { return null; } return { title: resolvedAction.title, edits, executedCommands }; } const GLOB_PATTERN_CHARS = /[*?[{]/; export function hasGlobPattern(value: string): boolean { return GLOB_PATTERN_CHARS.test(value); } export async function collectGlobMatches( pattern: string, cwd: string, maxMatches: number, ): Promise<{ matches: string[]; truncated: boolean }> { const normalizedLimit = Number.isFinite(maxMatches) ? Math.max(1, Math.trunc(maxMatches)) : 1; const matches: string[] = []; for await (const match of new Bun.Glob(pattern).scan({ cwd })) { if (matches.length >= normalizedLimit) { return { matches, truncated: true }; } matches.push(match); } return { matches, truncated: false }; } export async function resolveDiagnosticTargets( file: string, cwd: string, maxMatches: number, ): Promise<{ matches: string[]; truncated: boolean }> { if (!hasGlobPattern(file)) { return { matches: [file], truncated: false }; } const resolved = resolveToCwd(file, cwd); try { const stat = await fs.stat(resolved); if (stat.isFile()) { return { matches: [file], truncated: false }; } } catch (error) { if (!isEnoent(error)) { throw error; } } return collectGlobMatches(file, cwd, maxMatches); } // ============================================================================= // Hover Content Extraction // ============================================================================= /** * Extract plain text from hover contents. */ export function extractHoverText( contents: string | { kind: string; value: string } | { language: string; value: string } | unknown[], ): string { if (typeof contents === "string") { return contents; } if (Array.isArray(contents)) { return contents.map(c => extractHoverText(c as string | { kind: string; value: string })).join("\n\n"); } if (typeof contents === "object" && contents !== null) { if ("value" in contents && typeof contents.value === "string") { return contents.value; } } return String(contents); } // ============================================================================= // General Utilities function firstNonWhitespaceColumn(lineText: string): number { const match = lineText.match(/\S/); return match ? (match.index ?? 0) : 0; } const BARE_IDENTIFIER_RE = /^[A-Za-z_][\w]*$/; const IDENTIFIER_CHAR_RE = /[A-Za-z0-9_$]/; function findSymbolMatchIndexes(lineText: string, symbol: string, caseInsensitive = false): number[] { if (symbol.length === 0) return []; const haystack = caseInsensitive ? lineText.toLowerCase() : lineText; const needle = caseInsensitive ? symbol.toLowerCase() : symbol; const requireWordBoundary = BARE_IDENTIFIER_RE.test(symbol); const indexes: number[] = []; let fromIndex = 0; while (fromIndex <= haystack.length - needle.length) { const matchIndex = haystack.indexOf(needle, fromIndex); if (matchIndex === -1) break; if (requireWordBoundary) { const before = matchIndex > 0 ? haystack[matchIndex - 1] : ""; const afterIdx = matchIndex + needle.length; const after = afterIdx < haystack.length ? haystack[afterIdx] : ""; if (IDENTIFIER_CHAR_RE.test(before) || IDENTIFIER_CHAR_RE.test(after)) { fromIndex = matchIndex + 1; continue; } } indexes.push(matchIndex); fromIndex = matchIndex + needle.length; } return indexes; } /** * Parses a symbol spec of the form `name` or `name#N` where N is the 1-indexed * occurrence on the target line. Returns `name` and `occurrence` (default 1). * * Greedy match on `.+` so `#name#2` parses as symbol=`#name` (TS private field) * with occurrence 2. Specs without a trailing `#\d+` are treated as literal. */ function parseSymbolSpec(spec: string): { symbol: string; occurrence: number } { const match = spec.match(/^(.+)#(\d+)$/); if (!match) return { symbol: spec, occurrence: 1 }; const occurrence = Math.max(1, Number.parseInt(match[2], 10)); return { symbol: match[1], occurrence }; } export async function resolveSymbolColumn(filePath: string, line: number, symbolSpec?: string): Promise { const lineNumber = Math.max(1, line); try { const fileText = await Bun.file(filePath).text(); const lines = fileText.split("\n"); const targetLine = lines[lineNumber - 1] ?? ""; if (!symbolSpec) { return firstNonWhitespaceColumn(targetLine); } const { symbol, occurrence } = parseSymbolSpec(symbolSpec); const exactIndexes = findSymbolMatchIndexes(targetLine, symbol); const fallbackIndexes = exactIndexes.length > 0 ? exactIndexes : findSymbolMatchIndexes(targetLine, symbol, true); if (fallbackIndexes.length === 0) { throw new Error(`Symbol "${symbol}" not found on line ${lineNumber}`); } if (occurrence > fallbackIndexes.length) { throw new Error( `Symbol "${symbol}" occurrence ${occurrence} is out of bounds on line ${lineNumber} (found ${fallbackIndexes.length})`, ); } return fallbackIndexes[occurrence - 1]; } catch (error) { if (isEnoent(error)) { throw new Error(`File not found: ${filePath}`); } throw error; } } export async function readLocationContext(filePath: string, line: number, contextLines = 1): Promise { const targetLine = Math.max(1, line); const surrounding = Math.max(0, contextLines); try { const fileText = await Bun.file(filePath).text(); const lines = fileText.split("\n"); if (lines.length === 0) return []; const startLine = Math.max(1, targetLine - surrounding); const endLine = Math.min(lines.length, targetLine + surrounding); const context: string[] = []; for (let currentLine = startLine; currentLine <= endLine; currentLine++) { const content = lines[currentLine - 1] ?? ""; context.push(`${currentLine}: ${content}`); } return context; } catch (error) { if (isEnoent(error)) { return []; } throw error; } }