Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
26 kB · 860 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861import { 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}