/ tools / LSPTool / LSPTool.ts
LSPTool.ts
  1  import { open } from 'fs/promises'
  2  import * as path from 'path'
  3  import { pathToFileURL } from 'url'
  4  import type {
  5    CallHierarchyIncomingCall,
  6    CallHierarchyItem,
  7    CallHierarchyOutgoingCall,
  8    DocumentSymbol,
  9    Hover,
 10    Location,
 11    LocationLink,
 12    SymbolInformation,
 13  } from 'vscode-languageserver-types'
 14  import { z } from 'zod/v4'
 15  import {
 16    getInitializationStatus,
 17    getLspServerManager,
 18    isLspConnected,
 19    waitForInitialization,
 20  } from '../../services/lsp/manager.js'
 21  import type { ValidationResult } from '../../Tool.js'
 22  import { buildTool, type ToolDef } from '../../Tool.js'
 23  import { uniq } from '../../utils/array.js'
 24  import { getCwd } from '../../utils/cwd.js'
 25  import { logForDebugging } from '../../utils/debug.js'
 26  import { isENOENT, toError } from '../../utils/errors.js'
 27  import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js'
 28  import { getFsImplementation } from '../../utils/fsOperations.js'
 29  import { lazySchema } from '../../utils/lazySchema.js'
 30  import { logError } from '../../utils/log.js'
 31  import { expandPath } from '../../utils/path.js'
 32  import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js'
 33  import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
 34  import {
 35    formatDocumentSymbolResult,
 36    formatFindReferencesResult,
 37    formatGoToDefinitionResult,
 38    formatHoverResult,
 39    formatIncomingCallsResult,
 40    formatOutgoingCallsResult,
 41    formatPrepareCallHierarchyResult,
 42    formatWorkspaceSymbolResult,
 43  } from './formatters.js'
 44  import { DESCRIPTION, LSP_TOOL_NAME } from './prompt.js'
 45  import { lspToolInputSchema } from './schemas.js'
 46  import {
 47    renderToolResultMessage,
 48    renderToolUseErrorMessage,
 49    renderToolUseMessage,
 50    userFacingName,
 51  } from './UI.js'
 52  
 53  const 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   */
 59  const 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  )
 87  type InputSchema = ReturnType<typeof inputSchema>
 88  
 89  const 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  )
122  type OutputSchema = ReturnType<typeof outputSchema>
123  
124  export type Output = z.infer<OutputSchema>
125  export type Input = z.infer<InputSchema>
126  
127  export 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   */
427  function 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   */
518  function 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   */
531  function 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   */
538  function 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   */
556  async 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   */
616  function 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   */
623  function 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   */
636  function 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   */
835  function 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   */
844  function 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   */
855  function 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  }