Download src/tools/LSPTool/LSPTool.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 25.7 kB
-
https://huggingface.co/chenbhao/codev/resolve/e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/LSPTool/LSPTool.ts
- Command line
-
hf download hf://chenbhao/codev@e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/LSPTool/LSPTool.ts
-
curl -L -o LSPTool.ts https://huggingface.co/chenbhao/codev/resolve/e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/LSPTool/LSPTool.ts
25.7 kB
| import { open } from 'fs/promises' | |
| import * as path from 'path' | |
| import { pathToFileURL } from 'url' | |
| import type { | |
| CallHierarchyIncomingCall, | |
| CallHierarchyItem, | |
| CallHierarchyOutgoingCall, | |
| DocumentSymbol, | |
| Hover, | |
| Location, | |
| LocationLink, | |
| SymbolInformation, | |
| } from 'vscode-languageserver-types' | |
| import { z } from 'zod/v4' | |
| import { | |
| getInitializationStatus, | |
| getLspServerManager, | |
| isLspConnected, | |
| waitForInitialization, | |
| } from '../../services/lsp/manager.js' | |
| import type { ValidationResult } from '../../Tool.js' | |
| import { buildTool, type ToolDef } from '../../Tool.js' | |
| import { uniq } from '../../utils/array.js' | |
| import { getCwd } from '../../utils/cwd.js' | |
| import { logForDebugging } from '../../utils/debug.js' | |
| import { isENOENT, toError } from '../../utils/errors.js' | |
| import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js' | |
| import { getFsImplementation } from '../../utils/fsOperations.js' | |
| import { lazySchema } from '../../utils/lazySchema.js' | |
| import { logError } from '../../utils/log.js' | |
| import { expandPath } from '../../utils/path.js' | |
| import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js' | |
| import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' | |
| import { | |
| formatDocumentSymbolResult, | |
| formatFindReferencesResult, | |
| formatGoToDefinitionResult, | |
| formatHoverResult, | |
| formatIncomingCallsResult, | |
| formatOutgoingCallsResult, | |
| formatPrepareCallHierarchyResult, | |
| formatWorkspaceSymbolResult, | |
| } from './formatters.js' | |
| import { DESCRIPTION, LSP_TOOL_NAME } from './prompt.js' | |
| import { lspToolInputSchema } from './schemas.js' | |
| import { | |
| renderToolResultMessage, | |
| renderToolUseErrorMessage, | |
| renderToolUseMessage, | |
| userFacingName, | |
| } from './UI.js' | |
| const MAX_LSP_FILE_SIZE_BYTES = 10_000_000 | |
| /** | |
| * Tool-compatible input schema (regular ZodObject instead of discriminated union) | |
| * We validate against the discriminated union in validateInput for better error messages | |
| */ | |
| const inputSchema = lazySchema(() => | |
| z.strictObject({ | |
| operation: z | |
| .enum([ | |
| 'goToDefinition', | |
| 'findReferences', | |
| 'hover', | |
| 'documentSymbol', | |
| 'workspaceSymbol', | |
| 'goToImplementation', | |
| 'prepareCallHierarchy', | |
| 'incomingCalls', | |
| 'outgoingCalls', | |
| ]) | |
| .describe('The LSP operation to perform'), | |
| filePath: z.string().describe('The absolute or relative path to the file'), | |
| line: z | |
| .number() | |
| .int() | |
| .positive() | |
| .describe('The line number (1-based, as shown in editors)'), | |
| character: z | |
| .number() | |
| .int() | |
| .positive() | |
| .describe('The character offset (1-based, as shown in editors)'), | |
| }), | |
| ) | |
| type InputSchema = ReturnType<typeof inputSchema> | |
| const outputSchema = lazySchema(() => | |
| z.object({ | |
| operation: z | |
| .enum([ | |
| 'goToDefinition', | |
| 'findReferences', | |
| 'hover', | |
| 'documentSymbol', | |
| 'workspaceSymbol', | |
| 'goToImplementation', | |
| 'prepareCallHierarchy', | |
| 'incomingCalls', | |
| 'outgoingCalls', | |
| ]) | |
| .describe('The LSP operation that was performed'), | |
| result: z.string().describe('The formatted result of the LSP operation'), | |
| filePath: z | |
| .string() | |
| .describe('The file path the operation was performed on'), | |
| resultCount: z | |
| .number() | |
| .int() | |
| .nonnegative() | |
| .optional() | |
| .describe('Number of results (definitions, references, symbols)'), | |
| fileCount: z | |
| .number() | |
| .int() | |
| .nonnegative() | |
| .optional() | |
| .describe('Number of files containing results'), | |
| }), | |
| ) | |
| type OutputSchema = ReturnType<typeof outputSchema> | |
| export type Output = z.infer<OutputSchema> | |
| export type Input = z.infer<InputSchema> | |
| export const LSPTool = buildTool({ | |
| name: LSP_TOOL_NAME, | |
| searchHint: 'code intelligence (definitions, references, symbols, hover)', | |
| maxResultSizeChars: 100_000, | |
| isLsp: true, | |
| async description() { | |
| return DESCRIPTION | |
| }, | |
| userFacingName, | |
| shouldDefer: true, | |
| isEnabled() { | |
| return isLspConnected() | |
| }, | |
| get inputSchema(): InputSchema { | |
| return inputSchema() | |
| }, | |
| get outputSchema(): OutputSchema { | |
| return outputSchema() | |
| }, | |
| isConcurrencySafe() { | |
| return true | |
| }, | |
| isReadOnly() { | |
| return true | |
| }, | |
| getPath({ filePath }): string { | |
| return expandPath(filePath) | |
| }, | |
| async validateInput(input: Input): Promise<ValidationResult> { | |
| // First validate against the discriminated union for better type safety | |
| const parseResult = lspToolInputSchema().safeParse(input) | |
| if (!parseResult.success) { | |
| return { | |
| result: false, | |
| message: `Invalid input: ${parseResult.error.message}`, | |
| errorCode: 3, | |
| } | |
| } | |
| // Validate file exists and is a regular file | |
| const fs = getFsImplementation() | |
| const absolutePath = expandPath(input.filePath) | |
| // SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks. | |
| if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) { | |
| return { result: true } | |
| } | |
| let stats | |
| try { | |
| stats = await fs.stat(absolutePath) | |
| } catch (error) { | |
| if (isENOENT(error)) { | |
| return { | |
| result: false, | |
| message: `File does not exist: ${input.filePath}`, | |
| errorCode: 1, | |
| } | |
| } | |
| const err = toError(error) | |
| // Log filesystem access errors for tracking | |
| logError( | |
| new Error( | |
| `Failed to access file stats for LSP operation on ${input.filePath}: ${err.message}`, | |
| ), | |
| ) | |
| return { | |
| result: false, | |
| message: `Cannot access file: ${input.filePath}. ${err.message}`, | |
| errorCode: 4, | |
| } | |
| } | |
| if (!stats.isFile()) { | |
| return { | |
| result: false, | |
| message: `Path is not a file: ${input.filePath}`, | |
| errorCode: 2, | |
| } | |
| } | |
| return { result: true } | |
| }, | |
| async checkPermissions(input, context): Promise<PermissionDecision> { | |
| const appState = context.getAppState() | |
| return checkReadPermissionForTool( | |
| LSPTool, | |
| input, | |
| appState.toolPermissionContext, | |
| ) | |
| }, | |
| async prompt() { | |
| return DESCRIPTION | |
| }, | |
| renderToolUseMessage, | |
| renderToolUseErrorMessage, | |
| renderToolResultMessage, | |
| async call(input: Input, _context) { | |
| const absolutePath = expandPath(input.filePath) | |
| const cwd = getCwd() | |
| // Wait for initialization if it's still pending | |
| // This prevents returning "no server available" before init completes | |
| const status = getInitializationStatus() | |
| if (status.status === 'pending') { | |
| await waitForInitialization() | |
| } | |
| // Get the LSP server manager | |
| const manager = getLspServerManager() | |
| if (!manager) { | |
| // Log this system-level failure for tracking | |
| logError( | |
| new Error('LSP server manager not initialized when tool was called'), | |
| ) | |
| const output: Output = { | |
| operation: input.operation, | |
| result: | |
| 'LSP server manager not initialized. This may indicate a startup issue.', | |
| filePath: input.filePath, | |
| } | |
| return { | |
| data: output, | |
| } | |
| } | |
| // Map operation to LSP method and prepare params | |
| const { method, params } = getMethodAndParams(input, absolutePath) | |
| try { | |
| // Ensure file is open in LSP server before making requests | |
| // Most LSP servers require textDocument/didOpen before operations | |
| // Only read the file if it's not already open to avoid unnecessary I/O | |
| if (!manager.isFileOpen(absolutePath)) { | |
| const handle = await open(absolutePath, 'r') | |
| try { | |
| const stats = await handle.stat() | |
| if (stats.size > MAX_LSP_FILE_SIZE_BYTES) { | |
| const output: Output = { | |
| operation: input.operation, | |
| result: `File too large for LSP analysis (${Math.ceil(stats.size / 1_000_000)}MB exceeds 10MB limit)`, | |
| filePath: input.filePath, | |
| } | |
| return { data: output } | |
| } | |
| const fileContent = await handle.readFile({ encoding: 'utf-8' }) | |
| await manager.openFile(absolutePath, fileContent) | |
| } finally { | |
| await handle.close() | |
| } | |
| } | |
| // Send request to LSP server | |
| let result = await manager.sendRequest(absolutePath, method, params) | |
| if (result === undefined) { | |
| // Log for diagnostic purposes - helps track usage patterns and potential bugs | |
| logForDebugging( | |
| `No LSP server available for file type ${path.extname(absolutePath)} for operation ${input.operation} on file ${input.filePath}`, | |
| ) | |
| const output: Output = { | |
| operation: input.operation, | |
| result: `No LSP server available for file type: ${path.extname(absolutePath)}`, | |
| filePath: input.filePath, | |
| } | |
| return { | |
| data: output, | |
| } | |
| } | |
| // For incomingCalls and outgoingCalls, we need a two-step process: | |
| // 1. First get CallHierarchyItem(s) from prepareCallHierarchy | |
| // 2. Then request the actual calls using that item | |
| if ( | |
| input.operation === 'incomingCalls' || | |
| input.operation === 'outgoingCalls' | |
| ) { | |
| const callItems = result as CallHierarchyItem[] | |
| if (!callItems || callItems.length === 0) { | |
| const output: Output = { | |
| operation: input.operation, | |
| result: 'No call hierarchy item found at this position', | |
| filePath: input.filePath, | |
| resultCount: 0, | |
| fileCount: 0, | |
| } | |
| return { data: output } | |
| } | |
| // Use the first call hierarchy item to request calls | |
| const callMethod = | |
| input.operation === 'incomingCalls' | |
| ? 'callHierarchy/incomingCalls' | |
| : 'callHierarchy/outgoingCalls' | |
| result = await manager.sendRequest(absolutePath, callMethod, { | |
| item: callItems[0], | |
| }) | |
| if (result === undefined) { | |
| logForDebugging( | |
| `LSP server returned undefined for ${callMethod} on ${input.filePath}`, | |
| ) | |
| // Continue to formatter which will handle empty/null gracefully | |
| } | |
| } | |
| // Filter out gitignored files from location-based results | |
| if ( | |
| result && | |
| Array.isArray(result) && | |
| (input.operation === 'findReferences' || | |
| input.operation === 'goToDefinition' || | |
| input.operation === 'goToImplementation' || | |
| input.operation === 'workspaceSymbol') | |
| ) { | |
| if (input.operation === 'workspaceSymbol') { | |
| // SymbolInformation has location.uri — filter by extracting locations | |
| const symbols = result as SymbolInformation[] | |
| const locations = symbols | |
| .filter(s => s?.location?.uri) | |
| .map(s => s.location) | |
| const filteredLocations = await filterGitIgnoredLocations( | |
| locations, | |
| cwd, | |
| ) | |
| const filteredUris = new Set(filteredLocations.map(l => l.uri)) | |
| result = symbols.filter( | |
| s => !s?.location?.uri || filteredUris.has(s.location.uri), | |
| ) | |
| } else { | |
| // Location[] or (Location | LocationLink)[] | |
| const locations = (result as (Location | LocationLink)[]).map( | |
| toLocation, | |
| ) | |
| const filteredLocations = await filterGitIgnoredLocations( | |
| locations, | |
| cwd, | |
| ) | |
| const filteredUris = new Set(filteredLocations.map(l => l.uri)) | |
| result = (result as (Location | LocationLink)[]).filter(item => { | |
| const loc = toLocation(item) | |
| return !loc.uri || filteredUris.has(loc.uri) | |
| }) | |
| } | |
| } | |
| // Format the result based on operation type | |
| const { formatted, resultCount, fileCount } = formatResult( | |
| input.operation, | |
| result, | |
| cwd, | |
| ) | |
| const output: Output = { | |
| operation: input.operation, | |
| result: formatted, | |
| filePath: input.filePath, | |
| resultCount, | |
| fileCount, | |
| } | |
| return { | |
| data: output, | |
| } | |
| } catch (error) { | |
| const err = toError(error) | |
| const errorMessage = err.message | |
| // Log error for tracking | |
| logError( | |
| new Error( | |
| `LSP tool request failed for ${input.operation} on ${input.filePath}: ${errorMessage}`, | |
| ), | |
| ) | |
| const output: Output = { | |
| operation: input.operation, | |
| result: `Error performing ${input.operation}: ${errorMessage}`, | |
| filePath: input.filePath, | |
| } | |
| return { | |
| data: output, | |
| } | |
| } | |
| }, | |
| mapToolResultToToolResultBlockParam(output, toolUseID) { | |
| return { | |
| tool_use_id: toolUseID, | |
| type: 'tool_result', | |
| content: output.result, | |
| } | |
| }, | |
| } satisfies ToolDef<InputSchema, Output>) | |
| /** | |
| * Maps LSPTool operation to LSP method and params | |
| */ | |
| function getMethodAndParams( | |
| input: Input, | |
| absolutePath: string, | |
| ): { method: string; params: unknown } { | |
| const uri = pathToFileURL(absolutePath).href | |
| // Convert from 1-based (user-friendly) to 0-based (LSP protocol) | |
| const position = { | |
| line: input.line - 1, | |
| character: input.character - 1, | |
| } | |
| switch (input.operation) { | |
| case 'goToDefinition': | |
| return { | |
| method: 'textDocument/definition', | |
| params: { | |
| textDocument: { uri }, | |
| position, | |
| }, | |
| } | |
| case 'findReferences': | |
| return { | |
| method: 'textDocument/references', | |
| params: { | |
| textDocument: { uri }, | |
| position, | |
| context: { includeDeclaration: true }, | |
| }, | |
| } | |
| case 'hover': | |
| return { | |
| method: 'textDocument/hover', | |
| params: { | |
| textDocument: { uri }, | |
| position, | |
| }, | |
| } | |
| case 'documentSymbol': | |
| return { | |
| method: 'textDocument/documentSymbol', | |
| params: { | |
| textDocument: { uri }, | |
| }, | |
| } | |
| case 'workspaceSymbol': | |
| return { | |
| method: 'workspace/symbol', | |
| params: { | |
| query: '', // Empty query returns all symbols | |
| }, | |
| } | |
| case 'goToImplementation': | |
| return { | |
| method: 'textDocument/implementation', | |
| params: { | |
| textDocument: { uri }, | |
| position, | |
| }, | |
| } | |
| case 'prepareCallHierarchy': | |
| return { | |
| method: 'textDocument/prepareCallHierarchy', | |
| params: { | |
| textDocument: { uri }, | |
| position, | |
| }, | |
| } | |
| case 'incomingCalls': | |
| // For incoming/outgoing calls, we first need to prepare the call hierarchy | |
| // The LSP server will return CallHierarchyItem(s) that we pass to the calls request | |
| return { | |
| method: 'textDocument/prepareCallHierarchy', | |
| params: { | |
| textDocument: { uri }, | |
| position, | |
| }, | |
| } | |
| case 'outgoingCalls': | |
| return { | |
| method: 'textDocument/prepareCallHierarchy', | |
| params: { | |
| textDocument: { uri }, | |
| position, | |
| }, | |
| } | |
| } | |
| } | |
| /** | |
| * Counts the total number of symbols including nested children | |
| */ | |
| function countSymbols(symbols: DocumentSymbol[]): number { | |
| let count = symbols.length | |
| for (const symbol of symbols) { | |
| if (symbol.children && symbol.children.length > 0) { | |
| count += countSymbols(symbol.children) | |
| } | |
| } | |
| return count | |
| } | |
| /** | |
| * Counts unique files from an array of locations | |
| */ | |
| function countUniqueFiles(locations: Location[]): number { | |
| return new Set(locations.map(loc => loc.uri)).size | |
| } | |
| /** | |
| * Extracts a file path from a file:// URI, decoding percent-encoded characters. | |
| */ | |
| function uriToFilePath(uri: string): string { | |
| let filePath = uri.replace(/^file:\/\//, '') | |
| // On Windows, file:///C:/path becomes /C:/path — strip the leading slash | |
| if (/^\/[A-Za-z]:/.test(filePath)) { | |
| filePath = filePath.slice(1) | |
| } | |
| try { | |
| filePath = decodeURIComponent(filePath) | |
| } catch { | |
| // Use un-decoded path if malformed | |
| } | |
| return filePath | |
| } | |
| /** | |
| * Filters out locations whose file paths are gitignored. | |
| * Uses `git check-ignore` with batched path arguments for efficiency. | |
| */ | |
| async function filterGitIgnoredLocations<T extends Location>( | |
| locations: T[], | |
| cwd: string, | |
| ): Promise<T[]> { | |
| if (locations.length === 0) { | |
| return locations | |
| } | |
| // Collect unique file paths from URIs | |
| const uriToPath = new Map<string, string>() | |
| for (const loc of locations) { | |
| if (loc.uri && !uriToPath.has(loc.uri)) { | |
| uriToPath.set(loc.uri, uriToFilePath(loc.uri)) | |
| } | |
| } | |
| const uniquePaths = uniq(uriToPath.values()) | |
| if (uniquePaths.length === 0) { | |
| return locations | |
| } | |
| // Batch check paths with git check-ignore | |
| // Exit code 0 = at least one path is ignored, 1 = none ignored, 128 = not a git repo | |
| const ignoredPaths = new Set<string>() | |
| const BATCH_SIZE = 50 | |
| for (let i = 0; i < uniquePaths.length; i += BATCH_SIZE) { | |
| const batch = uniquePaths.slice(i, i + BATCH_SIZE) | |
| const result = await execFileNoThrowWithCwd( | |
| 'git', | |
| ['check-ignore', ...batch], | |
| { | |
| cwd, | |
| preserveOutputOnError: false, | |
| timeout: 5_000, | |
| }, | |
| ) | |
| if (result.code === 0 && result.stdout) { | |
| for (const line of result.stdout.split('\n')) { | |
| const trimmed = line.trim() | |
| if (trimmed) { | |
| ignoredPaths.add(trimmed) | |
| } | |
| } | |
| } | |
| } | |
| if (ignoredPaths.size === 0) { | |
| return locations | |
| } | |
| return locations.filter(loc => { | |
| const filePath = uriToPath.get(loc.uri) | |
| return !filePath || !ignoredPaths.has(filePath) | |
| }) | |
| } | |
| /** | |
| * Checks if item is LocationLink (has targetUri) vs Location (has uri) | |
| */ | |
| function isLocationLink(item: Location | LocationLink): item is LocationLink { | |
| return 'targetUri' in item | |
| } | |
| /** | |
| * Converts LocationLink to Location format for uniform handling | |
| */ | |
| function toLocation(item: Location | LocationLink): Location { | |
| if (isLocationLink(item)) { | |
| return { | |
| uri: item.targetUri, | |
| range: item.targetSelectionRange || item.targetRange, | |
| } | |
| } | |
| return item | |
| } | |
| /** | |
| * Formats LSP result based on operation type and extracts summary counts | |
| */ | |
| function formatResult( | |
| operation: Input['operation'], | |
| result: unknown, | |
| cwd: string, | |
| ): { formatted: string; resultCount: number; fileCount: number } { | |
| switch (operation) { | |
| case 'goToDefinition': { | |
| // Handle both Location and LocationLink formats | |
| const rawResults = Array.isArray(result) | |
| ? result | |
| : result | |
| ? [result as Location | LocationLink] | |
| : [] | |
| // Convert LocationLinks to Locations for uniform handling | |
| const locations = rawResults.map(toLocation) | |
| // Log and filter out locations with undefined uris | |
| const invalidLocations = locations.filter(loc => !loc || !loc.uri) | |
| if (invalidLocations.length > 0) { | |
| logError( | |
| new Error( | |
| `LSP server returned ${invalidLocations.length} location(s) with undefined URI for goToDefinition on ${cwd}. ` + | |
| `This indicates malformed data from the LSP server.`, | |
| ), | |
| ) | |
| } | |
| const validLocations = locations.filter(loc => loc && loc.uri) | |
| return { | |
| formatted: formatGoToDefinitionResult( | |
| result as | |
| | Location | |
| | Location[] | |
| | LocationLink | |
| | LocationLink[] | |
| | null, | |
| cwd, | |
| ), | |
| resultCount: validLocations.length, | |
| fileCount: countUniqueFiles(validLocations), | |
| } | |
| } | |
| case 'findReferences': { | |
| const locations = (result as Location[]) || [] | |
| // Log and filter out locations with undefined uris | |
| const invalidLocations = locations.filter(loc => !loc || !loc.uri) | |
| if (invalidLocations.length > 0) { | |
| logError( | |
| new Error( | |
| `LSP server returned ${invalidLocations.length} location(s) with undefined URI for findReferences on ${cwd}. ` + | |
| `This indicates malformed data from the LSP server.`, | |
| ), | |
| ) | |
| } | |
| const validLocations = locations.filter(loc => loc && loc.uri) | |
| return { | |
| formatted: formatFindReferencesResult(result as Location[] | null, cwd), | |
| resultCount: validLocations.length, | |
| fileCount: countUniqueFiles(validLocations), | |
| } | |
| } | |
| case 'hover': { | |
| return { | |
| formatted: formatHoverResult(result as Hover | null, cwd), | |
| resultCount: result ? 1 : 0, | |
| fileCount: result ? 1 : 0, | |
| } | |
| } | |
| case 'documentSymbol': { | |
| // LSP allows documentSymbol to return either DocumentSymbol[] or SymbolInformation[] | |
| const symbols = (result as (DocumentSymbol | SymbolInformation)[]) || [] | |
| // Detect format: DocumentSymbol has 'range', SymbolInformation has 'location' | |
| const isDocumentSymbol = | |
| symbols.length > 0 && symbols[0] && 'range' in symbols[0] | |
| // Count symbols - DocumentSymbol can have nested children, SymbolInformation is flat | |
| const count = isDocumentSymbol | |
| ? countSymbols(symbols as DocumentSymbol[]) | |
| : symbols.length | |
| return { | |
| formatted: formatDocumentSymbolResult( | |
| result as (DocumentSymbol[] | SymbolInformation[]) | null, | |
| cwd, | |
| ), | |
| resultCount: count, | |
| fileCount: symbols.length > 0 ? 1 : 0, | |
| } | |
| } | |
| case 'workspaceSymbol': { | |
| const symbols = (result as SymbolInformation[]) || [] | |
| // Log and filter out symbols with undefined location.uri | |
| const invalidSymbols = symbols.filter( | |
| sym => !sym || !sym.location || !sym.location.uri, | |
| ) | |
| if (invalidSymbols.length > 0) { | |
| logError( | |
| new Error( | |
| `LSP server returned ${invalidSymbols.length} symbol(s) with undefined location URI for workspaceSymbol on ${cwd}. ` + | |
| `This indicates malformed data from the LSP server.`, | |
| ), | |
| ) | |
| } | |
| const validSymbols = symbols.filter( | |
| sym => sym && sym.location && sym.location.uri, | |
| ) | |
| const locations = validSymbols.map(s => s.location) | |
| return { | |
| formatted: formatWorkspaceSymbolResult( | |
| result as SymbolInformation[] | null, | |
| cwd, | |
| ), | |
| resultCount: validSymbols.length, | |
| fileCount: countUniqueFiles(locations), | |
| } | |
| } | |
| case 'goToImplementation': { | |
| // Handle both Location and LocationLink formats (same as goToDefinition) | |
| const rawResults = Array.isArray(result) | |
| ? result | |
| : result | |
| ? [result as Location | LocationLink] | |
| : [] | |
| // Convert LocationLinks to Locations for uniform handling | |
| const locations = rawResults.map(toLocation) | |
| // Log and filter out locations with undefined uris | |
| const invalidLocations = locations.filter(loc => !loc || !loc.uri) | |
| if (invalidLocations.length > 0) { | |
| logError( | |
| new Error( | |
| `LSP server returned ${invalidLocations.length} location(s) with undefined URI for goToImplementation on ${cwd}. ` + | |
| `This indicates malformed data from the LSP server.`, | |
| ), | |
| ) | |
| } | |
| const validLocations = locations.filter(loc => loc && loc.uri) | |
| return { | |
| // Reuse goToDefinition formatter since the result format is identical | |
| formatted: formatGoToDefinitionResult( | |
| result as | |
| | Location | |
| | Location[] | |
| | LocationLink | |
| | LocationLink[] | |
| | null, | |
| cwd, | |
| ), | |
| resultCount: validLocations.length, | |
| fileCount: countUniqueFiles(validLocations), | |
| } | |
| } | |
| case 'prepareCallHierarchy': { | |
| const items = (result as CallHierarchyItem[]) || [] | |
| return { | |
| formatted: formatPrepareCallHierarchyResult( | |
| result as CallHierarchyItem[] | null, | |
| cwd, | |
| ), | |
| resultCount: items.length, | |
| fileCount: items.length > 0 ? countUniqueFilesFromCallItems(items) : 0, | |
| } | |
| } | |
| case 'incomingCalls': { | |
| const calls = (result as CallHierarchyIncomingCall[]) || [] | |
| return { | |
| formatted: formatIncomingCallsResult( | |
| result as CallHierarchyIncomingCall[] | null, | |
| cwd, | |
| ), | |
| resultCount: calls.length, | |
| fileCount: | |
| calls.length > 0 ? countUniqueFilesFromIncomingCalls(calls) : 0, | |
| } | |
| } | |
| case 'outgoingCalls': { | |
| const calls = (result as CallHierarchyOutgoingCall[]) || [] | |
| return { | |
| formatted: formatOutgoingCallsResult( | |
| result as CallHierarchyOutgoingCall[] | null, | |
| cwd, | |
| ), | |
| resultCount: calls.length, | |
| fileCount: | |
| calls.length > 0 ? countUniqueFilesFromOutgoingCalls(calls) : 0, | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Counts unique files from CallHierarchyItem array | |
| * Filters out items with undefined URIs | |
| */ | |
| function countUniqueFilesFromCallItems(items: CallHierarchyItem[]): number { | |
| const validUris = items.map(item => item.uri).filter(uri => uri) | |
| return new Set(validUris).size | |
| } | |
| /** | |
| * Counts unique files from CallHierarchyIncomingCall array | |
| * Filters out calls with undefined URIs | |
| */ | |
| function countUniqueFilesFromIncomingCalls( | |
| calls: CallHierarchyIncomingCall[], | |
| ): number { | |
| const validUris = calls.map(call => call.from?.uri).filter(uri => uri) | |
| return new Set(validUris).size | |
| } | |
| /** | |
| * Counts unique files from CallHierarchyOutgoingCall array | |
| * Filters out calls with undefined URIs | |
| */ | |
| function countUniqueFilesFromOutgoingCalls( | |
| calls: CallHierarchyOutgoingCall[], | |
| ): number { | |
| const validUris = calls.map(call => call.to?.uri).filter(uri => uri) | |
| return new Set(validUris).size | |
| } | |