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 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 export type Output = z.infer export type Input = z.infer 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 { // 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 { 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) /** * 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( locations: T[], cwd: string, ): Promise { if (locations.length === 0) { return locations } // Collect unique file paths from URIs const uriToPath = new Map() 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() 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 }