/ tools / LSPTool / formatters.ts
formatters.ts
  1  import { relative } from 'path'
  2  import type {
  3    CallHierarchyIncomingCall,
  4    CallHierarchyItem,
  5    CallHierarchyOutgoingCall,
  6    DocumentSymbol,
  7    Hover,
  8    Location,
  9    LocationLink,
 10    MarkedString,
 11    MarkupContent,
 12    SymbolInformation,
 13    SymbolKind,
 14  } from 'vscode-languageserver-types'
 15  import { logForDebugging } from '../../utils/debug.js'
 16  import { errorMessage } from '../../utils/errors.js'
 17  import { plural } from '../../utils/stringUtils.js'
 18  
 19  /**
 20   * Formats a URI by converting it to a relative path if possible.
 21   * Handles URI decoding and gracefully falls back to un-decoded path if malformed.
 22   * Only uses relative paths when shorter and not starting with ../../
 23   */
 24  function formatUri(uri: string | undefined, cwd?: string): string {
 25    // Handle undefined/null URIs - this indicates malformed LSP data
 26    if (!uri) {
 27      // NOTE: This should ideally be caught earlier with proper error logging
 28      // This is a defensive backstop in the formatting layer
 29      logForDebugging(
 30        'formatUri called with undefined URI - indicates malformed LSP server response',
 31        { level: 'warn' },
 32      )
 33      return '<unknown location>'
 34    }
 35  
 36    // Remove file:// protocol if present
 37    // On Windows, file:///C:/path becomes /C:/path after replacing file://
 38    // We need to strip the leading slash for Windows drive-letter paths
 39    let filePath = uri.replace(/^file:\/\//, '')
 40    if (/^\/[A-Za-z]:/.test(filePath)) {
 41      filePath = filePath.slice(1)
 42    }
 43  
 44    // Decode URI encoding - handle malformed URIs gracefully
 45    try {
 46      filePath = decodeURIComponent(filePath)
 47    } catch (error) {
 48      // Log for debugging but continue with un-decoded path
 49      const errorMsg = errorMessage(error)
 50      logForDebugging(
 51        `Failed to decode LSP URI '${uri}': ${errorMsg}. Using un-decoded path: ${filePath}`,
 52        { level: 'warn' },
 53      )
 54      // filePath already contains the un-decoded path, which is still usable
 55    }
 56  
 57    // Convert to relative path if cwd is provided
 58    if (cwd) {
 59      // Normalize separators to forward slashes for consistent display output
 60      const relativePath = relative(cwd, filePath).replaceAll('\\', '/')
 61      // Only use relative path if it's shorter and doesn't start with ../..
 62      if (
 63        relativePath.length < filePath.length &&
 64        !relativePath.startsWith('../../')
 65      ) {
 66        return relativePath
 67      }
 68    }
 69  
 70    // Normalize separators to forward slashes for consistent display output
 71    return filePath.replaceAll('\\', '/')
 72  }
 73  
 74  /**
 75   * Groups items by their file URI.
 76   * Generic helper that works with both Location[] and SymbolInformation[]
 77   */
 78  function groupByFile<T extends { uri: string } | { location: { uri: string } }>(
 79    items: T[],
 80    cwd?: string,
 81  ): Map<string, T[]> {
 82    const byFile = new Map<string, T[]>()
 83    for (const item of items) {
 84      const uri = 'uri' in item ? item.uri : item.location.uri
 85      const filePath = formatUri(uri, cwd)
 86      const existingItems = byFile.get(filePath)
 87      if (existingItems) {
 88        existingItems.push(item)
 89      } else {
 90        byFile.set(filePath, [item])
 91      }
 92    }
 93    return byFile
 94  }
 95  
 96  /**
 97   * Formats a Location with file path and line/character position
 98   */
 99  function formatLocation(location: Location, cwd?: string): string {
100    const filePath = formatUri(location.uri, cwd)
101    const line = location.range.start.line + 1 // Convert to 1-based
102    const character = location.range.start.character + 1 // Convert to 1-based
103    return `${filePath}:${line}:${character}`
104  }
105  
106  /**
107   * Converts LocationLink to Location format for consistent handling
108   */
109  function locationLinkToLocation(link: LocationLink): Location {
110    return {
111      uri: link.targetUri,
112      range: link.targetSelectionRange || link.targetRange,
113    }
114  }
115  
116  /**
117   * Checks if an object is a LocationLink (has targetUri) vs Location (has uri)
118   */
119  function isLocationLink(item: Location | LocationLink): item is LocationLink {
120    return 'targetUri' in item
121  }
122  
123  /**
124   * Formats goToDefinition result
125   * Can return Location, LocationLink, or arrays of either
126   */
127  export function formatGoToDefinitionResult(
128    result: Location | Location[] | LocationLink | LocationLink[] | null,
129    cwd?: string,
130  ): string {
131    if (!result) {
132      return 'No definition found. This may occur if the cursor is not on a symbol, or if the definition is in an external library not indexed by the LSP server.'
133    }
134  
135    if (Array.isArray(result)) {
136      // Convert LocationLinks to Locations for uniform handling
137      const locations: Location[] = result.map(item =>
138        isLocationLink(item) ? locationLinkToLocation(item) : item,
139      )
140  
141      // Log and filter out any locations with undefined uris
142      const invalidLocations = locations.filter(loc => !loc || !loc.uri)
143      if (invalidLocations.length > 0) {
144        logForDebugging(
145          `formatGoToDefinitionResult: Filtering out ${invalidLocations.length} invalid location(s) - this should have been caught earlier`,
146          { level: 'warn' },
147        )
148      }
149  
150      const validLocations = locations.filter(loc => loc && loc.uri)
151  
152      if (validLocations.length === 0) {
153        return 'No definition found. This may occur if the cursor is not on a symbol, or if the definition is in an external library not indexed by the LSP server.'
154      }
155      if (validLocations.length === 1) {
156        return `Defined in ${formatLocation(validLocations[0]!, cwd)}`
157      }
158      const locationList = validLocations
159        .map(loc => `  ${formatLocation(loc, cwd)}`)
160        .join('\n')
161      return `Found ${validLocations.length} definitions:\n${locationList}`
162    }
163  
164    // Single result - convert LocationLink if needed
165    const location = isLocationLink(result)
166      ? locationLinkToLocation(result)
167      : result
168    return `Defined in ${formatLocation(location, cwd)}`
169  }
170  
171  /**
172   * Formats findReferences result
173   */
174  export function formatFindReferencesResult(
175    result: Location[] | null,
176    cwd?: string,
177  ): string {
178    if (!result || result.length === 0) {
179      return 'No references found. This may occur if the symbol has no usages, or if the LSP server has not fully indexed the workspace.'
180    }
181  
182    // Log and filter out any locations with undefined uris
183    const invalidLocations = result.filter(loc => !loc || !loc.uri)
184    if (invalidLocations.length > 0) {
185      logForDebugging(
186        `formatFindReferencesResult: Filtering out ${invalidLocations.length} invalid location(s) - this should have been caught earlier`,
187        { level: 'warn' },
188      )
189    }
190  
191    const validLocations = result.filter(loc => loc && loc.uri)
192  
193    if (validLocations.length === 0) {
194      return 'No references found. This may occur if the symbol has no usages, or if the LSP server has not fully indexed the workspace.'
195    }
196  
197    if (validLocations.length === 1) {
198      return `Found 1 reference:\n  ${formatLocation(validLocations[0]!, cwd)}`
199    }
200  
201    // Group references by file
202    const byFile = groupByFile(validLocations, cwd)
203  
204    const lines: string[] = [
205      `Found ${validLocations.length} references across ${byFile.size} files:`,
206    ]
207  
208    for (const [filePath, locations] of byFile) {
209      lines.push(`\n${filePath}:`)
210      for (const loc of locations) {
211        const line = loc.range.start.line + 1
212        const character = loc.range.start.character + 1
213        lines.push(`  Line ${line}:${character}`)
214      }
215    }
216  
217    return lines.join('\n')
218  }
219  
220  /**
221   * Extracts text content from MarkupContent or MarkedString
222   */
223  function extractMarkupText(
224    contents: MarkupContent | MarkedString | MarkedString[],
225  ): string {
226    if (Array.isArray(contents)) {
227      return contents
228        .map(item => {
229          if (typeof item === 'string') {
230            return item
231          }
232          return item.value
233        })
234        .join('\n\n')
235    }
236  
237    if (typeof contents === 'string') {
238      return contents
239    }
240  
241    if ('kind' in contents) {
242      // MarkupContent
243      return contents.value
244    }
245  
246    // MarkedString object
247    return contents.value
248  }
249  
250  /**
251   * Formats hover result
252   */
253  export function formatHoverResult(result: Hover | null, _cwd?: string): string {
254    if (!result) {
255      return 'No hover information available. This may occur if the cursor is not on a symbol, or if the LSP server has not fully indexed the file.'
256    }
257  
258    const content = extractMarkupText(result.contents)
259  
260    if (result.range) {
261      const line = result.range.start.line + 1
262      const character = result.range.start.character + 1
263      return `Hover info at ${line}:${character}:\n\n${content}`
264    }
265  
266    return content
267  }
268  
269  /**
270   * Maps SymbolKind enum to readable string
271   */
272  function symbolKindToString(kind: SymbolKind): string {
273    const kinds: Record<SymbolKind, string> = {
274      [1]: 'File',
275      [2]: 'Module',
276      [3]: 'Namespace',
277      [4]: 'Package',
278      [5]: 'Class',
279      [6]: 'Method',
280      [7]: 'Property',
281      [8]: 'Field',
282      [9]: 'Constructor',
283      [10]: 'Enum',
284      [11]: 'Interface',
285      [12]: 'Function',
286      [13]: 'Variable',
287      [14]: 'Constant',
288      [15]: 'String',
289      [16]: 'Number',
290      [17]: 'Boolean',
291      [18]: 'Array',
292      [19]: 'Object',
293      [20]: 'Key',
294      [21]: 'Null',
295      [22]: 'EnumMember',
296      [23]: 'Struct',
297      [24]: 'Event',
298      [25]: 'Operator',
299      [26]: 'TypeParameter',
300    }
301    return kinds[kind] || 'Unknown'
302  }
303  
304  /**
305   * Formats a single DocumentSymbol with indentation
306   */
307  function formatDocumentSymbolNode(
308    symbol: DocumentSymbol,
309    indent: number = 0,
310  ): string[] {
311    const lines: string[] = []
312    const prefix = '  '.repeat(indent)
313    const kind = symbolKindToString(symbol.kind)
314  
315    let line = `${prefix}${symbol.name} (${kind})`
316    if (symbol.detail) {
317      line += ` ${symbol.detail}`
318    }
319  
320    const symbolLine = symbol.range.start.line + 1
321    line += ` - Line ${symbolLine}`
322  
323    lines.push(line)
324  
325    // Recursively format children
326    if (symbol.children && symbol.children.length > 0) {
327      for (const child of symbol.children) {
328        lines.push(...formatDocumentSymbolNode(child, indent + 1))
329      }
330    }
331  
332    return lines
333  }
334  
335  /**
336   * Formats documentSymbol result (hierarchical outline)
337   * Handles both DocumentSymbol[] (hierarchical, with range) and SymbolInformation[] (flat, with location.range)
338   * per LSP spec which allows textDocument/documentSymbol to return either format
339   */
340  export function formatDocumentSymbolResult(
341    result: DocumentSymbol[] | SymbolInformation[] | null,
342    cwd?: string,
343  ): string {
344    if (!result || result.length === 0) {
345      return 'No symbols found in document. This may occur if the file is empty, not supported by the LSP server, or if the server has not fully indexed the file.'
346    }
347  
348    // Detect format: DocumentSymbol has 'range' directly, SymbolInformation has 'location.range'
349    // Check the first valid element to determine format
350    const firstSymbol = result[0]
351    const isSymbolInformation = firstSymbol && 'location' in firstSymbol
352  
353    if (isSymbolInformation) {
354      // Delegate to workspace symbol formatter which handles SymbolInformation[]
355      return formatWorkspaceSymbolResult(result as SymbolInformation[], cwd)
356    }
357  
358    // Handle DocumentSymbol[] format (hierarchical)
359    const lines: string[] = ['Document symbols:']
360  
361    for (const symbol of result as DocumentSymbol[]) {
362      lines.push(...formatDocumentSymbolNode(symbol))
363    }
364  
365    return lines.join('\n')
366  }
367  
368  /**
369   * Formats workspaceSymbol result (flat list of symbols)
370   */
371  export function formatWorkspaceSymbolResult(
372    result: SymbolInformation[] | null,
373    cwd?: string,
374  ): string {
375    if (!result || result.length === 0) {
376      return 'No symbols found in workspace. This may occur if the workspace is empty, or if the LSP server has not finished indexing the project.'
377    }
378  
379    // Log and filter out any symbols with undefined location.uri
380    const invalidSymbols = result.filter(
381      sym => !sym || !sym.location || !sym.location.uri,
382    )
383    if (invalidSymbols.length > 0) {
384      logForDebugging(
385        `formatWorkspaceSymbolResult: Filtering out ${invalidSymbols.length} invalid symbol(s) - this should have been caught earlier`,
386        { level: 'warn' },
387      )
388    }
389  
390    const validSymbols = result.filter(
391      sym => sym && sym.location && sym.location.uri,
392    )
393  
394    if (validSymbols.length === 0) {
395      return 'No symbols found in workspace. This may occur if the workspace is empty, or if the LSP server has not finished indexing the project.'
396    }
397  
398    const lines: string[] = [
399      `Found ${validSymbols.length} ${plural(validSymbols.length, 'symbol')} in workspace:`,
400    ]
401  
402    // Group by file
403    const byFile = groupByFile(validSymbols, cwd)
404  
405    for (const [filePath, symbols] of byFile) {
406      lines.push(`\n${filePath}:`)
407      for (const symbol of symbols) {
408        const kind = symbolKindToString(symbol.kind)
409        const line = symbol.location.range.start.line + 1
410        let symbolLine = `  ${symbol.name} (${kind}) - Line ${line}`
411  
412        // Add container name if available
413        if (symbol.containerName) {
414          symbolLine += ` in ${symbol.containerName}`
415        }
416  
417        lines.push(symbolLine)
418      }
419    }
420  
421    return lines.join('\n')
422  }
423  
424  /**
425   * Formats a CallHierarchyItem with its location
426   * Validates URI before formatting to handle malformed LSP data
427   */
428  function formatCallHierarchyItem(
429    item: CallHierarchyItem,
430    cwd?: string,
431  ): string {
432    // Validate URI - handle undefined/null gracefully
433    if (!item.uri) {
434      logForDebugging(
435        'formatCallHierarchyItem: CallHierarchyItem has undefined URI',
436        { level: 'warn' },
437      )
438      return `${item.name} (${symbolKindToString(item.kind)}) - <unknown location>`
439    }
440  
441    const filePath = formatUri(item.uri, cwd)
442    const line = item.range.start.line + 1
443    const kind = symbolKindToString(item.kind)
444    let result = `${item.name} (${kind}) - ${filePath}:${line}`
445    if (item.detail) {
446      result += ` [${item.detail}]`
447    }
448    return result
449  }
450  
451  /**
452   * Formats prepareCallHierarchy result
453   * Returns the call hierarchy item(s) at the given position
454   */
455  export function formatPrepareCallHierarchyResult(
456    result: CallHierarchyItem[] | null,
457    cwd?: string,
458  ): string {
459    if (!result || result.length === 0) {
460      return 'No call hierarchy item found at this position'
461    }
462  
463    if (result.length === 1) {
464      return `Call hierarchy item: ${formatCallHierarchyItem(result[0]!, cwd)}`
465    }
466  
467    const lines = [`Found ${result.length} call hierarchy items:`]
468    for (const item of result) {
469      lines.push(`  ${formatCallHierarchyItem(item, cwd)}`)
470    }
471    return lines.join('\n')
472  }
473  
474  /**
475   * Formats incomingCalls result
476   * Shows all functions/methods that call the target
477   */
478  export function formatIncomingCallsResult(
479    result: CallHierarchyIncomingCall[] | null,
480    cwd?: string,
481  ): string {
482    if (!result || result.length === 0) {
483      return 'No incoming calls found (nothing calls this function)'
484    }
485  
486    const lines = [
487      `Found ${result.length} incoming ${plural(result.length, 'call')}:`,
488    ]
489  
490    // Group by file
491    const byFile = new Map<string, CallHierarchyIncomingCall[]>()
492    for (const call of result) {
493      if (!call.from) {
494        logForDebugging(
495          'formatIncomingCallsResult: CallHierarchyIncomingCall has undefined from field',
496          { level: 'warn' },
497        )
498        continue
499      }
500      const filePath = formatUri(call.from.uri, cwd)
501      const existing = byFile.get(filePath)
502      if (existing) {
503        existing.push(call)
504      } else {
505        byFile.set(filePath, [call])
506      }
507    }
508  
509    for (const [filePath, calls] of byFile) {
510      lines.push(`\n${filePath}:`)
511      for (const call of calls) {
512        if (!call.from) {
513          continue // Already logged above
514        }
515        const kind = symbolKindToString(call.from.kind)
516        const line = call.from.range.start.line + 1
517        let callLine = `  ${call.from.name} (${kind}) - Line ${line}`
518  
519        // Show call sites within the caller
520        if (call.fromRanges && call.fromRanges.length > 0) {
521          const callSites = call.fromRanges
522            .map(r => `${r.start.line + 1}:${r.start.character + 1}`)
523            .join(', ')
524          callLine += ` [calls at: ${callSites}]`
525        }
526  
527        lines.push(callLine)
528      }
529    }
530  
531    return lines.join('\n')
532  }
533  
534  /**
535   * Formats outgoingCalls result
536   * Shows all functions/methods called by the target
537   */
538  export function formatOutgoingCallsResult(
539    result: CallHierarchyOutgoingCall[] | null,
540    cwd?: string,
541  ): string {
542    if (!result || result.length === 0) {
543      return 'No outgoing calls found (this function calls nothing)'
544    }
545  
546    const lines = [
547      `Found ${result.length} outgoing ${plural(result.length, 'call')}:`,
548    ]
549  
550    // Group by file
551    const byFile = new Map<string, CallHierarchyOutgoingCall[]>()
552    for (const call of result) {
553      if (!call.to) {
554        logForDebugging(
555          'formatOutgoingCallsResult: CallHierarchyOutgoingCall has undefined to field',
556          { level: 'warn' },
557        )
558        continue
559      }
560      const filePath = formatUri(call.to.uri, cwd)
561      const existing = byFile.get(filePath)
562      if (existing) {
563        existing.push(call)
564      } else {
565        byFile.set(filePath, [call])
566      }
567    }
568  
569    for (const [filePath, calls] of byFile) {
570      lines.push(`\n${filePath}:`)
571      for (const call of calls) {
572        if (!call.to) {
573          continue // Already logged above
574        }
575        const kind = symbolKindToString(call.to.kind)
576        const line = call.to.range.start.line + 1
577        let callLine = `  ${call.to.name} (${kind}) - Line ${line}`
578  
579        // Show call sites within the current function
580        if (call.fromRanges && call.fromRanges.length > 0) {
581          const callSites = call.fromRanges
582            .map(r => `${r.start.line + 1}:${r.start.character + 1}`)
583            .join(', ')
584          callLine += ` [called from: ${callSites}]`
585        }
586  
587        lines.push(callLine)
588      }
589    }
590  
591    return lines.join('\n')
592  }