From caf7aac116a13059377bb87471f2617c6ff3b748 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 1 Dec 2025 02:05:27 +0500 Subject: [PATCH] feat(ipuaro): add search tools (v0.7.0) --- packages/ipuaro/CHANGELOG.md | 30 + packages/ipuaro/package.json | 2 +- .../ipuaro/src/infrastructure/tools/index.ts | 12 + .../tools/search/FindDefinitionTool.ts | 221 +++++++ .../tools/search/FindReferencesTool.ts | 260 ++++++++ .../src/infrastructure/tools/search/index.ts | 12 + .../tools/search/FindDefinitionTool.test.ts | 534 +++++++++++++++++ .../tools/search/FindReferencesTool.test.ts | 564 ++++++++++++++++++ 8 files changed, 1634 insertions(+), 1 deletion(-) create mode 100644 packages/ipuaro/src/infrastructure/tools/search/FindDefinitionTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/search/FindReferencesTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/search/index.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/search/FindDefinitionTool.test.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/search/FindReferencesTool.test.ts diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 7ea6a42..0e9fd91 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2025-12-01 - Search Tools + +### Added + +- **FindReferencesTool (0.7.1)** + - `find_references(symbol, path?)`: Find all usages of a symbol across the codebase + - Word boundary matching with support for special characters (e.g., `$value`) + - Context lines around each reference (1 line before/after) + - Marks definition vs usage references + - Optional path filter for scoped searches + - Returns: path, line, column, context, isDefinition + - 37 unit tests + +- **FindDefinitionTool (0.7.2)** + - `find_definition(symbol)`: Find where a symbol is defined + - Uses SymbolIndex for fast lookups + - Returns multiple definitions (for overloads/re-exports) + - Suggests similar symbols when not found (Levenshtein distance) + - Context lines around definition (2 lines before/after) + - Returns: path, line, type, context + - 32 unit tests + +### Changed + +- Total tests: 733 (was 664) +- Coverage: 97.71% lines, 91.84% branches +- Search tools category now fully implemented (2/2 tools) + +--- + ## [0.6.0] - 2025-12-01 - Edit Tools ### Added diff --git a/packages/ipuaro/package.json b/packages/ipuaro/package.json index 7c99222..dfc9aa8 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/ipuaro", - "version": "0.6.0", + "version": "0.7.0", "description": "Local AI agent for codebase operations with infinite context feeling", "author": "Fozilbek Samiyev ", "license": "MIT", diff --git a/packages/ipuaro/src/infrastructure/tools/index.ts b/packages/ipuaro/src/infrastructure/tools/index.ts index 60ea147..c66d491 100644 --- a/packages/ipuaro/src/infrastructure/tools/index.ts +++ b/packages/ipuaro/src/infrastructure/tools/index.ts @@ -15,3 +15,15 @@ export { export { EditLinesTool, type EditLinesResult } from "./edit/EditLinesTool.js" export { CreateFileTool, type CreateFileResult } from "./edit/CreateFileTool.js" export { DeleteFileTool, type DeleteFileResult } from "./edit/DeleteFileTool.js" + +// Search tools +export { + FindReferencesTool, + type FindReferencesResult, + type SymbolReference, +} from "./search/FindReferencesTool.js" +export { + FindDefinitionTool, + type FindDefinitionResult, + type DefinitionLocation, +} from "./search/FindDefinitionTool.js" diff --git a/packages/ipuaro/src/infrastructure/tools/search/FindDefinitionTool.ts b/packages/ipuaro/src/infrastructure/tools/search/FindDefinitionTool.ts new file mode 100644 index 0000000..daa9c7b --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/search/FindDefinitionTool.ts @@ -0,0 +1,221 @@ +import { promises as fs } from "node:fs" +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import type { SymbolLocation } from "../../../domain/services/IStorage.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * A single definition location with context. + */ +export interface DefinitionLocation { + path: string + line: number + type: SymbolLocation["type"] + context: string +} + +/** + * Result data from find_definition tool. + */ +export interface FindDefinitionResult { + symbol: string + found: boolean + definitions: DefinitionLocation[] + suggestions?: string[] +} + +/** + * Tool for finding where a symbol is defined. + * Uses the SymbolIndex to locate definitions. + */ +export class FindDefinitionTool implements ITool { + readonly name = "find_definition" + readonly description = + "Find where a symbol is defined. " + "Returns file path, line number, and symbol type." + readonly parameters: ToolParameterSchema[] = [ + { + name: "symbol", + type: "string", + description: "Symbol name to find definition for", + required: true, + }, + ] + readonly requiresConfirmation = false + readonly category = "search" as const + + private readonly contextLines = 2 + + validateParams(params: Record): string | null { + if (typeof params.symbol !== "string" || params.symbol.trim() === "") { + return "Parameter 'symbol' is required and must be a non-empty string" + } + + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const symbol = (params.symbol as string).trim() + + try { + const symbolIndex = await ctx.storage.getSymbolIndex() + const locations = symbolIndex.get(symbol) + + if (!locations || locations.length === 0) { + const suggestions = this.findSimilarSymbols(symbol, symbolIndex) + return createSuccessResult( + callId, + { + symbol, + found: false, + definitions: [], + suggestions: suggestions.length > 0 ? suggestions : undefined, + } satisfies FindDefinitionResult, + Date.now() - startTime, + ) + } + + const definitions: DefinitionLocation[] = [] + for (const loc of locations) { + const context = await this.getContext(loc, ctx) + definitions.push({ + path: loc.path, + line: loc.line, + type: loc.type, + context, + }) + } + + definitions.sort((a, b) => { + const pathCompare = a.path.localeCompare(b.path) + if (pathCompare !== 0) { + return pathCompare + } + return a.line - b.line + }) + + const result: FindDefinitionResult = { + symbol, + found: true, + definitions, + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Get context lines around the definition. + */ + private async getContext(loc: SymbolLocation, ctx: ToolContext): Promise { + try { + const lines = await this.getFileLines(loc.path, ctx) + if (lines.length === 0) { + return "" + } + + const lineIndex = loc.line - 1 + const startIndex = Math.max(0, lineIndex - this.contextLines) + const endIndex = Math.min(lines.length - 1, lineIndex + this.contextLines) + + const contextLines: string[] = [] + for (let i = startIndex; i <= endIndex; i++) { + const lineNum = i + 1 + const prefix = i === lineIndex ? ">" : " " + contextLines.push(`${prefix}${String(lineNum).padStart(4)}│${lines[i]}`) + } + + return contextLines.join("\n") + } catch { + return "" + } + } + + /** + * Get file lines from storage or filesystem. + */ + private async getFileLines(relativePath: string, ctx: ToolContext): Promise { + const fileData = await ctx.storage.getFile(relativePath) + if (fileData) { + return fileData.lines + } + + const absolutePath = path.resolve(ctx.projectRoot, relativePath) + try { + const content = await fs.readFile(absolutePath, "utf-8") + return content.split("\n") + } catch { + return [] + } + } + + /** + * Find similar symbol names for suggestions. + */ + private findSimilarSymbols(symbol: string, symbolIndex: Map): string[] { + const suggestions: string[] = [] + const lowerSymbol = symbol.toLowerCase() + const maxSuggestions = 5 + + for (const name of symbolIndex.keys()) { + if (suggestions.length >= maxSuggestions) { + break + } + + const lowerName = name.toLowerCase() + if (lowerName.includes(lowerSymbol) || lowerSymbol.includes(lowerName)) { + suggestions.push(name) + } else if (this.levenshteinDistance(lowerSymbol, lowerName) <= 2) { + suggestions.push(name) + } + } + + return suggestions.sort() + } + + /** + * Calculate Levenshtein distance between two strings. + */ + private levenshteinDistance(a: string, b: string): number { + if (a.length === 0) { + return b.length + } + if (b.length === 0) { + return a.length + } + + const matrix: number[][] = [] + + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i] + } + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j + } + + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1] + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1, + ) + } + } + } + + return matrix[b.length][a.length] + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/search/FindReferencesTool.ts b/packages/ipuaro/src/infrastructure/tools/search/FindReferencesTool.ts new file mode 100644 index 0000000..f740aac --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/search/FindReferencesTool.ts @@ -0,0 +1,260 @@ +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * A single reference to a symbol. + */ +export interface SymbolReference { + path: string + line: number + column: number + context: string + isDefinition: boolean +} + +/** + * Result data from find_references tool. + */ +export interface FindReferencesResult { + symbol: string + totalReferences: number + files: number + references: SymbolReference[] + definitionLocations: { + path: string + line: number + type: string + }[] +} + +/** + * Tool for finding all usages of a symbol across the codebase. + * Searches through indexed files for symbol references. + */ +export class FindReferencesTool implements ITool { + readonly name = "find_references" + readonly description = + "Find all usages of a symbol across the codebase. " + + "Returns list of file paths, line numbers, and context." + readonly parameters: ToolParameterSchema[] = [ + { + name: "symbol", + type: "string", + description: "Symbol name to search for (function, class, variable, etc.)", + required: true, + }, + { + name: "path", + type: "string", + description: "Limit search to specific file or directory", + required: false, + }, + ] + readonly requiresConfirmation = false + readonly category = "search" as const + + private readonly contextLines = 1 + + validateParams(params: Record): string | null { + if (typeof params.symbol !== "string" || params.symbol.trim() === "") { + return "Parameter 'symbol' is required and must be a non-empty string" + } + + if (params.path !== undefined && typeof params.path !== "string") { + return "Parameter 'path' must be a string" + } + + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const symbol = (params.symbol as string).trim() + const filterPath = params.path as string | undefined + + try { + const symbolIndex = await ctx.storage.getSymbolIndex() + const definitionLocations = symbolIndex.get(symbol) ?? [] + + const allFiles = await ctx.storage.getAllFiles() + const filesToSearch = this.filterFiles(allFiles, filterPath, ctx.projectRoot) + + if (filesToSearch.size === 0) { + return createSuccessResult( + callId, + { + symbol, + totalReferences: 0, + files: 0, + references: [], + definitionLocations: definitionLocations.map((loc) => ({ + path: loc.path, + line: loc.line, + type: loc.type, + })), + } satisfies FindReferencesResult, + Date.now() - startTime, + ) + } + + const references: SymbolReference[] = [] + const filesWithReferences = new Set() + + for (const [filePath, fileData] of filesToSearch) { + const fileRefs = this.findReferencesInFile( + filePath, + fileData.lines, + symbol, + definitionLocations, + ) + + if (fileRefs.length > 0) { + filesWithReferences.add(filePath) + references.push(...fileRefs) + } + } + + references.sort((a, b) => { + const pathCompare = a.path.localeCompare(b.path) + if (pathCompare !== 0) { + return pathCompare + } + return a.line - b.line + }) + + const result: FindReferencesResult = { + symbol, + totalReferences: references.length, + files: filesWithReferences.size, + references, + definitionLocations: definitionLocations.map((loc) => ({ + path: loc.path, + line: loc.line, + type: loc.type, + })), + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Filter files by path prefix if specified. + */ + private filterFiles( + allFiles: Map, + filterPath: string | undefined, + projectRoot: string, + ): Map { + if (!filterPath) { + return allFiles + } + + const normalizedFilter = filterPath.startsWith("/") + ? path.relative(projectRoot, filterPath) + : filterPath + + const filtered = new Map() + for (const [filePath, fileData] of allFiles) { + if (filePath === normalizedFilter || filePath.startsWith(`${normalizedFilter}/`)) { + filtered.set(filePath, fileData) + } + } + + return filtered + } + + /** + * Find all references to the symbol in a file. + */ + private findReferencesInFile( + filePath: string, + lines: string[], + symbol: string, + definitionLocations: { path: string; line: number }[], + ): SymbolReference[] { + const references: SymbolReference[] = [] + const symbolRegex = this.createSymbolRegex(symbol) + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex] + const lineNumber = lineIndex + 1 + let match: RegExpExecArray | null + + symbolRegex.lastIndex = 0 + while ((match = symbolRegex.exec(line)) !== null) { + const column = match.index + 1 + const context = this.buildContext(lines, lineIndex) + const isDefinition = this.isDefinitionLine( + filePath, + lineNumber, + definitionLocations, + ) + + references.push({ + path: filePath, + line: lineNumber, + column, + context, + isDefinition, + }) + } + } + + return references + } + + /** + * Create a regex for matching the symbol with appropriate boundaries. + * Handles symbols that start or end with non-word characters (like $value). + */ + private createSymbolRegex(symbol: string): RegExp { + const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + + const startsWithWordChar = /^\w/.test(symbol) + const endsWithWordChar = /\w$/.test(symbol) + + const prefix = startsWithWordChar ? "\\b" : "(?" : " " + contextLines.push(`${prefix}${String(lineNum).padStart(4)}│${lines[i]}`) + } + + return contextLines.join("\n") + } + + /** + * Check if this line is a definition location. + */ + private isDefinitionLine( + filePath: string, + lineNumber: number, + definitionLocations: { path: string; line: number }[], + ): boolean { + return definitionLocations.some((loc) => loc.path === filePath && loc.line === lineNumber) + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/search/index.ts b/packages/ipuaro/src/infrastructure/tools/search/index.ts new file mode 100644 index 0000000..7e483e7 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/search/index.ts @@ -0,0 +1,12 @@ +// Search tools exports +export { + FindReferencesTool, + type FindReferencesResult, + type SymbolReference, +} from "./FindReferencesTool.js" + +export { + FindDefinitionTool, + type FindDefinitionResult, + type DefinitionLocation, +} from "./FindDefinitionTool.js" diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/search/FindDefinitionTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/search/FindDefinitionTool.test.ts new file mode 100644 index 0000000..d7c1068 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/search/FindDefinitionTool.test.ts @@ -0,0 +1,534 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + FindDefinitionTool, + type FindDefinitionResult, +} from "../../../../../src/infrastructure/tools/search/FindDefinitionTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { + IStorage, + SymbolIndex, + SymbolLocation, +} from "../../../../../src/domain/services/IStorage.js" +import type { FileData } from "../../../../../src/domain/value-objects/FileData.js" + +function createMockFileData(lines: string[]): FileData { + return { + lines, + hash: "abc123", + size: lines.join("\n").length, + lastModified: Date.now(), + } +} + +function createMockStorage( + files: Map = new Map(), + symbolIndex: SymbolIndex = new Map(), +): IStorage { + return { + getFile: vi.fn().mockImplementation((p: string) => Promise.resolve(files.get(p) ?? null)), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn().mockResolvedValue(files), + getFileCount: vi.fn().mockResolvedValue(files.size), + getAST: vi.fn().mockResolvedValue(null), + setAST: vi.fn(), + deleteAST: vi.fn(), + getAllASTs: vi.fn().mockResolvedValue(new Map()), + getMeta: vi.fn().mockResolvedValue(null), + setMeta: vi.fn(), + deleteMeta: vi.fn(), + getAllMetas: vi.fn().mockResolvedValue(new Map()), + getSymbolIndex: vi.fn().mockResolvedValue(symbolIndex), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("FindDefinitionTool", () => { + let tool: FindDefinitionTool + + beforeEach(() => { + tool = new FindDefinitionTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("find_definition") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("search") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(1) + expect(tool.parameters[0].name).toBe("symbol") + expect(tool.parameters[0].required).toBe(true) + }) + + it("should have description", () => { + expect(tool.description).toContain("Find where a symbol is defined") + }) + }) + + describe("validateParams", () => { + it("should return null for valid params", () => { + expect(tool.validateParams({ symbol: "myFunction" })).toBeNull() + }) + + it("should return error for missing symbol", () => { + expect(tool.validateParams({})).toBe( + "Parameter 'symbol' is required and must be a non-empty string", + ) + }) + + it("should return error for empty symbol", () => { + expect(tool.validateParams({ symbol: "" })).toBe( + "Parameter 'symbol' is required and must be a non-empty string", + ) + }) + + it("should return error for whitespace-only symbol", () => { + expect(tool.validateParams({ symbol: " " })).toBe( + "Parameter 'symbol' is required and must be a non-empty string", + ) + }) + }) + + describe("execute", () => { + it("should find function definition", async () => { + const files = new Map([ + [ + "src/utils.ts", + createMockFileData([ + "// Utility functions", + "export function myFunction() {", + " return 42", + "}", + ]), + ], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["myFunction", [{ path: "src/utils.ts", line: 2, type: "function" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "myFunction" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.symbol).toBe("myFunction") + expect(data.found).toBe(true) + expect(data.definitions).toHaveLength(1) + expect(data.definitions[0].path).toBe("src/utils.ts") + expect(data.definitions[0].line).toBe(2) + expect(data.definitions[0].type).toBe("function") + }) + + it("should find class definition", async () => { + const files = new Map([ + [ + "src/models.ts", + createMockFileData([ + "export class User {", + " constructor(public name: string) {}", + "}", + ]), + ], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["User", [{ path: "src/models.ts", line: 1, type: "class" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "User" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(true) + expect(data.definitions[0].type).toBe("class") + }) + + it("should find interface definition", async () => { + const files = new Map([ + [ + "src/types.ts", + createMockFileData(["export interface Config {", " port: number", "}"]), + ], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["Config", [{ path: "src/types.ts", line: 1, type: "interface" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "Config" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(true) + expect(data.definitions[0].type).toBe("interface") + }) + + it("should find type alias definition", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["ID", [{ path: "src/types.ts", line: 1, type: "type" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "ID" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(true) + expect(data.definitions[0].type).toBe("type") + }) + + it("should find variable definition", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["DEFAULT_CONFIG", [{ path: "src/config.ts", line: 5, type: "variable" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "DEFAULT_CONFIG" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(true) + expect(data.definitions[0].type).toBe("variable") + }) + + it("should find multiple definitions (function overloads)", async () => { + const symbolIndex: SymbolIndex = new Map([ + [ + "process", + [ + { path: "src/a.ts", line: 1, type: "function" as const }, + { path: "src/b.ts", line: 5, type: "function" as const }, + ], + ], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "process" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(true) + expect(data.definitions).toHaveLength(2) + }) + + it("should return not found for unknown symbol", async () => { + const symbolIndex: SymbolIndex = new Map() + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "unknownSymbol" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(false) + expect(data.definitions).toHaveLength(0) + }) + + it("should suggest similar symbols when not found", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["myFunction", [{ path: "src/a.ts", line: 1, type: "function" as const }]], + ["myFunctionAsync", [{ path: "src/a.ts", line: 5, type: "function" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "myFunc" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(false) + expect(data.suggestions).toBeDefined() + expect(data.suggestions).toContain("myFunction") + }) + + it("should not include suggestions when exact match found", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["myFunction", [{ path: "src/a.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "myFunction" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(true) + expect(data.suggestions).toBeUndefined() + }) + + it("should include context lines", async () => { + const files = new Map([ + [ + "src/test.ts", + createMockFileData([ + "// Line 1", + "// Line 2", + "export function myFunc() {", + " return 1", + "}", + ]), + ], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["myFunc", [{ path: "src/test.ts", line: 3, type: "function" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "myFunc" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + const context = data.definitions[0].context + expect(context).toContain("// Line 1") + expect(context).toContain("// Line 2") + expect(context).toContain("export function myFunc()") + expect(context).toContain("return 1") + expect(context).toContain("}") + }) + + it("should mark definition line in context", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["// before", "const foo = 1", "// after"])], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["foo", [{ path: "src/test.ts", line: 2, type: "variable" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + const context = data.definitions[0].context + expect(context).toContain("> 2│const foo = 1") + expect(context).toContain(" 1│// before") + }) + + it("should handle context at file start", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const x = 1", "// after"])], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["x", [{ path: "src/test.ts", line: 1, type: "variable" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + const context = data.definitions[0].context + expect(context).toContain("> 1│const x = 1") + }) + + it("should handle context at file end", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["// before", "const x = 1"])], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["x", [{ path: "src/test.ts", line: 2, type: "variable" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + const context = data.definitions[0].context + expect(context).toContain("> 2│const x = 1") + }) + + it("should handle empty context when file not found", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["foo", [{ path: "src/nonexistent.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(true) + expect(data.definitions[0].context).toBe("") + }) + + it("should sort definitions by path then line", async () => { + const symbolIndex: SymbolIndex = new Map([ + [ + "foo", + [ + { path: "src/b.ts", line: 10, type: "function" as const }, + { path: "src/a.ts", line: 5, type: "function" as const }, + { path: "src/b.ts", line: 1, type: "function" as const }, + ], + ], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.definitions[0].path).toBe("src/a.ts") + expect(data.definitions[1].path).toBe("src/b.ts") + expect(data.definitions[1].line).toBe(1) + expect(data.definitions[2].path).toBe("src/b.ts") + expect(data.definitions[2].line).toBe(10) + }) + + it("should include callId in result", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["x", [{ path: "src/a.ts", line: 1, type: "variable" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.callId).toMatch(/^find_definition-\d+$/) + }) + + it("should include execution time in result", async () => { + const symbolIndex: SymbolIndex = new Map() + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should handle storage errors gracefully", async () => { + const storage = createMockStorage() + ;(storage.getSymbolIndex as ReturnType).mockRejectedValue( + new Error("Redis connection failed"), + ) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "test" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Redis connection failed") + }) + + it("should trim symbol before searching", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["foo", [{ path: "src/a.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: " foo " }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.symbol).toBe("foo") + expect(data.found).toBe(true) + }) + + it("should suggest symbols with small edit distance", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["fetchData", [{ path: "src/a.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "fethcData" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(false) + expect(data.suggestions).toContain("fetchData") + }) + + it("should limit suggestions to 5", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["testA", [{ path: "a.ts", line: 1, type: "function" as const }]], + ["testB", [{ path: "b.ts", line: 1, type: "function" as const }]], + ["testC", [{ path: "c.ts", line: 1, type: "function" as const }]], + ["testD", [{ path: "d.ts", line: 1, type: "function" as const }]], + ["testE", [{ path: "e.ts", line: 1, type: "function" as const }]], + ["testF", [{ path: "f.ts", line: 1, type: "function" as const }]], + ["testG", [{ path: "g.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "test" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.suggestions).toBeDefined() + expect(data.suggestions!.length).toBeLessThanOrEqual(5) + }) + + it("should sort suggestions alphabetically", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["testC", [{ path: "c.ts", line: 1, type: "function" as const }]], + ["testA", [{ path: "a.ts", line: 1, type: "function" as const }]], + ["testB", [{ path: "b.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "test" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.suggestions).toEqual(["testA", "testB", "testC"]) + }) + + it("should not include suggestions when no similar symbols exist", async () => { + const symbolIndex: SymbolIndex = new Map([ + ["xyz", [{ path: "a.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(new Map(), symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "abc" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindDefinitionResult + expect(data.found).toBe(false) + expect(data.suggestions).toBeUndefined() + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/search/FindReferencesTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/search/FindReferencesTool.test.ts new file mode 100644 index 0000000..f08eb9d --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/search/FindReferencesTool.test.ts @@ -0,0 +1,564 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + FindReferencesTool, + type FindReferencesResult, +} from "../../../../../src/infrastructure/tools/search/FindReferencesTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { + IStorage, + SymbolIndex, + SymbolLocation, +} from "../../../../../src/domain/services/IStorage.js" +import type { FileData } from "../../../../../src/domain/value-objects/FileData.js" + +function createMockFileData(lines: string[]): FileData { + return { + lines, + hash: "abc123", + size: lines.join("\n").length, + lastModified: Date.now(), + } +} + +function createMockStorage( + files: Map = new Map(), + symbolIndex: SymbolIndex = new Map(), +): IStorage { + return { + getFile: vi.fn().mockImplementation((p: string) => Promise.resolve(files.get(p) ?? null)), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn().mockResolvedValue(files), + getFileCount: vi.fn().mockResolvedValue(files.size), + getAST: vi.fn().mockResolvedValue(null), + setAST: vi.fn(), + deleteAST: vi.fn(), + getAllASTs: vi.fn().mockResolvedValue(new Map()), + getMeta: vi.fn().mockResolvedValue(null), + setMeta: vi.fn(), + deleteMeta: vi.fn(), + getAllMetas: vi.fn().mockResolvedValue(new Map()), + getSymbolIndex: vi.fn().mockResolvedValue(symbolIndex), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("FindReferencesTool", () => { + let tool: FindReferencesTool + + beforeEach(() => { + tool = new FindReferencesTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("find_references") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("search") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(2) + expect(tool.parameters[0].name).toBe("symbol") + expect(tool.parameters[0].required).toBe(true) + expect(tool.parameters[1].name).toBe("path") + expect(tool.parameters[1].required).toBe(false) + }) + + it("should have description", () => { + expect(tool.description).toContain("Find all usages") + }) + }) + + describe("validateParams", () => { + it("should return null for valid params with symbol only", () => { + expect(tool.validateParams({ symbol: "myFunction" })).toBeNull() + }) + + it("should return null for valid params with symbol and path", () => { + expect(tool.validateParams({ symbol: "myFunction", path: "src/" })).toBeNull() + }) + + it("should return error for missing symbol", () => { + expect(tool.validateParams({})).toBe( + "Parameter 'symbol' is required and must be a non-empty string", + ) + }) + + it("should return error for empty symbol", () => { + expect(tool.validateParams({ symbol: "" })).toBe( + "Parameter 'symbol' is required and must be a non-empty string", + ) + }) + + it("should return error for whitespace-only symbol", () => { + expect(tool.validateParams({ symbol: " " })).toBe( + "Parameter 'symbol' is required and must be a non-empty string", + ) + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ symbol: "test", path: 123 })).toBe( + "Parameter 'path' must be a string", + ) + }) + }) + + describe("execute", () => { + it("should find simple symbol references", async () => { + const files = new Map([ + [ + "src/index.ts", + createMockFileData([ + "import { myFunction } from './utils'", + "", + "myFunction()", + "const result = myFunction(42)", + ]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "myFunction" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.symbol).toBe("myFunction") + expect(data.totalReferences).toBe(3) + expect(data.files).toBe(1) + expect(data.references).toHaveLength(3) + }) + + it("should find references across multiple files", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["const foo = 1", "console.log(foo)"])], + [ + "src/b.ts", + createMockFileData(["import { foo } from './a'", "export const bar = foo + 1"]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(4) + expect(data.files).toBe(2) + }) + + it("should include definition locations from symbol index", async () => { + const files = new Map([ + ["src/utils.ts", createMockFileData(["export function helper() {}", "helper()"])], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["helper", [{ path: "src/utils.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "helper" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.definitionLocations).toHaveLength(1) + expect(data.definitionLocations[0]).toEqual({ + path: "src/utils.ts", + line: 1, + type: "function", + }) + }) + + it("should mark definition lines", async () => { + const files = new Map([ + ["src/utils.ts", createMockFileData(["export function myFunc() {}", "myFunc()"])], + ]) + const symbolIndex: SymbolIndex = new Map([ + ["myFunc", [{ path: "src/utils.ts", line: 1, type: "function" as const }]], + ]) + const storage = createMockStorage(files, symbolIndex) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "myFunc" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.references[0].isDefinition).toBe(true) + expect(data.references[1].isDefinition).toBe(false) + }) + + it("should filter by path", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["const x = 1"])], + ["src/b.ts", createMockFileData(["const x = 2"])], + ["lib/c.ts", createMockFileData(["const x = 3"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x", path: "src" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(2) + expect(data.references.every((r) => r.path.startsWith("src/"))).toBe(true) + }) + + it("should filter by specific file path", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["const x = 1"])], + ["src/b.ts", createMockFileData(["const x = 2"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x", path: "src/a.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(1) + expect(data.references[0].path).toBe("src/a.ts") + }) + + it("should return empty result when no files match filter", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["const x = 1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x", path: "nonexistent" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(0) + expect(data.files).toBe(0) + }) + + it("should return empty result when symbol not found", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["const foo = 1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "bar" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(0) + expect(data.files).toBe(0) + }) + + it("should use word boundaries for matching", async () => { + const files = new Map([ + [ + "src/test.ts", + createMockFileData([ + "const foo = 1", + "const foobar = 2", + "const barfoo = 3", + "const xfoox = 4", + ]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(1) + expect(data.references[0].line).toBe(1) + }) + + it("should include column number", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const value = 1", " value = 2"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "value" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.references[0].column).toBe(7) + expect(data.references[1].column).toBe(5) + }) + + it("should include context lines", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["// comment", "const foo = 1", "// after"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + const context = data.references[0].context + expect(context).toContain("// comment") + expect(context).toContain("const foo = 1") + expect(context).toContain("// after") + }) + + it("should mark current line in context", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["line1", "const foo = 1", "line3"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + const context = data.references[0].context + expect(context).toContain("> 2│const foo = 1") + expect(context).toContain(" 1│line1") + }) + + it("should handle context at file start", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const foo = 1", "line2"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + const context = data.references[0].context + expect(context).toContain("> 1│const foo = 1") + expect(context).toContain(" 2│line2") + }) + + it("should handle context at file end", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["line1", "const foo = 1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + const context = data.references[0].context + expect(context).toContain(" 1│line1") + expect(context).toContain("> 2│const foo = 1") + }) + + it("should find multiple occurrences on same line", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const x = x + x"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(3) + expect(data.references[0].column).toBe(7) + expect(data.references[1].column).toBe(11) + expect(data.references[2].column).toBe(15) + }) + + it("should sort results by path then line", async () => { + const files = new Map([ + ["src/b.ts", createMockFileData(["x", "", "x"])], + ["src/a.ts", createMockFileData(["x"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.references[0].path).toBe("src/a.ts") + expect(data.references[1].path).toBe("src/b.ts") + expect(data.references[1].line).toBe(1) + expect(data.references[2].path).toBe("src/b.ts") + expect(data.references[2].line).toBe(3) + }) + + it("should handle special regex characters in symbol", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const $value = 1", "$value + 2"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "$value" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(2) + }) + + it("should include callId in result", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const x = 1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.callId).toMatch(/^find_references-\d+$/) + }) + + it("should include execution time in result", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const x = 1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should handle storage errors gracefully", async () => { + const storage = createMockStorage() + ;(storage.getSymbolIndex as ReturnType).mockRejectedValue( + new Error("Redis connection failed"), + ) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "test" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Redis connection failed") + }) + + it("should trim symbol before searching", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const foo = 1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: " foo " }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.symbol).toBe("foo") + expect(data.totalReferences).toBe(1) + }) + + it("should handle empty files", async () => { + const files = new Map([ + ["src/empty.ts", createMockFileData([])], + ["src/test.ts", createMockFileData(["const x = 1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "x" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(1) + }) + + it("should handle symbols with underscores", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const my_variable = 1", "my_variable + 1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "my_variable" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(2) + }) + + it("should handle symbols with numbers", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const value1 = 1", "value1 + value2"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "value1" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(2) + }) + + it("should handle class method references", async () => { + const files = new Map([ + [ + "src/test.ts", + createMockFileData([ + "class Foo {", + " bar() {}", + "}", + "const f = new Foo()", + "f.bar()", + ]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "bar" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(2) + }) + + it("should not match partial words in strings", async () => { + const files = new Map([ + ["src/test.ts", createMockFileData(["const foo = 1", 'const msg = "foobar"'])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ symbol: "foo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as FindReferencesResult + expect(data.totalReferences).toBe(1) + expect(data.references[0].line).toBe(1) + }) + }) +})