basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import path from 'node:path';8import { fileURLToPath, pathToFileURL } from 'node:url';9import type { ToolInvocation, ToolResult } from './tools.js';10import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';11import { ToolDisplayNames, ToolNames } from './tool-names.js';12import { unescapePath } from '../utils/paths.js';13import type { Config } from '../config/config.js';14import type {15 LspCallHierarchyIncomingCall,16 LspCallHierarchyItem,17 LspCallHierarchyOutgoingCall,18 LspClient,19 LspCodeAction,20 LspCodeActionContext,21 LspCodeActionKind,22 LspDefinition,23 LspDiagnostic,24 LspFileDiagnostics,25 LspLocation,26 LspRange,27 LspReference,28 LspSymbolInformation,29} from '../lsp/types.js';30 31/**32 * Supported LSP operations.33 */34export type LspOperation =35 | 'goToDefinition'36 | 'findReferences'37 | 'hover'38 | 'documentSymbol'39 | 'workspaceSymbol'40 | 'goToImplementation'41 | 'prepareCallHierarchy'42 | 'incomingCalls'43 | 'outgoingCalls'44 | 'diagnostics'45 | 'workspaceDiagnostics'46 | 'codeActions';47 48/**49 * Parameters for the unified LSP tool.50 */51export interface LspToolParams {52 /** Operation to perform. */53 operation: LspOperation;54 /** File path (absolute or workspace-relative). */55 filePath?: string;56 /** 1-based line number when targeting a specific file location. */57 line?: number;58 /** 1-based character/column number when targeting a specific file location. */59 character?: number;60 /** End line for range-based operations (1-based). */61 endLine?: number;62 /** End character for range-based operations (1-based). */63 endCharacter?: number;64 /** Whether to include the declaration in reference results. */65 includeDeclaration?: boolean;66 /** Query string for workspace symbol search. */67 query?: string;68 /** Call hierarchy item from a previous call hierarchy operation. */69 callHierarchyItem?: LspCallHierarchyItem;70 /** Optional server name override. */71 serverName?: string;72 /** Optional maximum number of results. */73 limit?: number;74 /** Diagnostics for code action context. */75 diagnostics?: LspDiagnostic[];76 /** Code action kinds to filter by. */77 codeActionKinds?: LspCodeActionKind[];78}79 80type ResolvedTarget =81 | {82 location: LspLocation;83 description: string;84 }85 | { error: string };86 87/** Operations that require filePath and line. */88const LOCATION_REQUIRED_OPERATIONS = new Set<LspOperation>([89 'goToDefinition',90 'findReferences',91 'hover',92 'goToImplementation',93 'prepareCallHierarchy',94]);95 96/** Operations that only require filePath. */97const FILE_REQUIRED_OPERATIONS = new Set<LspOperation>([98 'documentSymbol',99 'diagnostics',100]);101 102/** Operations that require query. */103const QUERY_REQUIRED_OPERATIONS = new Set<LspOperation>(['workspaceSymbol']);104 105/** Operations that require callHierarchyItem. */106const ITEM_REQUIRED_OPERATIONS = new Set<LspOperation>([107 'incomingCalls',108 'outgoingCalls',109]);110 111/** Operations that require filePath and range for code actions. */112const RANGE_REQUIRED_OPERATIONS = new Set<LspOperation>(['codeActions']);113 114class LspToolInvocation extends BaseToolInvocation<LspToolParams, ToolResult> {115 constructor(116 private readonly config: Config,117 params: LspToolParams,118 ) {119 super(params);120 }121 122 getDescription(): string {123 const operationLabel = this.getOperationLabel();124 if (this.params.operation === 'workspaceSymbol') {125 return `LSP ${operationLabel} for "${this.params.query ?? ''}"`;126 }127 if (this.params.operation === 'documentSymbol') {128 return this.params.filePath129 ? `LSP ${operationLabel} for ${this.params.filePath}`130 : `LSP ${operationLabel}`;131 }132 if (133 this.params.operation === 'incomingCalls' ||134 this.params.operation === 'outgoingCalls'135 ) {136 return `LSP ${operationLabel} for ${this.describeCallHierarchyItemShort()}`;137 }138 if (this.params.filePath && this.params.line !== undefined) {139 return `LSP ${operationLabel} at ${this.params.filePath}:${this.params.line}:${this.params.character ?? 1}`;140 }141 if (this.params.filePath) {142 return `LSP ${operationLabel} for ${this.params.filePath}`;143 }144 return `LSP ${operationLabel}`;145 }146 147 async execute(_signal: AbortSignal): Promise<ToolResult> {148 const client = this.config.getLspClient();149 if (!client || !this.config.isLspEnabled()) {150 const message = `LSP ${this.getOperationLabel()} is unavailable (LSP disabled or not initialized).`;151 return { llmContent: message, returnDisplay: message };152 }153 154 switch (this.params.operation) {155 case 'goToDefinition':156 return this.executeDefinitions(client);157 case 'findReferences':158 return this.executeReferences(client);159 case 'hover':160 return this.executeHover(client);161 case 'documentSymbol':162 return this.executeDocumentSymbols(client);163 case 'workspaceSymbol':164 return this.executeWorkspaceSymbols(client);165 case 'goToImplementation':166 return this.executeImplementations(client);167 case 'prepareCallHierarchy':168 return this.executePrepareCallHierarchy(client);169 case 'incomingCalls':170 return this.executeIncomingCalls(client);171 case 'outgoingCalls':172 return this.executeOutgoingCalls(client);173 case 'diagnostics':174 return this.executeDiagnostics(client);175 case 'workspaceDiagnostics':176 return this.executeWorkspaceDiagnostics(client);177 case 'codeActions':178 return this.executeCodeActions(client);179 default: {180 const message = `Unsupported LSP operation: ${this.params.operation}`;181 return { llmContent: message, returnDisplay: message };182 }183 }184 }185 186 private async executeDefinitions(client: LspClient): Promise<ToolResult> {187 const target = this.resolveLocationTarget();188 if ('error' in target) {189 return { llmContent: target.error, returnDisplay: target.error };190 }191 192 const limit = this.params.limit ?? 20;193 let definitions: LspDefinition[] = [];194 try {195 definitions = await client.definitions(196 target.location,197 this.params.serverName,198 limit,199 );200 } catch (error) {201 const message = `LSP go-to-definition failed: ${202 (error as Error)?.message || String(error)203 }`;204 return { llmContent: message, returnDisplay: message };205 }206 207 if (!definitions.length) {208 const message = `No definitions found for ${target.description}.`;209 return { llmContent: message, returnDisplay: message };210 }211 212 const workspaceRoot = this.config.getProjectRoot();213 const lines = definitions214 .slice(0, limit)215 .map(216 (definition, index) =>217 `${index + 1}. ${this.formatLocationWithServer(definition, workspaceRoot)}`,218 );219 220 const heading = `Definitions for ${target.description}:`;221 return {222 llmContent: [heading, ...lines].join('\n'),223 returnDisplay: lines.join('\n'),224 };225 }226 227 private async executeImplementations(client: LspClient): Promise<ToolResult> {228 const target = this.resolveLocationTarget();229 if ('error' in target) {230 return { llmContent: target.error, returnDisplay: target.error };231 }232 233 const limit = this.params.limit ?? 20;234 let implementations: LspDefinition[] = [];235 try {236 implementations = await client.implementations(237 target.location,238 this.params.serverName,239 limit,240 );241 } catch (error) {242 const message = `LSP go-to-implementation failed: ${243 (error as Error)?.message || String(error)244 }`;245 return { llmContent: message, returnDisplay: message };246 }247 248 if (!implementations.length) {249 const message = `No implementations found for ${target.description}.`;250 return { llmContent: message, returnDisplay: message };251 }252 253 const workspaceRoot = this.config.getProjectRoot();254 const lines = implementations255 .slice(0, limit)256 .map(257 (implementation, index) =>258 `${index + 1}. ${this.formatLocationWithServer(implementation, workspaceRoot)}`,259 );260 261 const heading = `Implementations for ${target.description}:`;262 return {263 llmContent: [heading, ...lines].join('\n'),264 returnDisplay: lines.join('\n'),265 };266 }267 268 private async executeReferences(client: LspClient): Promise<ToolResult> {269 const target = this.resolveLocationTarget();270 if ('error' in target) {271 return { llmContent: target.error, returnDisplay: target.error };272 }273 274 const limit = this.params.limit ?? 50;275 let references: LspReference[] = [];276 try {277 references = await client.references(278 target.location,279 this.params.serverName,280 this.params.includeDeclaration ?? false,281 limit,282 );283 } catch (error) {284 const message = `LSP find-references failed: ${285 (error as Error)?.message || String(error)286 }`;287 return { llmContent: message, returnDisplay: message };288 }289 290 if (!references.length) {291 const message = `No references found for ${target.description}.`;292 return { llmContent: message, returnDisplay: message };293 }294 295 const workspaceRoot = this.config.getProjectRoot();296 const lines = references297 .slice(0, limit)298 .map(299 (reference, index) =>300 `${index + 1}. ${this.formatLocationWithServer(reference, workspaceRoot)}`,301 );302 303 const heading = `References for ${target.description}:`;304 return {305 llmContent: [heading, ...lines].join('\n'),306 returnDisplay: lines.join('\n'),307 };308 }309 310 private async executeHover(client: LspClient): Promise<ToolResult> {311 const target = this.resolveLocationTarget();312 if ('error' in target) {313 return { llmContent: target.error, returnDisplay: target.error };314 }315 316 let hoverText = '';317 try {318 const result = await client.hover(319 target.location,320 this.params.serverName,321 );322 if (result) {323 hoverText = result.contents ?? '';324 }325 } catch (error) {326 const message = `LSP hover failed: ${327 (error as Error)?.message || String(error)328 }`;329 return { llmContent: message, returnDisplay: message };330 }331 332 if (!hoverText || hoverText.trim().length === 0) {333 const message = `No hover information found for ${target.description}.`;334 return { llmContent: message, returnDisplay: message };335 }336 337 const heading = `Hover for ${target.description}:`;338 const content = hoverText.trim();339 return {340 llmContent: `${heading}\n${content}`,341 returnDisplay: content,342 };343 }344 345 private async executeDocumentSymbols(client: LspClient): Promise<ToolResult> {346 const workspaceRoot = this.config.getProjectRoot();347 const filePath = this.params.filePath ?? '';348 const uri = this.resolveUri(filePath, workspaceRoot);349 if (!uri) {350 const message = 'A valid filePath is required for document symbols.';351 return { llmContent: message, returnDisplay: message };352 }353 354 const limit = this.params.limit ?? 50;355 let symbols: LspSymbolInformation[] = [];356 try {357 symbols = await client.documentSymbols(358 uri,359 this.params.serverName,360 limit,361 );362 } catch (error) {363 const message = `LSP document symbols failed: ${364 (error as Error)?.message || String(error)365 }`;366 return { llmContent: message, returnDisplay: message };367 }368 369 if (!symbols.length) {370 const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);371 const message = `No document symbols found for ${fileLabel}.`;372 return { llmContent: message, returnDisplay: message };373 }374 375 const lines = symbols.slice(0, limit).map((symbol, index) => {376 const location = this.formatLocationWithoutServer(377 symbol.location,378 workspaceRoot,379 );380 const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : '';381 const kind = symbol.kind ? ` (${symbol.kind})` : '';382 const container = symbol.containerName383 ? ` in ${symbol.containerName}`384 : '';385 return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;386 });387 388 const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);389 const heading = `Document symbols for ${fileLabel}:`;390 return {391 llmContent: [heading, ...lines].join('\n'),392 returnDisplay: lines.join('\n'),393 };394 }395 396 private async executeWorkspaceSymbols(397 client: LspClient,398 ): Promise<ToolResult> {399 const limit = this.params.limit ?? 20;400 const query = this.params.query ?? '';401 let symbols: LspSymbolInformation[] = [];402 try {403 symbols = await client.workspaceSymbols(query, limit);404 } catch (error) {405 const message = `LSP workspace symbol search failed: ${406 (error as Error)?.message || String(error)407 }`;408 return { llmContent: message, returnDisplay: message };409 }410 411 if (!symbols.length) {412 const message = `No symbols found for query "${query}".`;413 return { llmContent: message, returnDisplay: message };414 }415 416 const workspaceRoot = this.config.getProjectRoot();417 const lines = symbols.slice(0, limit).map((symbol, index) => {418 const location = this.formatLocationWithoutServer(419 symbol.location,420 workspaceRoot,421 );422 const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : '';423 const kind = symbol.kind ? ` (${symbol.kind})` : '';424 const container = symbol.containerName425 ? ` in ${symbol.containerName}`426 : '';427 return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;428 });429 430 const heading = `Found ${Math.min(symbols.length, limit)} of ${431 symbols.length432 } symbols for query "${query}":`;433 434 // Also fetch references for the top match to provide additional context.435 let referenceSection = '';436 const topSymbol = symbols[0];437 if (topSymbol) {438 try {439 const referenceLimit = Math.min(20, Math.max(limit, 5));440 const references = await client.references(441 topSymbol.location,442 topSymbol.serverName,443 false,444 referenceLimit,445 );446 if (references.length > 0) {447 const refLines = references.map((ref, index) => {448 const location = this.formatLocationWithoutServer(449 ref,450 workspaceRoot,451 );452 const serverSuffix = ref.serverName ? ` [${ref.serverName}]` : '';453 return `${index + 1}. ${location}${serverSuffix}`;454 });455 referenceSection = [456 '',457 `References for top match (${topSymbol.name}):`,458 ...refLines,459 ].join('\n');460 }461 } catch (error) {462 referenceSection = `\nReferences lookup failed: ${463 (error as Error)?.message || String(error)464 }`;465 }466 }467 468 const llmParts = referenceSection469 ? [heading, ...lines, referenceSection]470 : [heading, ...lines];471 const displayParts = referenceSection472 ? [...lines, referenceSection]473 : [...lines];474 475 return {476 llmContent: llmParts.join('\n'),477 returnDisplay: displayParts.join('\n'),478 };479 }480 481 private async executePrepareCallHierarchy(482 client: LspClient,483 ): Promise<ToolResult> {484 const target = this.resolveLocationTarget();485 if ('error' in target) {486 return { llmContent: target.error, returnDisplay: target.error };487 }488 489 const limit = this.params.limit ?? 20;490 let items: LspCallHierarchyItem[] = [];491 try {492 items = await client.prepareCallHierarchy(493 target.location,494 this.params.serverName,495 limit,496 );497 } catch (error) {498 const message = `LSP call hierarchy prepare failed: ${499 (error as Error)?.message || String(error)500 }`;501 return { llmContent: message, returnDisplay: message };502 }503 504 if (!items.length) {505 const message = `No call hierarchy items found for ${target.description}.`;506 return { llmContent: message, returnDisplay: message };507 }508 509 const workspaceRoot = this.config.getProjectRoot();510 const slicedItems = items.slice(0, limit);511 const lines = slicedItems.map((item, index) =>512 this.formatCallHierarchyItemLine(item, index, workspaceRoot),513 );514 515 const heading = `Call hierarchy items for ${target.description}:`;516 const jsonSection = this.formatJsonSection(517 'Call hierarchy items (JSON)',518 slicedItems,519 );520 return {521 llmContent: [heading, ...lines].join('\n') + jsonSection,522 returnDisplay: lines.join('\n'),523 };524 }525 526 private async executeIncomingCalls(client: LspClient): Promise<ToolResult> {527 const item = this.params.callHierarchyItem;528 if (!item) {529 const message = 'callHierarchyItem is required for incomingCalls.';530 return { llmContent: message, returnDisplay: message };531 }532 533 const limit = this.params.limit ?? 20;534 const serverName = this.params.serverName ?? item.serverName;535 let calls: LspCallHierarchyIncomingCall[] = [];536 try {537 calls = await client.incomingCalls(item, serverName, limit);538 } catch (error) {539 const message = `LSP incoming calls failed: ${540 (error as Error)?.message || String(error)541 }`;542 return { llmContent: message, returnDisplay: message };543 }544 545 if (!calls.length) {546 const message = `No incoming calls found for ${this.describeCallHierarchyItemFull(547 item,548 )}.`;549 return { llmContent: message, returnDisplay: message };550 }551 552 const workspaceRoot = this.config.getProjectRoot();553 const slicedCalls = calls.slice(0, limit);554 const lines = slicedCalls.map((call, index) => {555 const targetItem = call.from;556 const location = this.formatLocationWithServer(557 {558 uri: targetItem.uri,559 range: targetItem.selectionRange,560 serverName: targetItem.serverName,561 },562 workspaceRoot,563 );564 const kind = targetItem.kind ? ` (${targetItem.kind})` : '';565 const detail = targetItem.detail ? ` ${targetItem.detail}` : '';566 const rangeSuffix = this.formatCallRanges(call.fromRanges);567 return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`;568 });569 570 const heading = `Incoming calls for ${this.describeCallHierarchyItemFull(571 item,572 )}:`;573 const jsonSection = this.formatJsonSection(574 'Incoming calls (JSON)',575 slicedCalls,576 );577 return {578 llmContent: [heading, ...lines].join('\n') + jsonSection,579 returnDisplay: lines.join('\n'),580 };581 }582 583 private async executeOutgoingCalls(client: LspClient): Promise<ToolResult> {584 const item = this.params.callHierarchyItem;585 if (!item) {586 const message = 'callHierarchyItem is required for outgoingCalls.';587 return { llmContent: message, returnDisplay: message };588 }589 590 const limit = this.params.limit ?? 20;591 const serverName = this.params.serverName ?? item.serverName;592 let calls: LspCallHierarchyOutgoingCall[] = [];593 try {594 calls = await client.outgoingCalls(item, serverName, limit);595 } catch (error) {596 const message = `LSP outgoing calls failed: ${597 (error as Error)?.message || String(error)598 }`;599 return { llmContent: message, returnDisplay: message };600 }601 602 if (!calls.length) {603 const message = `No outgoing calls found for ${this.describeCallHierarchyItemFull(604 item,605 )}.`;606 return { llmContent: message, returnDisplay: message };607 }608 609 const workspaceRoot = this.config.getProjectRoot();610 const slicedCalls = calls.slice(0, limit);611 const lines = slicedCalls.map((call, index) => {612 const targetItem = call.to;613 const location = this.formatLocationWithServer(614 {615 uri: targetItem.uri,616 range: targetItem.selectionRange,617 serverName: targetItem.serverName,618 },619 workspaceRoot,620 );621 const kind = targetItem.kind ? ` (${targetItem.kind})` : '';622 const detail = targetItem.detail ? ` ${targetItem.detail}` : '';623 const rangeSuffix = this.formatCallRanges(call.fromRanges);624 return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`;625 });626 627 const heading = `Outgoing calls for ${this.describeCallHierarchyItemFull(628 item,629 )}:`;630 const jsonSection = this.formatJsonSection(631 'Outgoing calls (JSON)',632 slicedCalls,633 );634 return {635 llmContent: [heading, ...lines].join('\n') + jsonSection,636 returnDisplay: lines.join('\n'),637 };638 }639 640 private async executeDiagnostics(client: LspClient): Promise<ToolResult> {641 const workspaceRoot = this.config.getProjectRoot();642 const filePath = this.params.filePath ?? '';643 const uri = this.resolveUri(filePath, workspaceRoot);644 if (!uri) {645 const message = 'A valid filePath is required for diagnostics.';646 return { llmContent: message, returnDisplay: message };647 }648 649 let diagnostics: LspDiagnostic[] = [];650 try {651 diagnostics = await client.diagnostics(uri, this.params.serverName);652 } catch (error) {653 const message = `LSP diagnostics failed: ${654 (error as Error)?.message || String(error)655 }`;656 return { llmContent: message, returnDisplay: message };657 }658 659 if (!diagnostics.length) {660 const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);661 const message = `No diagnostics found for ${fileLabel}.`;662 return { llmContent: message, returnDisplay: message };663 }664 665 const lines = diagnostics.map((diag, index) => {666 const severity = diag.severity ? `[${diag.severity.toUpperCase()}]` : '';667 const position = `${diag.range.start.line + 1}:${diag.range.start.character + 1}`;668 const code = diag.code ? ` (${diag.code})` : '';669 const source = diag.source ? ` [${diag.source}]` : '';670 return `${index + 1}. ${severity} ${position}${code}${source}: ${diag.message}`;671 });672 673 const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);674 const heading = `Diagnostics for ${fileLabel} (${diagnostics.length} issues):`;675 return {676 llmContent: [heading, ...lines].join('\n'),677 returnDisplay: lines.join('\n'),678 };679 }680 681 private async executeWorkspaceDiagnostics(682 client: LspClient,683 ): Promise<ToolResult> {684 const limit = this.params.limit ?? 50;685 let fileDiagnostics: LspFileDiagnostics[] = [];686 try {687 fileDiagnostics = await client.workspaceDiagnostics(688 this.params.serverName,689 limit,690 );691 } catch (error) {692 const message = `LSP workspace diagnostics failed: ${693 (error as Error)?.message || String(error)694 }`;695 return { llmContent: message, returnDisplay: message };696 }697 698 if (!fileDiagnostics.length) {699 const message = 'No diagnostics found in the workspace.';700 return { llmContent: message, returnDisplay: message };701 }702 703 const workspaceRoot = this.config.getProjectRoot();704 const lines: string[] = [];705 let totalIssues = 0;706 707 for (const fileDiag of fileDiagnostics) {708 const fileLabel = this.formatUriForDisplay(fileDiag.uri, workspaceRoot);709 const serverSuffix = fileDiag.serverName710 ? ` [${fileDiag.serverName}]`711 : '';712 lines.push(`\n${fileLabel}${serverSuffix}:`);713 714 for (const diag of fileDiag.diagnostics) {715 const severity = diag.severity716 ? `[${diag.severity.toUpperCase()}]`717 : '';718 const position = `${diag.range.start.line + 1}:${diag.range.start.character + 1}`;719 const code = diag.code ? ` (${diag.code})` : '';720 lines.push(` ${severity} ${position}${code}: ${diag.message}`);721 totalIssues++;722 }723 }724 725 const heading = `Workspace diagnostics (${totalIssues} issues in ${fileDiagnostics.length} files):`;726 return {727 llmContent: [heading, ...lines].join('\n'),728 returnDisplay: lines.join('\n'),729 };730 }731 732 private async executeCodeActions(client: LspClient): Promise<ToolResult> {733 const workspaceRoot = this.config.getProjectRoot();734 const filePath = this.params.filePath ?? '';735 const uri = this.resolveUri(filePath, workspaceRoot);736 if (!uri) {737 const message = 'A valid filePath is required for code actions.';738 return { llmContent: message, returnDisplay: message };739 }740 741 // Build range from params742 const startLine = Math.max(0, (this.params.line ?? 1) - 1);743 const startChar = Math.max(0, (this.params.character ?? 1) - 1);744 const endLine = Math.max(745 0,746 (this.params.endLine ?? this.params.line ?? 1) - 1,747 );748 const endChar = Math.max(749 0,750 (this.params.endCharacter ?? this.params.character ?? 1) - 1,751 );752 753 const range: LspRange = {754 start: { line: startLine, character: startChar },755 end: { line: endLine, character: endChar },756 };757 758 // Build context759 const context: LspCodeActionContext = {760 diagnostics: this.params.diagnostics ?? [],761 only: this.params.codeActionKinds,762 triggerKind: 'invoked',763 };764 765 const limit = this.params.limit ?? 20;766 let actions: LspCodeAction[] = [];767 try {768 actions = await client.codeActions(769 uri,770 range,771 context,772 this.params.serverName,773 limit,774 );775 } catch (error) {776 const message = `LSP code actions failed: ${777 (error as Error)?.message || String(error)778 }`;779 return { llmContent: message, returnDisplay: message };780 }781 782 if (!actions.length) {783 const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);784 const message = `No code actions available at ${fileLabel}:${startLine + 1}:${startChar + 1}.`;785 return { llmContent: message, returnDisplay: message };786 }787 788 const lines = actions.slice(0, limit).map((action, index) => {789 const kind = action.kind ? ` [${action.kind}]` : '';790 const preferred = action.isPreferred ? ' ★' : '';791 const hasEdit = action.edit ? ' (has edit)' : '';792 const hasCommand = action.command ? ' (has command)' : '';793 const serverSuffix = action.serverName ? ` [${action.serverName}]` : '';794 return `${index + 1}. ${action.title}${kind}${preferred}${hasEdit}${hasCommand}${serverSuffix}`;795 });796 797 const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);798 const heading = `Code actions at ${fileLabel}:${startLine + 1}:${startChar + 1}:`;799 const jsonSection = this.formatJsonSection(800 'Code actions (JSON)',801 actions.slice(0, limit),802 );803 return {804 llmContent: [heading, ...lines].join('\n') + jsonSection,805 returnDisplay: lines.join('\n'),806 };807 }808 809 private resolveLocationTarget(): ResolvedTarget {810 const filePath = this.params.filePath;811 if (!filePath) {812 return {813 error: 'filePath is required for this operation.',814 };815 }816 if (typeof this.params.line !== 'number') {817 return {818 error: 'line is required for this operation.',819 };820 }821 822 const workspaceRoot = this.config.getProjectRoot();823 const uri = this.resolveUri(filePath, workspaceRoot);824 if (!uri) {825 return {826 error: 'A valid filePath is required when specifying a line/character.',827 };828 }829 830 const position = {831 line: Math.max(0, Math.floor(this.params.line - 1)),832 character: Math.max(0, Math.floor((this.params.character ?? 1) - 1)),833 };834 const location: LspLocation = {835 uri,836 range: { start: position, end: position },837 };838 const description = this.formatLocationWithServer(839 { ...location, serverName: this.params.serverName },840 workspaceRoot,841 );842 return {843 location,844 description,845 };846 }847 848 private resolveUri(filePath: string, workspaceRoot: string): string | null {849 if (!filePath) {850 return null;851 }852 if (filePath.startsWith('file://') || filePath.includes('://')) {853 return filePath;854 }855 const absolutePath = path.isAbsolute(filePath)856 ? filePath857 : path.resolve(workspaceRoot, filePath);858 return pathToFileURL(absolutePath).toString();859 }860 861 private formatLocationWithServer(862 location: LspLocation & { serverName?: string },863 workspaceRoot: string,864 ): string {865 const start = location.range.start;866 let filePath = location.uri;867 868 if (filePath.startsWith('file://')) {869 filePath = fileURLToPath(filePath);870 filePath = path.relative(workspaceRoot, filePath) || '.';871 }872 873 const serverSuffix =874 location.serverName && location.serverName !== ''875 ? ` [${location.serverName}]`876 : '';877 878 return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;879 }880 881 private formatLocationWithoutServer(882 location: LspLocation,883 workspaceRoot: string,884 ): string {885 const { uri, range } = location;886 let filePath = uri;887 if (uri.startsWith('file://')) {888 filePath = fileURLToPath(uri);889 filePath = path.relative(workspaceRoot, filePath) || '.';890 }891 const line = (range.start.line ?? 0) + 1;892 const character = (range.start.character ?? 0) + 1;893 return `${filePath}:${line}:${character}`;894 }895 896 private formatCallHierarchyItemLine(897 item: LspCallHierarchyItem,898 index: number,899 workspaceRoot: string,900 ): string {901 const location = this.formatLocationWithServer(902 {903 uri: item.uri,904 range: item.selectionRange,905 serverName: item.serverName,906 },907 workspaceRoot,908 );909 const kind = item.kind ? ` (${item.kind})` : '';910 const detail = item.detail ? ` ${item.detail}` : '';911 return `${index + 1}. ${item.name}${kind}${detail} - ${location}`;912 }913 914 private formatCallRanges(ranges: LspRange[]): string {915 if (!ranges.length) {916 return '';917 }918 const formatted = ranges.map((range) => this.formatPosition(range.start));919 const maxShown = 3;920 const shown = formatted.slice(0, maxShown);921 const extra =922 formatted.length > maxShown923 ? `, +${formatted.length - maxShown} more`924 : '';925 return ` (calls at ${shown.join(', ')}${extra})`;926 }927 928 private formatPosition(position: LspRange['start']): string {929 return `${(position.line ?? 0) + 1}:${(position.character ?? 0) + 1}`;930 }931 932 private formatUriForDisplay(uri: string, workspaceRoot: string): string {933 let filePath = uri;934 if (uri.startsWith('file://')) {935 filePath = fileURLToPath(uri);936 }937 if (path.isAbsolute(filePath)) {938 return path.relative(workspaceRoot, filePath) || '.';939 }940 return filePath;941 }942 943 private formatJsonSection(label: string, data: unknown): string {944 return `\n\n${label}:\n${JSON.stringify(data, null, 2)}`;945 }946 947 private describeCallHierarchyItemShort(): string {948 const item = this.params.callHierarchyItem;949 if (!item) {950 return 'call hierarchy item';951 }952 return item.name || 'call hierarchy item';953 }954 955 private describeCallHierarchyItemFull(item: LspCallHierarchyItem): string {956 const workspaceRoot = this.config.getProjectRoot();957 const location = this.formatLocationWithServer(958 {959 uri: item.uri,960 range: item.selectionRange,961 serverName: item.serverName,962 },963 workspaceRoot,964 );965 return `${item.name} at ${location}`;966 }967 968 private getOperationLabel(): string {969 switch (this.params.operation) {970 case 'goToDefinition':971 return 'go-to-definition';972 case 'findReferences':973 return 'find-references';974 case 'hover':975 return 'hover';976 case 'documentSymbol':977 return 'document symbols';978 case 'workspaceSymbol':979 return 'workspace symbol search';980 case 'goToImplementation':981 return 'go-to-implementation';982 case 'prepareCallHierarchy':983 return 'prepare call hierarchy';984 case 'incomingCalls':985 return 'incoming calls';986 case 'outgoingCalls':987 return 'outgoing calls';988 case 'diagnostics':989 return 'diagnostics';990 case 'workspaceDiagnostics':991 return 'workspace diagnostics';992 case 'codeActions':993 return 'code actions';994 default:995 return this.params.operation;996 }997 }998}999 1000/**1001 * Unified LSP tool that supports multiple operations:1002 * - goToDefinition: Find where a symbol is defined1003 * - findReferences: Find all references to a symbol1004 * - hover: Get hover information (documentation, type info)1005 * - documentSymbol: Get all symbols in a document1006 * - workspaceSymbol: Search for symbols across the workspace1007 * - goToImplementation: Find implementations of an interface or abstract method1008 * - prepareCallHierarchy: Get call hierarchy item at a position1009 * - incomingCalls: Find all functions that call the given function1010 * - outgoingCalls: Find all functions called by the given function1011 * - diagnostics: Get diagnostic messages (errors, warnings) for a file1012 * - workspaceDiagnostics: Get all diagnostic messages across the workspace1013 * - codeActions: Get available code actions (quick fixes, refactorings) at a location1014 */1015export class LspTool extends BaseDeclarativeTool<LspToolParams, ToolResult> {1016 static readonly Name = ToolNames.LSP;1017 1018 constructor(private readonly config: Config) {1019 super(1020 LspTool.Name,1021 ToolDisplayNames.LSP,1022 'Language Server Protocol (LSP) tool for code intelligence: definitions, references, hover, symbols, call hierarchy, diagnostics, and code actions.\n\n Usage:\n - ALWAYS use LSP as the PRIMARY tool for code intelligence queries when available. Do NOT use grep_search or glob first.\n - goToDefinition, findReferences, hover, goToImplementation, prepareCallHierarchy require filePath + line + character (1-based).\n - documentSymbol and diagnostics require filePath.\n - workspaceSymbol requires query (use when user asks "where is X defined?" without specifying a file).\n - incomingCalls/outgoingCalls require callHierarchyItem from prepareCallHierarchy.\n - workspaceDiagnostics needs no parameters.\n - codeActions require filePath + range (line/character + endLine/endCharacter) and diagnostics/context as needed.',1023 Kind.Other,1024 {1025 type: 'object',1026 properties: {1027 operation: {1028 type: 'string',1029 description: 'LSP operation to execute.',1030 enum: [1031 'goToDefinition',1032 'findReferences',1033 'hover',1034 'documentSymbol',1035 'workspaceSymbol',1036 'goToImplementation',1037 'prepareCallHierarchy',1038 'incomingCalls',1039 'outgoingCalls',1040 'diagnostics',1041 'workspaceDiagnostics',1042 'codeActions',1043 ],1044 },1045 filePath: {1046 type: 'string',1047 description: 'File path (absolute or workspace-relative).',1048 },1049 line: {1050 type: 'number',1051 description: '1-based line number for the target location.',1052 },1053 character: {1054 type: 'number',1055 description:1056 '1-based character/column number for the target location.',1057 },1058 endLine: {1059 type: 'number',1060 description: '1-based end line number for range-based operations.',1061 },1062 endCharacter: {1063 type: 'number',1064 description: '1-based end character for range-based operations.',1065 },1066 includeDeclaration: {1067 type: 'boolean',1068 description:1069 'Include the declaration itself when looking up references.',1070 },1071 query: {1072 type: 'string',1073 description: 'Symbol query for workspace symbol search.',1074 },1075 callHierarchyItem: {1076 $ref: '#/definitions/LspCallHierarchyItem',1077 description: 'Call hierarchy item for incoming/outgoing calls.',1078 },1079 serverName: {1080 type: 'string',1081 description: 'Optional LSP server name to target.',1082 },1083 limit: {1084 type: 'integer',1085 minimum: 1,1086 description: 'Optional maximum number of results to return.',1087 },1088 diagnostics: {1089 type: 'array',1090 items: { $ref: '#/definitions/LspDiagnostic' },1091 description: 'Diagnostics for code action context.',1092 },1093 codeActionKinds: {1094 type: 'array',1095 items: { type: 'string' },1096 description:1097 'Filter code actions by kind (quickfix, refactor, etc.).',1098 },1099 },1100 required: ['operation'],1101 definitions: {1102 LspPosition: {1103 type: 'object',1104 properties: {1105 line: { type: 'number' },1106 character: { type: 'number' },1107 },1108 required: ['line', 'character'],1109 },1110 LspRange: {1111 type: 'object',1112 properties: {1113 start: { $ref: '#/definitions/LspPosition' },1114 end: { $ref: '#/definitions/LspPosition' },1115 },1116 required: ['start', 'end'],1117 },1118 LspCallHierarchyItem: {1119 type: 'object',1120 properties: {1121 name: { type: 'string' },1122 kind: { type: 'string' },1123 rawKind: { type: 'number' },1124 detail: { type: 'string' },1125 uri: { type: 'string' },1126 range: { $ref: '#/definitions/LspRange' },1127 selectionRange: { $ref: '#/definitions/LspRange' },1128 data: {},1129 serverName: { type: 'string' },1130 },1131 required: ['name', 'uri', 'range', 'selectionRange'],1132 },1133 LspDiagnostic: {1134 type: 'object',1135 properties: {1136 range: { $ref: '#/definitions/LspRange' },1137 severity: {1138 type: 'string',1139 enum: ['error', 'warning', 'information', 'hint'],1140 },1141 code: { type: ['string', 'number'] },1142 source: { type: 'string' },1143 message: { type: 'string' },1144 serverName: { type: 'string' },1145 },1146 required: ['range', 'message'],1147 },1148 },1149 },1150 false, // isOutputMarkdown1151 false, // canUpdateOutput1152 true, // shouldDefer — loaded on demand via ToolSearch1153 false, // alwaysLoad1154 'lsp language server definition references hover symbol diagnostics code actions',1155 );1156 }1157 1158 protected override validateToolParamValues(1159 params: LspToolParams,1160 ): string | null {1161 const operation = params.operation;1162 1163 // Normalize shell-escaped paths (e.g. "my\ file.txt" → "my file.txt")1164 if (params.filePath) {1165 params.filePath = unescapePath(params.filePath.trim());1166 }1167 1168 if (LOCATION_REQUIRED_OPERATIONS.has(operation)) {1169 if (!params.filePath) {1170 return `filePath is required for ${operation}.`;1171 }1172 if (typeof params.line !== 'number') {1173 return `line is required for ${operation}.`;1174 }1175 }1176 1177 if (FILE_REQUIRED_OPERATIONS.has(operation)) {1178 if (!params.filePath) {1179 return `filePath is required for ${operation}.`;1180 }1181 }1182 1183 if (QUERY_REQUIRED_OPERATIONS.has(operation)) {1184 if (!params.query || params.query.trim() === '') {1185 return `query is required for ${operation}.`;1186 }1187 }1188 1189 if (ITEM_REQUIRED_OPERATIONS.has(operation)) {1190 if (!params.callHierarchyItem) {1191 return `callHierarchyItem is required for ${operation}.`;1192 }1193 }1194 1195 if (RANGE_REQUIRED_OPERATIONS.has(operation)) {1196 if (!params.filePath) {1197 return `filePath is required for ${operation}.`;1198 }1199 if (typeof params.line !== 'number') {1200 return `line is required for ${operation}.`;