source dump of claude code
at main 592 lines 17 kB view raw
1import { relative } from 'path' 2import 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' 15import { logForDebugging } from '../../utils/debug.js' 16import { errorMessage } from '../../utils/errors.js' 17import { 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 */ 24function 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 */ 78function 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 */ 99function 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 */ 109function 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 */ 119function 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 */ 127export 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 */ 174export 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 */ 223function 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 */ 253export 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 */ 272function 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 */ 307function 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 */ 340export 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 */ 371export 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 */ 428function 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 */ 455export 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 */ 478export 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 */ 538export 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}