source dump of claude code
at main 860 lines 26 kB view raw
1import { open } from 'fs/promises' 2import * as path from 'path' 3import { pathToFileURL } from 'url' 4import type { 5 CallHierarchyIncomingCall, 6 CallHierarchyItem, 7 CallHierarchyOutgoingCall, 8 DocumentSymbol, 9 Hover, 10 Location, 11 LocationLink, 12 SymbolInformation, 13} from 'vscode-languageserver-types' 14import { z } from 'zod/v4' 15import { 16 getInitializationStatus, 17 getLspServerManager, 18 isLspConnected, 19 waitForInitialization, 20} from '../../services/lsp/manager.js' 21import type { ValidationResult } from '../../Tool.js' 22import { buildTool, type ToolDef } from '../../Tool.js' 23import { uniq } from '../../utils/array.js' 24import { getCwd } from '../../utils/cwd.js' 25import { logForDebugging } from '../../utils/debug.js' 26import { isENOENT, toError } from '../../utils/errors.js' 27import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js' 28import { getFsImplementation } from '../../utils/fsOperations.js' 29import { lazySchema } from '../../utils/lazySchema.js' 30import { logError } from '../../utils/log.js' 31import { expandPath } from '../../utils/path.js' 32import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js' 33import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 34import { 35 formatDocumentSymbolResult, 36 formatFindReferencesResult, 37 formatGoToDefinitionResult, 38 formatHoverResult, 39 formatIncomingCallsResult, 40 formatOutgoingCallsResult, 41 formatPrepareCallHierarchyResult, 42 formatWorkspaceSymbolResult, 43} from './formatters.js' 44import { DESCRIPTION, LSP_TOOL_NAME } from './prompt.js' 45import { lspToolInputSchema } from './schemas.js' 46import { 47 renderToolResultMessage, 48 renderToolUseErrorMessage, 49 renderToolUseMessage, 50 userFacingName, 51} from './UI.js' 52 53const MAX_LSP_FILE_SIZE_BYTES = 10_000_000 54 55/** 56 * Tool-compatible input schema (regular ZodObject instead of discriminated union) 57 * We validate against the discriminated union in validateInput for better error messages 58 */ 59const inputSchema = lazySchema(() => 60 z.strictObject({ 61 operation: z 62 .enum([ 63 'goToDefinition', 64 'findReferences', 65 'hover', 66 'documentSymbol', 67 'workspaceSymbol', 68 'goToImplementation', 69 'prepareCallHierarchy', 70 'incomingCalls', 71 'outgoingCalls', 72 ]) 73 .describe('The LSP operation to perform'), 74 filePath: z.string().describe('The absolute or relative path to the file'), 75 line: z 76 .number() 77 .int() 78 .positive() 79 .describe('The line number (1-based, as shown in editors)'), 80 character: z 81 .number() 82 .int() 83 .positive() 84 .describe('The character offset (1-based, as shown in editors)'), 85 }), 86) 87type InputSchema = ReturnType<typeof inputSchema> 88 89const outputSchema = lazySchema(() => 90 z.object({ 91 operation: z 92 .enum([ 93 'goToDefinition', 94 'findReferences', 95 'hover', 96 'documentSymbol', 97 'workspaceSymbol', 98 'goToImplementation', 99 'prepareCallHierarchy', 100 'incomingCalls', 101 'outgoingCalls', 102 ]) 103 .describe('The LSP operation that was performed'), 104 result: z.string().describe('The formatted result of the LSP operation'), 105 filePath: z 106 .string() 107 .describe('The file path the operation was performed on'), 108 resultCount: z 109 .number() 110 .int() 111 .nonnegative() 112 .optional() 113 .describe('Number of results (definitions, references, symbols)'), 114 fileCount: z 115 .number() 116 .int() 117 .nonnegative() 118 .optional() 119 .describe('Number of files containing results'), 120 }), 121) 122type OutputSchema = ReturnType<typeof outputSchema> 123 124export type Output = z.infer<OutputSchema> 125export type Input = z.infer<InputSchema> 126 127export const LSPTool = buildTool({ 128 name: LSP_TOOL_NAME, 129 searchHint: 'code intelligence (definitions, references, symbols, hover)', 130 maxResultSizeChars: 100_000, 131 isLsp: true, 132 async description() { 133 return DESCRIPTION 134 }, 135 userFacingName, 136 shouldDefer: true, 137 isEnabled() { 138 return isLspConnected() 139 }, 140 get inputSchema(): InputSchema { 141 return inputSchema() 142 }, 143 get outputSchema(): OutputSchema { 144 return outputSchema() 145 }, 146 isConcurrencySafe() { 147 return true 148 }, 149 isReadOnly() { 150 return true 151 }, 152 getPath({ filePath }): string { 153 return expandPath(filePath) 154 }, 155 async validateInput(input: Input): Promise<ValidationResult> { 156 // First validate against the discriminated union for better type safety 157 const parseResult = lspToolInputSchema().safeParse(input) 158 if (!parseResult.success) { 159 return { 160 result: false, 161 message: `Invalid input: ${parseResult.error.message}`, 162 errorCode: 3, 163 } 164 } 165 166 // Validate file exists and is a regular file 167 const fs = getFsImplementation() 168 const absolutePath = expandPath(input.filePath) 169 170 // SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks. 171 if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) { 172 return { result: true } 173 } 174 175 let stats 176 try { 177 stats = await fs.stat(absolutePath) 178 } catch (error) { 179 if (isENOENT(error)) { 180 return { 181 result: false, 182 message: `File does not exist: ${input.filePath}`, 183 errorCode: 1, 184 } 185 } 186 const err = toError(error) 187 // Log filesystem access errors for tracking 188 logError( 189 new Error( 190 `Failed to access file stats for LSP operation on ${input.filePath}: ${err.message}`, 191 ), 192 ) 193 return { 194 result: false, 195 message: `Cannot access file: ${input.filePath}. ${err.message}`, 196 errorCode: 4, 197 } 198 } 199 200 if (!stats.isFile()) { 201 return { 202 result: false, 203 message: `Path is not a file: ${input.filePath}`, 204 errorCode: 2, 205 } 206 } 207 208 return { result: true } 209 }, 210 async checkPermissions(input, context): Promise<PermissionDecision> { 211 const appState = context.getAppState() 212 return checkReadPermissionForTool( 213 LSPTool, 214 input, 215 appState.toolPermissionContext, 216 ) 217 }, 218 async prompt() { 219 return DESCRIPTION 220 }, 221 renderToolUseMessage, 222 renderToolUseErrorMessage, 223 renderToolResultMessage, 224 async call(input: Input, _context) { 225 const absolutePath = expandPath(input.filePath) 226 const cwd = getCwd() 227 228 // Wait for initialization if it's still pending 229 // This prevents returning "no server available" before init completes 230 const status = getInitializationStatus() 231 if (status.status === 'pending') { 232 await waitForInitialization() 233 } 234 235 // Get the LSP server manager 236 const manager = getLspServerManager() 237 if (!manager) { 238 // Log this system-level failure for tracking 239 logError( 240 new Error('LSP server manager not initialized when tool was called'), 241 ) 242 243 const output: Output = { 244 operation: input.operation, 245 result: 246 'LSP server manager not initialized. This may indicate a startup issue.', 247 filePath: input.filePath, 248 } 249 return { 250 data: output, 251 } 252 } 253 254 // Map operation to LSP method and prepare params 255 const { method, params } = getMethodAndParams(input, absolutePath) 256 257 try { 258 // Ensure file is open in LSP server before making requests 259 // Most LSP servers require textDocument/didOpen before operations 260 // Only read the file if it's not already open to avoid unnecessary I/O 261 if (!manager.isFileOpen(absolutePath)) { 262 const handle = await open(absolutePath, 'r') 263 try { 264 const stats = await handle.stat() 265 if (stats.size > MAX_LSP_FILE_SIZE_BYTES) { 266 const output: Output = { 267 operation: input.operation, 268 result: `File too large for LSP analysis (${Math.ceil(stats.size / 1_000_000)}MB exceeds 10MB limit)`, 269 filePath: input.filePath, 270 } 271 return { data: output } 272 } 273 const fileContent = await handle.readFile({ encoding: 'utf-8' }) 274 await manager.openFile(absolutePath, fileContent) 275 } finally { 276 await handle.close() 277 } 278 } 279 280 // Send request to LSP server 281 let result = await manager.sendRequest(absolutePath, method, params) 282 283 if (result === undefined) { 284 // Log for diagnostic purposes - helps track usage patterns and potential bugs 285 logForDebugging( 286 `No LSP server available for file type ${path.extname(absolutePath)} for operation ${input.operation} on file ${input.filePath}`, 287 ) 288 289 const output: Output = { 290 operation: input.operation, 291 result: `No LSP server available for file type: ${path.extname(absolutePath)}`, 292 filePath: input.filePath, 293 } 294 return { 295 data: output, 296 } 297 } 298 299 // For incomingCalls and outgoingCalls, we need a two-step process: 300 // 1. First get CallHierarchyItem(s) from prepareCallHierarchy 301 // 2. Then request the actual calls using that item 302 if ( 303 input.operation === 'incomingCalls' || 304 input.operation === 'outgoingCalls' 305 ) { 306 const callItems = result as CallHierarchyItem[] 307 if (!callItems || callItems.length === 0) { 308 const output: Output = { 309 operation: input.operation, 310 result: 'No call hierarchy item found at this position', 311 filePath: input.filePath, 312 resultCount: 0, 313 fileCount: 0, 314 } 315 return { data: output } 316 } 317 318 // Use the first call hierarchy item to request calls 319 const callMethod = 320 input.operation === 'incomingCalls' 321 ? 'callHierarchy/incomingCalls' 322 : 'callHierarchy/outgoingCalls' 323 324 result = await manager.sendRequest(absolutePath, callMethod, { 325 item: callItems[0], 326 }) 327 328 if (result === undefined) { 329 logForDebugging( 330 `LSP server returned undefined for ${callMethod} on ${input.filePath}`, 331 ) 332 // Continue to formatter which will handle empty/null gracefully 333 } 334 } 335 336 // Filter out gitignored files from location-based results 337 if ( 338 result && 339 Array.isArray(result) && 340 (input.operation === 'findReferences' || 341 input.operation === 'goToDefinition' || 342 input.operation === 'goToImplementation' || 343 input.operation === 'workspaceSymbol') 344 ) { 345 if (input.operation === 'workspaceSymbol') { 346 // SymbolInformation has location.uri — filter by extracting locations 347 const symbols = result as SymbolInformation[] 348 const locations = symbols 349 .filter(s => s?.location?.uri) 350 .map(s => s.location) 351 const filteredLocations = await filterGitIgnoredLocations( 352 locations, 353 cwd, 354 ) 355 const filteredUris = new Set(filteredLocations.map(l => l.uri)) 356 result = symbols.filter( 357 s => !s?.location?.uri || filteredUris.has(s.location.uri), 358 ) 359 } else { 360 // Location[] or (Location | LocationLink)[] 361 const locations = (result as (Location | LocationLink)[]).map( 362 toLocation, 363 ) 364 const filteredLocations = await filterGitIgnoredLocations( 365 locations, 366 cwd, 367 ) 368 const filteredUris = new Set(filteredLocations.map(l => l.uri)) 369 result = (result as (Location | LocationLink)[]).filter(item => { 370 const loc = toLocation(item) 371 return !loc.uri || filteredUris.has(loc.uri) 372 }) 373 } 374 } 375 376 // Format the result based on operation type 377 const { formatted, resultCount, fileCount } = formatResult( 378 input.operation, 379 result, 380 cwd, 381 ) 382 383 const output: Output = { 384 operation: input.operation, 385 result: formatted, 386 filePath: input.filePath, 387 resultCount, 388 fileCount, 389 } 390 391 return { 392 data: output, 393 } 394 } catch (error) { 395 const err = toError(error) 396 const errorMessage = err.message 397 398 // Log error for tracking 399 logError( 400 new Error( 401 `LSP tool request failed for ${input.operation} on ${input.filePath}: ${errorMessage}`, 402 ), 403 ) 404 405 const output: Output = { 406 operation: input.operation, 407 result: `Error performing ${input.operation}: ${errorMessage}`, 408 filePath: input.filePath, 409 } 410 return { 411 data: output, 412 } 413 } 414 }, 415 mapToolResultToToolResultBlockParam(output, toolUseID) { 416 return { 417 tool_use_id: toolUseID, 418 type: 'tool_result', 419 content: output.result, 420 } 421 }, 422} satisfies ToolDef<InputSchema, Output>) 423 424/** 425 * Maps LSPTool operation to LSP method and params 426 */ 427function getMethodAndParams( 428 input: Input, 429 absolutePath: string, 430): { method: string; params: unknown } { 431 const uri = pathToFileURL(absolutePath).href 432 // Convert from 1-based (user-friendly) to 0-based (LSP protocol) 433 const position = { 434 line: input.line - 1, 435 character: input.character - 1, 436 } 437 438 switch (input.operation) { 439 case 'goToDefinition': 440 return { 441 method: 'textDocument/definition', 442 params: { 443 textDocument: { uri }, 444 position, 445 }, 446 } 447 case 'findReferences': 448 return { 449 method: 'textDocument/references', 450 params: { 451 textDocument: { uri }, 452 position, 453 context: { includeDeclaration: true }, 454 }, 455 } 456 case 'hover': 457 return { 458 method: 'textDocument/hover', 459 params: { 460 textDocument: { uri }, 461 position, 462 }, 463 } 464 case 'documentSymbol': 465 return { 466 method: 'textDocument/documentSymbol', 467 params: { 468 textDocument: { uri }, 469 }, 470 } 471 case 'workspaceSymbol': 472 return { 473 method: 'workspace/symbol', 474 params: { 475 query: '', // Empty query returns all symbols 476 }, 477 } 478 case 'goToImplementation': 479 return { 480 method: 'textDocument/implementation', 481 params: { 482 textDocument: { uri }, 483 position, 484 }, 485 } 486 case 'prepareCallHierarchy': 487 return { 488 method: 'textDocument/prepareCallHierarchy', 489 params: { 490 textDocument: { uri }, 491 position, 492 }, 493 } 494 case 'incomingCalls': 495 // For incoming/outgoing calls, we first need to prepare the call hierarchy 496 // The LSP server will return CallHierarchyItem(s) that we pass to the calls request 497 return { 498 method: 'textDocument/prepareCallHierarchy', 499 params: { 500 textDocument: { uri }, 501 position, 502 }, 503 } 504 case 'outgoingCalls': 505 return { 506 method: 'textDocument/prepareCallHierarchy', 507 params: { 508 textDocument: { uri }, 509 position, 510 }, 511 } 512 } 513} 514 515/** 516 * Counts the total number of symbols including nested children 517 */ 518function countSymbols(symbols: DocumentSymbol[]): number { 519 let count = symbols.length 520 for (const symbol of symbols) { 521 if (symbol.children && symbol.children.length > 0) { 522 count += countSymbols(symbol.children) 523 } 524 } 525 return count 526} 527 528/** 529 * Counts unique files from an array of locations 530 */ 531function countUniqueFiles(locations: Location[]): number { 532 return new Set(locations.map(loc => loc.uri)).size 533} 534 535/** 536 * Extracts a file path from a file:// URI, decoding percent-encoded characters. 537 */ 538function uriToFilePath(uri: string): string { 539 let filePath = uri.replace(/^file:\/\//, '') 540 // On Windows, file:///C:/path becomes /C:/path — strip the leading slash 541 if (/^\/[A-Za-z]:/.test(filePath)) { 542 filePath = filePath.slice(1) 543 } 544 try { 545 filePath = decodeURIComponent(filePath) 546 } catch { 547 // Use un-decoded path if malformed 548 } 549 return filePath 550} 551 552/** 553 * Filters out locations whose file paths are gitignored. 554 * Uses `git check-ignore` with batched path arguments for efficiency. 555 */ 556async function filterGitIgnoredLocations<T extends Location>( 557 locations: T[], 558 cwd: string, 559): Promise<T[]> { 560 if (locations.length === 0) { 561 return locations 562 } 563 564 // Collect unique file paths from URIs 565 const uriToPath = new Map<string, string>() 566 for (const loc of locations) { 567 if (loc.uri && !uriToPath.has(loc.uri)) { 568 uriToPath.set(loc.uri, uriToFilePath(loc.uri)) 569 } 570 } 571 572 const uniquePaths = uniq(uriToPath.values()) 573 if (uniquePaths.length === 0) { 574 return locations 575 } 576 577 // Batch check paths with git check-ignore 578 // Exit code 0 = at least one path is ignored, 1 = none ignored, 128 = not a git repo 579 const ignoredPaths = new Set<string>() 580 const BATCH_SIZE = 50 581 for (let i = 0; i < uniquePaths.length; i += BATCH_SIZE) { 582 const batch = uniquePaths.slice(i, i + BATCH_SIZE) 583 const result = await execFileNoThrowWithCwd( 584 'git', 585 ['check-ignore', ...batch], 586 { 587 cwd, 588 preserveOutputOnError: false, 589 timeout: 5_000, 590 }, 591 ) 592 593 if (result.code === 0 && result.stdout) { 594 for (const line of result.stdout.split('\n')) { 595 const trimmed = line.trim() 596 if (trimmed) { 597 ignoredPaths.add(trimmed) 598 } 599 } 600 } 601 } 602 603 if (ignoredPaths.size === 0) { 604 return locations 605 } 606 607 return locations.filter(loc => { 608 const filePath = uriToPath.get(loc.uri) 609 return !filePath || !ignoredPaths.has(filePath) 610 }) 611} 612 613/** 614 * Checks if item is LocationLink (has targetUri) vs Location (has uri) 615 */ 616function isLocationLink(item: Location | LocationLink): item is LocationLink { 617 return 'targetUri' in item 618} 619 620/** 621 * Converts LocationLink to Location format for uniform handling 622 */ 623function toLocation(item: Location | LocationLink): Location { 624 if (isLocationLink(item)) { 625 return { 626 uri: item.targetUri, 627 range: item.targetSelectionRange || item.targetRange, 628 } 629 } 630 return item 631} 632 633/** 634 * Formats LSP result based on operation type and extracts summary counts 635 */ 636function formatResult( 637 operation: Input['operation'], 638 result: unknown, 639 cwd: string, 640): { formatted: string; resultCount: number; fileCount: number } { 641 switch (operation) { 642 case 'goToDefinition': { 643 // Handle both Location and LocationLink formats 644 const rawResults = Array.isArray(result) 645 ? result 646 : result 647 ? [result as Location | LocationLink] 648 : [] 649 650 // Convert LocationLinks to Locations for uniform handling 651 const locations = rawResults.map(toLocation) 652 653 // Log and filter out locations with undefined uris 654 const invalidLocations = locations.filter(loc => !loc || !loc.uri) 655 if (invalidLocations.length > 0) { 656 logError( 657 new Error( 658 `LSP server returned ${invalidLocations.length} location(s) with undefined URI for goToDefinition on ${cwd}. ` + 659 `This indicates malformed data from the LSP server.`, 660 ), 661 ) 662 } 663 664 const validLocations = locations.filter(loc => loc && loc.uri) 665 return { 666 formatted: formatGoToDefinitionResult( 667 result as 668 | Location 669 | Location[] 670 | LocationLink 671 | LocationLink[] 672 | null, 673 cwd, 674 ), 675 resultCount: validLocations.length, 676 fileCount: countUniqueFiles(validLocations), 677 } 678 } 679 case 'findReferences': { 680 const locations = (result as Location[]) || [] 681 682 // Log and filter out locations with undefined uris 683 const invalidLocations = locations.filter(loc => !loc || !loc.uri) 684 if (invalidLocations.length > 0) { 685 logError( 686 new Error( 687 `LSP server returned ${invalidLocations.length} location(s) with undefined URI for findReferences on ${cwd}. ` + 688 `This indicates malformed data from the LSP server.`, 689 ), 690 ) 691 } 692 693 const validLocations = locations.filter(loc => loc && loc.uri) 694 return { 695 formatted: formatFindReferencesResult(result as Location[] | null, cwd), 696 resultCount: validLocations.length, 697 fileCount: countUniqueFiles(validLocations), 698 } 699 } 700 case 'hover': { 701 return { 702 formatted: formatHoverResult(result as Hover | null, cwd), 703 resultCount: result ? 1 : 0, 704 fileCount: result ? 1 : 0, 705 } 706 } 707 case 'documentSymbol': { 708 // LSP allows documentSymbol to return either DocumentSymbol[] or SymbolInformation[] 709 const symbols = (result as (DocumentSymbol | SymbolInformation)[]) || [] 710 // Detect format: DocumentSymbol has 'range', SymbolInformation has 'location' 711 const isDocumentSymbol = 712 symbols.length > 0 && symbols[0] && 'range' in symbols[0] 713 // Count symbols - DocumentSymbol can have nested children, SymbolInformation is flat 714 const count = isDocumentSymbol 715 ? countSymbols(symbols as DocumentSymbol[]) 716 : symbols.length 717 return { 718 formatted: formatDocumentSymbolResult( 719 result as (DocumentSymbol[] | SymbolInformation[]) | null, 720 cwd, 721 ), 722 resultCount: count, 723 fileCount: symbols.length > 0 ? 1 : 0, 724 } 725 } 726 case 'workspaceSymbol': { 727 const symbols = (result as SymbolInformation[]) || [] 728 729 // Log and filter out symbols with undefined location.uri 730 const invalidSymbols = symbols.filter( 731 sym => !sym || !sym.location || !sym.location.uri, 732 ) 733 if (invalidSymbols.length > 0) { 734 logError( 735 new Error( 736 `LSP server returned ${invalidSymbols.length} symbol(s) with undefined location URI for workspaceSymbol on ${cwd}. ` + 737 `This indicates malformed data from the LSP server.`, 738 ), 739 ) 740 } 741 742 const validSymbols = symbols.filter( 743 sym => sym && sym.location && sym.location.uri, 744 ) 745 const locations = validSymbols.map(s => s.location) 746 return { 747 formatted: formatWorkspaceSymbolResult( 748 result as SymbolInformation[] | null, 749 cwd, 750 ), 751 resultCount: validSymbols.length, 752 fileCount: countUniqueFiles(locations), 753 } 754 } 755 case 'goToImplementation': { 756 // Handle both Location and LocationLink formats (same as goToDefinition) 757 const rawResults = Array.isArray(result) 758 ? result 759 : result 760 ? [result as Location | LocationLink] 761 : [] 762 763 // Convert LocationLinks to Locations for uniform handling 764 const locations = rawResults.map(toLocation) 765 766 // Log and filter out locations with undefined uris 767 const invalidLocations = locations.filter(loc => !loc || !loc.uri) 768 if (invalidLocations.length > 0) { 769 logError( 770 new Error( 771 `LSP server returned ${invalidLocations.length} location(s) with undefined URI for goToImplementation on ${cwd}. ` + 772 `This indicates malformed data from the LSP server.`, 773 ), 774 ) 775 } 776 777 const validLocations = locations.filter(loc => loc && loc.uri) 778 return { 779 // Reuse goToDefinition formatter since the result format is identical 780 formatted: formatGoToDefinitionResult( 781 result as 782 | Location 783 | Location[] 784 | LocationLink 785 | LocationLink[] 786 | null, 787 cwd, 788 ), 789 resultCount: validLocations.length, 790 fileCount: countUniqueFiles(validLocations), 791 } 792 } 793 case 'prepareCallHierarchy': { 794 const items = (result as CallHierarchyItem[]) || [] 795 return { 796 formatted: formatPrepareCallHierarchyResult( 797 result as CallHierarchyItem[] | null, 798 cwd, 799 ), 800 resultCount: items.length, 801 fileCount: items.length > 0 ? countUniqueFilesFromCallItems(items) : 0, 802 } 803 } 804 case 'incomingCalls': { 805 const calls = (result as CallHierarchyIncomingCall[]) || [] 806 return { 807 formatted: formatIncomingCallsResult( 808 result as CallHierarchyIncomingCall[] | null, 809 cwd, 810 ), 811 resultCount: calls.length, 812 fileCount: 813 calls.length > 0 ? countUniqueFilesFromIncomingCalls(calls) : 0, 814 } 815 } 816 case 'outgoingCalls': { 817 const calls = (result as CallHierarchyOutgoingCall[]) || [] 818 return { 819 formatted: formatOutgoingCallsResult( 820 result as CallHierarchyOutgoingCall[] | null, 821 cwd, 822 ), 823 resultCount: calls.length, 824 fileCount: 825 calls.length > 0 ? countUniqueFilesFromOutgoingCalls(calls) : 0, 826 } 827 } 828 } 829} 830 831/** 832 * Counts unique files from CallHierarchyItem array 833 * Filters out items with undefined URIs 834 */ 835function countUniqueFilesFromCallItems(items: CallHierarchyItem[]): number { 836 const validUris = items.map(item => item.uri).filter(uri => uri) 837 return new Set(validUris).size 838} 839 840/** 841 * Counts unique files from CallHierarchyIncomingCall array 842 * Filters out calls with undefined URIs 843 */ 844function countUniqueFilesFromIncomingCalls( 845 calls: CallHierarchyIncomingCall[], 846): number { 847 const validUris = calls.map(call => call.from?.uri).filter(uri => uri) 848 return new Set(validUris).size 849} 850 851/** 852 * Counts unique files from CallHierarchyOutgoingCall array 853 * Filters out calls with undefined URIs 854 */ 855function countUniqueFilesFromOutgoingCalls( 856 calls: CallHierarchyOutgoingCall[], 857): number { 858 const validUris = calls.map(call => call.to?.uri).filter(uri => uri) 859 return new Set(validUris).size 860}