lib.ts
1 import fs from "fs/promises"; 2 import path from "path"; 3 import os from "os"; 4 import { randomBytes } from "crypto"; 5 import { diffLines, createTwoFilesPatch } from "diff"; 6 import { minimatch } from "minimatch"; 7 import { normalizePath, expandHome } from "./path-utils.js"; 8 import { isPathWithinAllowedDirectories } from "./path-validation.js"; 9 10 // Global configuration - set by the main module 11 let allowedDirectories: string[] = []; 12 let ignoredFolders: string[] = []; 13 let enabledTools: string[] = []; 14 15 // Function to set allowed directories from the main module 16 export function setAllowedDirectories(directories: string[]): void { 17 allowedDirectories = [...directories]; 18 } 19 20 // Function to get current allowed directories 21 export function getAllowedDirectories(): string[] { 22 return [...allowedDirectories]; 23 } 24 25 // Function to set ignored folders from the main module 26 export function setIgnoredFolders(folders: string[]): void { 27 ignoredFolders = [...folders]; 28 } 29 30 // Function to get current ignored folders 31 export function getIgnoredFolders(): string[] { 32 return [...ignoredFolders]; 33 } 34 35 // Function to set enabled tools from the main module 36 export function setEnabledTools(tools: string[]): void { 37 enabledTools = [...tools]; 38 } 39 40 // Function to get current enabled tools 41 export function getEnabledTools(): string[] { 42 return [...enabledTools]; 43 } 44 45 // Function to check if a folder should be ignored 46 export function shouldIgnoreFolder(folderName: string): boolean { 47 if (ignoredFolders.length === 0) { 48 return false; 49 } 50 51 // Check exact matches first 52 if (ignoredFolders.includes(folderName)) { 53 return true; 54 } 55 56 // Check glob patterns 57 return ignoredFolders.some((pattern) => { 58 try { 59 return minimatch(folderName, pattern); 60 } catch (error) { 61 // If pattern is invalid, log warning but don't ignore 62 console.warn(`Invalid ignore pattern: ${pattern}`); 63 return false; 64 } 65 }); 66 } 67 68 // Type definitions 69 interface FileInfo { 70 size: number; 71 created: Date; 72 modified: Date; 73 accessed: Date; 74 isDirectory: boolean; 75 isFile: boolean; 76 permissions: string; 77 } 78 79 export interface SearchOptions { 80 excludePatterns?: string[]; 81 } 82 83 export interface SearchResult { 84 path: string; 85 isDirectory: boolean; 86 } 87 88 // Grep interfaces and types 89 export interface GrepOptions { 90 caseInsensitive?: boolean; 91 contextBefore?: number; 92 contextAfter?: number; 93 outputMode?: "content" | "files_with_matches" | "count"; 94 headLimit?: number; 95 multiline?: boolean; 96 fileType?: string; 97 globPattern?: string; 98 } 99 100 export interface GrepMatch { 101 file: string; 102 line: number; 103 content: string; 104 contextBefore?: string[]; 105 contextAfter?: string[]; 106 } 107 108 export interface GrepResult { 109 mode: "content" | "files_with_matches" | "count"; 110 matches?: GrepMatch[]; 111 files?: string[]; 112 counts?: Map<string, number>; 113 totalMatches: number; 114 filesSearched: number; 115 } 116 117 // File type extensions mapping (ripgrep-compatible) 118 const FILE_TYPE_EXTENSIONS: Record<string, string[]> = { 119 js: [".js", ".jsx", ".mjs", ".cjs"], 120 ts: [".ts", ".tsx", ".mts", ".cts"], 121 py: [".py", ".pyi", ".pyw"], 122 rust: [".rs"], 123 go: [".go"], 124 java: [".java"], 125 cpp: [".cpp", ".cc", ".cxx", ".hpp", ".h", ".hxx"], 126 c: [".c", ".h"], 127 rb: [".rb"], 128 php: [".php"], 129 css: [".css", ".scss", ".sass", ".less"], 130 html: [".html", ".htm"], 131 json: [".json"], 132 yaml: [".yaml", ".yml"], 133 xml: [".xml"], 134 md: [".md", ".markdown"], 135 sql: [".sql"], 136 sh: [".sh", ".bash"], 137 }; 138 139 function getExtensionsForType(type: string): string[] { 140 return FILE_TYPE_EXTENSIONS[type.toLowerCase()] || []; 141 } 142 143 // Pure Utility Functions 144 export function formatSize(bytes: number): string { 145 const units = ["B", "KB", "MB", "GB", "TB"]; 146 if (bytes === 0) return "0 B"; 147 148 const i = Math.floor(Math.log(bytes) / Math.log(1024)); 149 150 if (i < 0 || i === 0) return `${bytes} ${units[0]}`; 151 152 const unitIndex = Math.min(i, units.length - 1); 153 return `${(bytes / Math.pow(1024, unitIndex)).toFixed(2)} ${ 154 units[unitIndex] 155 }`; 156 } 157 158 export function normalizeLineEndings(text: string): string { 159 return text.replace(/\r\n/g, "\n"); 160 } 161 162 export function detectLineEnding(content: string): "\r\n" | "\n" { 163 return content.includes("\r\n") ? "\r\n" : "\n"; 164 } 165 166 // Calculate diff statistics 167 function calculateDiffStats( 168 original: string, 169 modified: string, 170 ): { 171 added: number; 172 removed: number; 173 modified: number; 174 } { 175 const originalLines = original.split("\n"); 176 const modifiedLines = modified.split("\n"); 177 178 let added = 0; 179 let removed = 0; 180 181 if (modifiedLines.length > originalLines.length) { 182 added = modifiedLines.length - originalLines.length; 183 } else if (originalLines.length > modifiedLines.length) { 184 removed = originalLines.length - modifiedLines.length; 185 } 186 187 // Count modified lines (lines that exist in both but are different) 188 const minLength = Math.min(originalLines.length, modifiedLines.length); 189 let modifiedCount = 0; 190 for (let i = 0; i < minLength; i++) { 191 if (originalLines[i] !== modifiedLines[i]) { 192 modifiedCount++; 193 } 194 } 195 196 return { added, removed, modified: modifiedCount }; 197 } 198 199 // Format detailed diff output with edit summaries 200 function formatDetailedDiff( 201 original: string, 202 modified: string, 203 filePath: string, 204 results: MatchResult[], 205 ): string { 206 let output = ""; 207 208 // Edit Summary 209 if (results.length > 0) { 210 output += "Edit Summary:\n"; 211 results.forEach((result, idx) => { 212 const editNum = results.length > 1 ? ` ${idx + 1}` : ""; 213 output += ` Edit${editNum}:\n`; 214 output += ` Strategy: ${result.strategy}`; 215 216 // Add strategy description for non-exact matches 217 if (result.strategy === "flexible") { 218 output += " (whitespace-insensitive)"; 219 } else if (result.strategy === "fuzzy") { 220 output += " (token-based pattern matching)"; 221 } 222 output += "\n"; 223 224 output += ` ${result.message}\n`; 225 226 if (result.lineRange) { 227 const lineDesc = 228 result.lineRange.start === result.lineRange.end 229 ? `line ${result.lineRange.start}` 230 : `lines ${result.lineRange.start}-${result.lineRange.end}`; 231 output += ` Location: ${lineDesc}\n`; 232 } 233 234 if (result.warning) { 235 output += ` ⚠️ Warning: ${result.warning}\n`; 236 } 237 238 if (result.ambiguity) { 239 output += ` ⚠️ Multiple matches found at ${result.ambiguity.locations}\n`; 240 output += ` Replaced first occurrence only\n`; 241 output += ` ${result.ambiguity.suggestion}\n`; 242 } 243 }); 244 output += "\n"; 245 } 246 247 // Diff Statistics 248 const stats = calculateDiffStats(original, modified); 249 const totalChanges = stats.added + stats.removed + stats.modified; 250 251 if (totalChanges > 0) { 252 output += "Diff Statistics:\n"; 253 if (stats.added > 0) { 254 output += ` +${stats.added} line${stats.added !== 1 ? "s" : ""} added\n`; 255 } 256 if (stats.removed > 0) { 257 output += ` -${stats.removed} line${ 258 stats.removed !== 1 ? "s" : "" 259 } removed\n`; 260 } 261 if (stats.modified > 0) { 262 output += ` ~${stats.modified} line${ 263 stats.modified !== 1 ? "s" : "" 264 } modified\n`; 265 } 266 output += ` Total: ${totalChanges} line${ 267 totalChanges !== 1 ? "s" : "" 268 } changed\n\n`; 269 } 270 271 // Unified Diff 272 const diff = createUnifiedDiff(original, modified, filePath); 273 output += diff; 274 275 // Notes 276 const usedNonExact = results.some((r) => r.strategy !== "exact"); 277 const hasWarnings = results.some((r) => r.warning); 278 279 if (usedNonExact || hasWarnings) { 280 output += "\n\n"; 281 if (usedNonExact) { 282 output += 283 "📝 Note: Non-exact matching strategies were used. Changes were applied successfully.\n"; 284 } 285 if (hasWarnings) { 286 output += 287 "⚠️ Please review the changes carefully to ensure they match your intentions.\n"; 288 } 289 } 290 291 return output; 292 } 293 294 export function createUnifiedDiff( 295 originalContent: string, 296 newContent: string, 297 filepath: string = "file", 298 ): string { 299 // Ensure consistent line endings for diff 300 const normalizedOriginal = normalizeLineEndings(originalContent); 301 const normalizedNew = normalizeLineEndings(newContent); 302 303 return createTwoFilesPatch( 304 filepath, 305 filepath, 306 normalizedOriginal, 307 normalizedNew, 308 "original", 309 "modified", 310 ); 311 } 312 313 // Security & Validation Functions 314 export interface ValidatePathOptions { 315 /** 316 * When true, validatePath will attempt to create any missing parent 317 * directories for the requested path, as long as they are within 318 * the configured allowedDirectories. 319 * 320 * This is intended for write operations only. Read/search tools 321 * should use the default behavior (no directory creation). 322 */ 323 createParentIfMissing?: boolean; 324 } 325 326 async function ensureParentDirectoryExists( 327 absolutePath: string, 328 ): Promise<void> { 329 const parentDir = path.dirname(absolutePath); 330 331 // Fast path: if parentDir already exists, let the existing logic handle it 332 try { 333 const realParentPath = await fs.realpath(parentDir); 334 const normalizedParent = normalizePath(realParentPath); 335 if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) { 336 throw new Error( 337 `Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join( 338 ", ", 339 )}`, 340 ); 341 } 342 return; 343 } catch (error) { 344 if ((error as NodeJS.ErrnoException).code !== "ENOENT") { 345 // If it's not a "does not exist" error, rethrow and let validatePath handle it 346 throw error; 347 } 348 } 349 350 // Parent (or some ancestor) does not exist - we may need to create it. 351 // Security: We only create directories if the final requested path is 352 // within allowedDirectories (checked in validatePath before calling this), 353 // and we validate each created directory as we go. 354 const segments: string[] = []; 355 let current = parentDir; 356 357 // Walk up until we find an existing ancestor or hit filesystem root 358 // Note: We don't trust dirname("/") to progress forever; stop when stable. 359 while (true) { 360 segments.push(current); 361 const parent = path.dirname(current); 362 if (parent === current) { 363 break; 364 } 365 try { 366 await fs.lstat(current); 367 break; // Found an existing path 368 } catch (error) { 369 if ((error as NodeJS.ErrnoException).code === "ENOENT") { 370 current = parent; 371 continue; 372 } 373 throw error; 374 } 375 } 376 377 // We collected segments from leaf up to the first existing ancestor/root. 378 // Create them from top-most missing down to the immediate parentDir. 379 for (let i = segments.length - 1; i >= 0; i--) { 380 const dir = segments[i]; 381 382 // Normalize and check allowedDirectories before creating 383 const normalizedDir = normalizePath(dir); 384 if (!isPathWithinAllowedDirectories(normalizedDir, allowedDirectories)) { 385 throw new Error( 386 `Access denied - cannot create directory outside allowed directories: ${dir} not in ${allowedDirectories.join( 387 ", ", 388 )}`, 389 ); 390 } 391 392 try { 393 await fs.mkdir(dir); 394 } catch (error) { 395 if ((error as NodeJS.ErrnoException).code === "EEXIST") { 396 // Something already exists at this path; verify it's a directory 397 const stats = await fs.lstat(dir); 398 if (!stats.isDirectory()) { 399 throw new Error( 400 `Cannot create directory; path exists and is not a directory: ${dir}`, 401 ); 402 } 403 continue; 404 } 405 throw error; 406 } 407 408 // After creation, resolve real path and ensure it is still within allowed directories 409 const realCreatedPath = await fs.realpath(dir); 410 const normalizedCreated = normalizePath(realCreatedPath); 411 if ( 412 !isPathWithinAllowedDirectories(normalizedCreated, allowedDirectories) 413 ) { 414 throw new Error( 415 `Access denied - created directory symlink target outside allowed directories: ${realCreatedPath} not in ${allowedDirectories.join( 416 ", ", 417 )}`, 418 ); 419 } 420 } 421 } 422 423 export async function validatePath( 424 requestedPath: string, 425 options?: ValidatePathOptions, 426 ): Promise<string> { 427 const expandedPath = expandHome(requestedPath); 428 const absolute = path.isAbsolute(expandedPath) 429 ? path.resolve(expandedPath) 430 : path.resolve(process.cwd(), expandedPath); 431 432 const normalizedRequested = normalizePath(absolute); 433 434 // Security: Check if path is within allowed directories before any file operations 435 const isAllowed = isPathWithinAllowedDirectories( 436 normalizedRequested, 437 allowedDirectories, 438 ); 439 if (!isAllowed) { 440 throw new Error( 441 `Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join( 442 ", ", 443 )}`, 444 ); 445 } 446 447 const createParentIfMissing = options?.createParentIfMissing === true; 448 449 // Security: Handle symlinks by checking their real path to prevent symlink attacks 450 // This prevents attackers from creating symlinks that point outside allowed directories 451 try { 452 const realPath = await fs.realpath(absolute); 453 const normalizedReal = normalizePath(realPath); 454 if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories)) { 455 throw new Error( 456 `Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join( 457 ", ", 458 )}`, 459 ); 460 } 461 return realPath; 462 } catch (error) { 463 // Security: For new files that don't exist yet, verify parent directory 464 // This ensures we can't create files in unauthorized locations 465 if ((error as NodeJS.ErrnoException).code === "ENOENT") { 466 const parentDir = path.dirname(absolute); 467 try { 468 const realParentPath = await fs.realpath(parentDir); 469 const normalizedParent = normalizePath(realParentPath); 470 if ( 471 !isPathWithinAllowedDirectories(normalizedParent, allowedDirectories) 472 ) { 473 throw new Error( 474 `Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join( 475 ", ", 476 )}`, 477 ); 478 } 479 return absolute; 480 } catch (parentError) { 481 if (!createParentIfMissing) { 482 throw new Error(`Parent directory does not exist: ${parentDir}`); 483 } 484 485 // Attempt to securely create the missing parent directory chain 486 await ensureParentDirectoryExists(absolute); 487 // After creation, return the absolute path for the new file. We intentionally 488 // do not call fs.realpath on the file itself here, because it still may not 489 // exist yet (for new files). 490 return absolute; 491 } 492 } 493 throw error; 494 } 495 } 496 497 // File Operations 498 export async function getFileStats(filePath: string): Promise<FileInfo> { 499 const stats = await fs.stat(filePath); 500 return { 501 size: stats.size, 502 created: stats.birthtime, 503 modified: stats.mtime, 504 accessed: stats.atime, 505 isDirectory: stats.isDirectory(), 506 isFile: stats.isFile(), 507 permissions: stats.mode.toString(8).slice(-3), 508 }; 509 } 510 511 export async function readFileContent( 512 filePath: string, 513 encoding: string = "utf-8", 514 ): Promise<string> { 515 return await fs.readFile(filePath, encoding as BufferEncoding); 516 } 517 518 export async function writeFileContent( 519 filePath: string, 520 content: string, 521 ): Promise<void> { 522 try { 523 // Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists, 524 // preventing writes through pre-existing symlinks 525 await fs.writeFile(filePath, content, { encoding: "utf-8", flag: "wx" }); 526 } catch (error) { 527 if ((error as NodeJS.ErrnoException).code === "EEXIST") { 528 // Security: Use atomic rename to prevent race conditions where symlinks 529 // could be created between validation and write. Rename operations 530 // replace the target file atomically and don't follow symlinks. 531 const tempPath = `${filePath}.${randomBytes(16).toString("hex")}.tmp`; 532 try { 533 await fs.writeFile(tempPath, content, "utf-8"); 534 await fs.rename(tempPath, filePath); 535 } catch (renameError) { 536 try { 537 await fs.unlink(tempPath); 538 } catch {} 539 throw renameError; 540 } 541 } else { 542 throw error; 543 } 544 } 545 } 546 547 // File Editing Functions 548 export interface FileEdit { 549 oldText: string; 550 newText: string; 551 instruction?: string; 552 expectedOccurrences?: number; 553 } 554 555 interface MatchResult { 556 strategy: "exact" | "flexible" | "fuzzy"; 557 occurrences: number; 558 modifiedContent: string; 559 message: string; 560 warning?: string; 561 ambiguity?: { 562 locations: string; 563 suggestion: string; 564 }; 565 lineRange?: { 566 start: number; 567 end: number; 568 }; 569 } 570 571 // Helper function to escape regex special characters 572 function escapeRegex(str: string): string { 573 return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 574 } 575 576 // Tier 1: Exact Match 577 function tryExactMatch( 578 content: string, 579 oldText: string, 580 newText: string, 581 ): MatchResult | null { 582 const searchText = normalizeLineEndings(oldText); 583 const replaceText = normalizeLineEndings(newText); 584 585 // Count occurrences 586 const escapedSearch = escapeRegex(searchText); 587 const occurrences = (content.match(new RegExp(escapedSearch, "g")) || []) 588 .length; 589 590 if (occurrences === 0) { 591 return null; 592 } 593 594 // Replace first occurrence only 595 const modified = content.replace(searchText, replaceText); 596 597 // Find the line number where the match occurred 598 const matchIndex = content.indexOf(searchText); 599 const linesBeforeMatch = content.substring(0, matchIndex).split("\n").length; 600 601 return { 602 strategy: "exact", 603 occurrences, 604 modifiedContent: modified, 605 message: `Exact match found (${occurrences} occurrence${ 606 occurrences > 1 ? "s" : "" 607 })`, 608 lineRange: { 609 start: linesBeforeMatch, 610 end: linesBeforeMatch + searchText.split("\n").length - 1, 611 }, 612 ambiguity: 613 occurrences > 1 614 ? { 615 locations: `${occurrences} locations in file`, 616 suggestion: 617 "Consider adding more context to oldText to uniquely identify the target", 618 } 619 : undefined, 620 }; 621 } 622 623 // Tier 2: Flexible Match (whitespace-insensitive) 624 function tryFlexibleMatch( 625 content: string, 626 oldText: string, 627 newText: string, 628 ): MatchResult | null { 629 const contentLines = content.split("\n"); 630 const searchLines = oldText.split("\n"); 631 const replaceLines = newText.split("\n"); 632 633 const matches: Array<{ 634 startLine: number; 635 endLine: number; 636 indentation: string; 637 }> = []; 638 639 // Find all matching windows 640 for (let i = 0; i <= contentLines.length - searchLines.length; i++) { 641 const window = contentLines.slice(i, i + searchLines.length); 642 643 // Compare with trimmed content (whitespace-insensitive) 644 const isMatch = searchLines.every( 645 (searchLine, j) => searchLine.trim() === window[j].trim(), 646 ); 647 648 if (isMatch) { 649 const indentation = window[0].match(/^(\s*)/)?.[0] || ""; 650 matches.push({ 651 startLine: i + 1, 652 endLine: i + searchLines.length, 653 indentation, 654 }); 655 } 656 } 657 658 if (matches.length === 0) { 659 return null; 660 } 661 662 // Apply replacement to first match, preserving indentation 663 const firstMatch = matches[0]; 664 const indentedReplaceLines = replaceLines.map((line) => { 665 if (line.trim() === "") return ""; // Preserve empty lines 666 return firstMatch.indentation + line.trim(); 667 }); 668 669 contentLines.splice( 670 firstMatch.startLine - 1, 671 firstMatch.endLine - firstMatch.startLine + 1, 672 ...indentedReplaceLines, 673 ); 674 675 return { 676 strategy: "flexible", 677 occurrences: matches.length, 678 modifiedContent: contentLines.join("\n"), 679 message: `Flexible match found at line ${firstMatch.startLine} (${ 680 matches.length 681 } total occurrence${matches.length > 1 ? "s" : ""})`, 682 lineRange: { 683 start: firstMatch.startLine, 684 end: firstMatch.endLine, 685 }, 686 ambiguity: 687 matches.length > 1 688 ? { 689 locations: matches 690 .map((m) => `lines ${m.startLine}-${m.endLine}`) 691 .join(", "), 692 suggestion: 693 "Consider adding more context to oldText to uniquely identify the target", 694 } 695 : undefined, 696 }; 697 } 698 699 // Tier 3: Fuzzy Match (token-based regex) 700 function tryFuzzyMatch( 701 content: string, 702 oldText: string, 703 newText: string, 704 ): MatchResult | null { 705 // Tokenize around code delimiters 706 const delimiters = [ 707 "(", 708 ")", 709 ":", 710 "[", 711 "]", 712 "{", 713 "}", 714 ">", 715 "<", 716 "=", 717 ";", 718 ",", 719 ]; 720 721 let tokenizedSearch = oldText; 722 for (const delim of delimiters) { 723 tokenizedSearch = tokenizedSearch.split(delim).join(` ${delim} `); 724 } 725 726 // Split on whitespace and filter empties 727 const tokens = tokenizedSearch.split(/\s+/).filter(Boolean); 728 729 if (tokens.length === 0) { 730 return null; 731 } 732 733 // Build regex: tokens separated by flexible whitespace 734 const escapedTokens = tokens.map((t) => escapeRegex(t)); 735 const pattern = `^(\\s*)${escapedTokens.join("\\s*")}`; 736 const fuzzyRegex = new RegExp(pattern, "m"); 737 738 const match = fuzzyRegex.exec(content); 739 740 if (!match) { 741 return null; 742 } 743 744 const matchedText = match[0]; 745 const indentation = match[1] || ""; 746 747 // Apply indentation to replacement 748 const replaceLines = newText.split("\n"); 749 const indentedReplace = replaceLines 750 .map((line) => { 751 if (line.trim() === "") return ""; 752 return indentation + line.trim(); 753 }) 754 .join("\n"); 755 756 const modified = content.replace(fuzzyRegex, indentedReplace); 757 758 // Calculate approximate position 759 const linesBeforeMatch = content.substring(0, match.index).split("\n").length; 760 761 return { 762 strategy: "fuzzy", 763 occurrences: 1, // Fuzzy match only replaces first occurrence 764 modifiedContent: modified, 765 message: `Fuzzy match found near line ${linesBeforeMatch}`, 766 lineRange: { 767 start: linesBeforeMatch, 768 end: linesBeforeMatch + matchedText.split("\n").length - 1, 769 }, 770 warning: 771 "Fuzzy matching was used. Please review changes carefully to ensure accuracy.", 772 }; 773 } 774 775 // Apply edit with strategy selection 776 function applyEditWithStrategy( 777 content: string, 778 edit: FileEdit, 779 strategy: "exact" | "flexible" | "fuzzy" | "auto", 780 failOnAmbiguous: boolean, 781 ): MatchResult { 782 let result: MatchResult | null = null; 783 const attemptedStrategies: string[] = []; 784 785 if (strategy === "auto") { 786 // Try each strategy in order 787 result = tryExactMatch(content, edit.oldText, edit.newText); 788 if (result) { 789 if ( 790 failOnAmbiguous && 791 result.occurrences > 1 && 792 (!edit.expectedOccurrences || edit.expectedOccurrences === 1) 793 ) { 794 throw new Error( 795 `Ambiguous match: found ${result.occurrences} occurrences of the search text.\n` + 796 (edit.instruction ? `Edit: ${edit.instruction}\n` : "") + 797 `Locations: ${result.ambiguity?.locations || "multiple"}\n` + 798 `Suggestion: ${ 799 result.ambiguity?.suggestion || 800 "Add more context to uniquely identify the target" 801 }`, 802 ); 803 } 804 return result; 805 } 806 attemptedStrategies.push("exact"); 807 808 result = tryFlexibleMatch(content, edit.oldText, edit.newText); 809 if (result) { 810 if ( 811 failOnAmbiguous && 812 result.occurrences > 1 && 813 (!edit.expectedOccurrences || edit.expectedOccurrences === 1) 814 ) { 815 throw new Error( 816 `Ambiguous match: found ${result.occurrences} occurrences of the search text.\n` + 817 (edit.instruction ? `Edit: ${edit.instruction}\n` : "") + 818 `Locations: ${result.ambiguity?.locations || "multiple"}\n` + 819 `Suggestion: ${ 820 result.ambiguity?.suggestion || 821 "Add more context to uniquely identify the target" 822 }`, 823 ); 824 } 825 return result; 826 } 827 attemptedStrategies.push("flexible"); 828 829 result = tryFuzzyMatch(content, edit.oldText, edit.newText); 830 if (result) return result; 831 attemptedStrategies.push("fuzzy"); 832 } else { 833 // Use specified strategy only 834 switch (strategy) { 835 case "exact": 836 result = tryExactMatch(content, edit.oldText, edit.newText); 837 attemptedStrategies.push("exact"); 838 break; 839 case "flexible": 840 result = tryFlexibleMatch(content, edit.oldText, edit.newText); 841 attemptedStrategies.push("flexible"); 842 break; 843 case "fuzzy": 844 result = tryFuzzyMatch(content, edit.oldText, edit.newText); 845 attemptedStrategies.push("fuzzy"); 846 break; 847 } 848 849 if ( 850 result && 851 failOnAmbiguous && 852 result.occurrences > 1 && 853 (!edit.expectedOccurrences || edit.expectedOccurrences === 1) 854 ) { 855 throw new Error( 856 `Ambiguous match: found ${result.occurrences} occurrences of the search text.\n` + 857 (edit.instruction ? `Edit: ${edit.instruction}\n` : "") + 858 `Locations: ${result.ambiguity?.locations || "multiple"}\n` + 859 `Suggestion: ${ 860 result.ambiguity?.suggestion || 861 "Add more context to uniquely identify the target" 862 }`, 863 ); 864 } 865 } 866 867 // No match found - throw error 868 if (!result) { 869 let errorMsg = "Failed to apply edit"; 870 if (edit.instruction) { 871 errorMsg += `\nEdit goal: ${edit.instruction}`; 872 } 873 errorMsg += `\n\nSearched for:\n${edit.oldText}\n`; 874 errorMsg += `\nAttempted strategies: ${attemptedStrategies.join(", ")}`; 875 errorMsg += `\n\nTroubleshooting tips:`; 876 errorMsg += `\n- Ensure oldText matches the file content exactly (check whitespace, indentation)`; 877 errorMsg += `\n- Use the read_file tool to verify current file content`; 878 errorMsg += `\n- Include 3-5 lines of context before and after the target change`; 879 errorMsg += `\n- Try matchingStrategy: "flexible" if whitespace is the issue`; 880 881 throw new Error(errorMsg); 882 } 883 884 // Validate occurrence count 885 if ( 886 edit.expectedOccurrences && 887 result.occurrences !== edit.expectedOccurrences 888 ) { 889 throw new Error( 890 `Expected ${edit.expectedOccurrences} occurrence(s) but found ${result.occurrences}\n` + 891 (edit.instruction ? `Edit: ${edit.instruction}\n` : "") + 892 `Strategy used: ${result.strategy}`, 893 ); 894 } 895 896 return result; 897 } 898 899 export async function applyFileEdits( 900 filePath: string, 901 edits: FileEdit[], 902 dryRun: boolean = false, 903 matchingStrategy: "exact" | "flexible" | "fuzzy" | "auto" = "auto", 904 failOnAmbiguous: boolean = true, 905 returnMetadata?: boolean, 906 ): Promise<string | { diff: string; metadata: MatchResult[] }> { 907 // Read file content and detect original line ending 908 const rawContent = await fs.readFile(filePath, "utf-8"); 909 const originalLineEnding = detectLineEnding(rawContent); 910 911 // Normalize line endings for processing 912 const content = normalizeLineEndings(rawContent); 913 914 // Apply edits sequentially, tracking results 915 let modifiedContent = content; 916 const editResults: MatchResult[] = []; 917 918 for (const edit of edits) { 919 const result = applyEditWithStrategy( 920 modifiedContent, 921 edit, 922 matchingStrategy, 923 failOnAmbiguous, 924 ); 925 editResults.push(result); 926 modifiedContent = result.modifiedContent; 927 } 928 929 // Create detailed diff output with edit summaries 930 const detailedDiff = formatDetailedDiff( 931 content, 932 modifiedContent, 933 filePath, 934 editResults, 935 ); 936 937 // Format diff with appropriate number of backticks 938 let numBackticks = 3; 939 while (detailedDiff.includes("`".repeat(numBackticks))) { 940 numBackticks++; 941 } 942 const formattedDiff = `${"`".repeat( 943 numBackticks, 944 )}diff\n${detailedDiff}${"`".repeat(numBackticks)}\n\n`; 945 946 if (!dryRun) { 947 // Restore original line endings before writing 948 const contentToWrite = 949 originalLineEnding === "\r\n" 950 ? modifiedContent.replace(/\n/g, "\r\n") 951 : modifiedContent; 952 953 // Security: Use atomic rename to prevent race conditions where symlinks 954 // could be created between validation and write. Rename operations 955 // replace the target file atomically and don't follow symlinks. 956 const tempPath = `${filePath}.${randomBytes(16).toString("hex")}.tmp`; 957 try { 958 await fs.writeFile(tempPath, contentToWrite, "utf-8"); 959 await fs.rename(tempPath, filePath); 960 } catch (error) { 961 try { 962 await fs.unlink(tempPath); 963 } catch {} 964 throw error; 965 } 966 } 967 968 if (returnMetadata) { 969 return { 970 diff: formattedDiff, 971 metadata: editResults, 972 }; 973 } 974 975 return formattedDiff; 976 } 977 978 // Memory-efficient implementation to get the last N lines of a file 979 export async function tailFile( 980 filePath: string, 981 numLines: number, 982 ): Promise<string> { 983 const CHUNK_SIZE = 1024; // Read 1KB at a time 984 const stats = await fs.stat(filePath); 985 const fileSize = stats.size; 986 987 if (fileSize === 0) return ""; 988 989 // Open file for reading 990 const fileHandle = await fs.open(filePath, "r"); 991 try { 992 const lines: string[] = []; 993 let position = fileSize; 994 let chunk = Buffer.alloc(CHUNK_SIZE); 995 let linesFound = 0; 996 let remainingText = ""; 997 998 // Read chunks from the end of the file until we have enough lines 999 while (position > 0 && linesFound < numLines) { 1000 const size = Math.min(CHUNK_SIZE, position); 1001 position -= size; 1002 1003 const { bytesRead } = await fileHandle.read(chunk, 0, size, position); 1004 if (!bytesRead) break; 1005 1006 // Get the chunk as a string and prepend any remaining text from previous iteration 1007 const readData = chunk.slice(0, bytesRead).toString("utf-8"); 1008 const chunkText = readData + remainingText; 1009 1010 // Split by newlines and count 1011 const chunkLines = normalizeLineEndings(chunkText).split("\n"); 1012 1013 // If this isn't the end of the file, the first line is likely incomplete 1014 // Save it to prepend to the next chunk 1015 if (position > 0) { 1016 remainingText = chunkLines[0]; 1017 chunkLines.shift(); // Remove the first (incomplete) line 1018 } 1019 1020 // Add lines to our result (up to the number we need) 1021 for ( 1022 let i = chunkLines.length - 1; 1023 i >= 0 && linesFound < numLines; 1024 i-- 1025 ) { 1026 lines.unshift(chunkLines[i]); 1027 linesFound++; 1028 } 1029 } 1030 1031 return lines.join("\n"); 1032 } finally { 1033 await fileHandle.close(); 1034 } 1035 } 1036 1037 // New function to get the first N lines of a file 1038 export async function headFile( 1039 filePath: string, 1040 numLines: number, 1041 ): Promise<string> { 1042 const fileHandle = await fs.open(filePath, "r"); 1043 try { 1044 const lines: string[] = []; 1045 let buffer = ""; 1046 let bytesRead = 0; 1047 const chunk = Buffer.alloc(1024); // 1KB buffer 1048 1049 // Read chunks and count lines until we have enough or reach EOF 1050 while (lines.length < numLines) { 1051 const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead); 1052 if (result.bytesRead === 0) break; // End of file 1053 bytesRead += result.bytesRead; 1054 buffer += chunk.slice(0, result.bytesRead).toString("utf-8"); 1055 1056 const newLineIndex = buffer.lastIndexOf("\n"); 1057 if (newLineIndex !== -1) { 1058 const completeLines = buffer.slice(0, newLineIndex).split("\n"); 1059 buffer = buffer.slice(newLineIndex + 1); 1060 for (const line of completeLines) { 1061 lines.push(line); 1062 if (lines.length >= numLines) break; 1063 } 1064 } 1065 } 1066 1067 // If there is leftover content and we still need lines, add it 1068 if (buffer.length > 0 && lines.length < numLines) { 1069 lines.push(buffer); 1070 } 1071 1072 return lines.join("\n"); 1073 } finally { 1074 await fileHandle.close(); 1075 } 1076 } 1077 1078 /** 1079 * Read a specific range of lines from a file 1080 * Memory-efficient implementation that reads sequentially and stops after reaching endLine 1081 * @param filePath - Path to the file to read 1082 * @param startLine - Starting line number (1-indexed, inclusive) 1083 * @param endLine - Ending line number (1-indexed, inclusive) 1084 * @returns Promise resolving to the requested lines as a string 1085 */ 1086 export async function rangeFile( 1087 filePath: string, 1088 startLine: number, 1089 endLine: number, 1090 ): Promise<string> { 1091 const CHUNK_SIZE = 1024; // Read 1KB at a time 1092 const fileHandle = await fs.open(filePath, "r"); 1093 1094 try { 1095 const targetLines: string[] = []; 1096 let currentLineNumber = 0; 1097 let buffer = ""; 1098 let bytesRead = 0; 1099 const chunk = Buffer.alloc(CHUNK_SIZE); 1100 1101 // Read file sequentially until we reach the end line 1102 while (currentLineNumber < endLine) { 1103 const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead); 1104 1105 // End of file reached 1106 if (result.bytesRead === 0) { 1107 // Process any remaining buffer content 1108 if (buffer.length > 0 && currentLineNumber + 1 >= startLine) { 1109 currentLineNumber++; 1110 if (currentLineNumber >= startLine && currentLineNumber <= endLine) { 1111 targetLines.push(buffer); 1112 } 1113 } 1114 break; 1115 } 1116 1117 bytesRead += result.bytesRead; 1118 buffer += chunk.slice(0, result.bytesRead).toString("utf-8"); 1119 1120 // Process complete lines in buffer 1121 let newLineIndex = buffer.indexOf("\n"); 1122 while (newLineIndex !== -1) { 1123 const line = buffer.slice(0, newLineIndex); 1124 buffer = buffer.slice(newLineIndex + 1); 1125 currentLineNumber++; 1126 1127 // Check if this line is within our target range 1128 if (currentLineNumber >= startLine && currentLineNumber <= endLine) { 1129 targetLines.push(line); 1130 } 1131 1132 // Early exit if we've collected all needed lines 1133 if (currentLineNumber >= endLine) { 1134 break; 1135 } 1136 1137 newLineIndex = buffer.indexOf("\n"); 1138 } 1139 1140 // Early exit if we've reached the end line 1141 if (currentLineNumber >= endLine) { 1142 break; 1143 } 1144 } 1145 1146 return targetLines.join("\n"); 1147 } finally { 1148 await fileHandle.close(); 1149 } 1150 } 1151 1152 export async function searchFilesWithValidation( 1153 rootPath: string, 1154 pattern: string, 1155 allowedDirectories: string[], 1156 options: SearchOptions = {}, 1157 ): Promise<string[]> { 1158 const { excludePatterns = [] } = options; 1159 const results: string[] = []; 1160 1161 // Check if pattern requires recursive search (contains ** or has path separators) 1162 const needsRecursion = 1163 pattern.includes("**") || pattern.includes("/") || pattern.includes("\\"); 1164 1165 async function search(currentPath: string, currentDepth: number = 0) { 1166 const entries = await fs.readdir(currentPath, { withFileTypes: true }); 1167 1168 for (const entry of entries) { 1169 const fullPath = path.join(currentPath, entry.name); 1170 1171 try { 1172 await validatePath(fullPath); 1173 1174 const relativePath = path.relative(rootPath, fullPath); 1175 const shouldExclude = excludePatterns.some((excludePattern) => 1176 minimatch(relativePath, excludePattern, { dot: true }), 1177 ); 1178 1179 if (shouldExclude) continue; 1180 1181 // Use glob matching for the search pattern 1182 if (minimatch(relativePath, pattern, { dot: true })) { 1183 results.push(fullPath); 1184 } 1185 1186 // Only recurse if pattern requires it and we're not too deep 1187 // Limit recursion depth to prevent infinite loops or excessive searching 1188 if (entry.isDirectory() && needsRecursion && currentDepth < 10) { 1189 await search(fullPath, currentDepth + 1); 1190 } 1191 } catch { 1192 continue; 1193 } 1194 } 1195 } 1196 1197 await search(rootPath); 1198 return results; 1199 } 1200 1201 export async function grepFilesWithValidation( 1202 pattern: string, 1203 searchPath: string, 1204 allowedDirectories: string[], 1205 options: GrepOptions = {}, 1206 ): Promise<GrepResult> { 1207 const { 1208 caseInsensitive = false, 1209 contextBefore = 0, 1210 contextAfter = 0, 1211 outputMode = "content", 1212 headLimit, 1213 multiline = false, 1214 fileType, 1215 globPattern, 1216 } = options; 1217 1218 // Create regex with appropriate flags (no 'g' flag to avoid stateful matching) 1219 const flags = caseInsensitive ? "i" : ""; 1220 const dotAllFlag = multiline ? "s" : ""; 1221 let regex: RegExp; 1222 1223 try { 1224 regex = new RegExp(pattern, flags + dotAllFlag); 1225 } catch (error) { 1226 throw new Error( 1227 `Invalid regex pattern: ${pattern} - ${ 1228 error instanceof Error ? error.message : String(error) 1229 }`, 1230 ); 1231 } 1232 1233 const result: GrepResult = { 1234 mode: outputMode, 1235 matches: outputMode === "content" ? [] : undefined, 1236 files: outputMode === "files_with_matches" ? [] : undefined, 1237 counts: outputMode === "count" ? new Map() : undefined, 1238 totalMatches: 0, 1239 filesSearched: 0, 1240 }; 1241 1242 // Determine if we need to search recursively 1243 const stats = await fs.stat(searchPath); 1244 const isDirectory = stats.isDirectory(); 1245 1246 async function searchFile(filePath: string): Promise<void> { 1247 // Validate path against allowed directories 1248 try { 1249 await validatePath(filePath); 1250 } catch { 1251 return; // Skip files outside allowed directories 1252 } 1253 1254 // Apply file type filter 1255 if (fileType) { 1256 const extensions = getExtensionsForType(fileType); 1257 if (extensions.length > 0) { 1258 const ext = path.extname(filePath).toLowerCase(); 1259 if (!extensions.includes(ext)) { 1260 return; // Skip files that don't match type 1261 } 1262 } 1263 } 1264 1265 // Apply glob filter 1266 if (globPattern) { 1267 const relativePath = path.relative(searchPath, filePath); 1268 if (!minimatch(relativePath, globPattern, { dot: true })) { 1269 return; // Skip files that don't match glob 1270 } 1271 } 1272 1273 // Check file size (limit to 100MB to prevent memory issues) 1274 const MAX_FILE_SIZE = 100 * 1024 * 1024; 1275 try { 1276 const fileStats = await fs.stat(filePath); 1277 if (fileStats.size > MAX_FILE_SIZE) { 1278 console.warn( 1279 `Skipping large file: ${filePath} (${formatSize(fileStats.size)})`, 1280 ); 1281 return; 1282 } 1283 } catch { 1284 return; // Skip if can't stat 1285 } 1286 1287 result.filesSearched++; 1288 1289 try { 1290 const content = await fs.readFile(filePath, "utf-8"); 1291 let fileMatchCount = 0; 1292 let fileHasMatch = false; 1293 1294 if (multiline) { 1295 // For multiline mode, search the entire content 1296 const matches = content.match(new RegExp(pattern, flags + "g")); 1297 if (matches) { 1298 fileHasMatch = true; 1299 fileMatchCount = matches.length; 1300 result.totalMatches += matches.length; 1301 1302 if (outputMode === "content") { 1303 // For multiline, we'll split by lines and find which lines have matches 1304 const lines = normalizeLineEndings(content).split("\n"); 1305 for (let i = 0; i < lines.length; i++) { 1306 const line = lines[i]; 1307 if (regex.test(line)) { 1308 if (headLimit && result.matches!.length >= headLimit) { 1309 break; 1310 } 1311 1312 const match: GrepMatch = { 1313 file: filePath, 1314 line: i + 1, 1315 content: line, 1316 }; 1317 1318 // Add context lines if requested 1319 if (contextBefore > 0) { 1320 match.contextBefore = []; 1321 for (let j = Math.max(0, i - contextBefore); j < i; j++) { 1322 match.contextBefore.push(lines[j]); 1323 } 1324 } 1325 1326 if (contextAfter > 0) { 1327 match.contextAfter = []; 1328 for ( 1329 let j = i + 1; 1330 j < Math.min(lines.length, i + 1 + contextAfter); 1331 j++ 1332 ) { 1333 match.contextAfter.push(lines[j]); 1334 } 1335 } 1336 1337 result.matches!.push(match); 1338 } 1339 } 1340 } 1341 } 1342 } else { 1343 // For single-line mode, search line by line 1344 const lines = normalizeLineEndings(content).split("\n"); 1345 for (let i = 0; i < lines.length; i++) { 1346 const line = lines[i]; 1347 1348 if (regex.test(line)) { 1349 fileHasMatch = true; 1350 fileMatchCount++; 1351 result.totalMatches++; 1352 1353 // Handle different output modes 1354 if (outputMode === "content") { 1355 // Check head limit for content mode 1356 if (headLimit && result.matches!.length >= headLimit) { 1357 break; // Stop processing this file 1358 } 1359 1360 const match: GrepMatch = { 1361 file: filePath, 1362 line: i + 1, 1363 content: line, 1364 }; 1365 1366 // Add context lines if requested 1367 if (contextBefore > 0) { 1368 match.contextBefore = []; 1369 for (let j = Math.max(0, i - contextBefore); j < i; j++) { 1370 match.contextBefore.push(lines[j]); 1371 } 1372 } 1373 1374 if (contextAfter > 0) { 1375 match.contextAfter = []; 1376 for ( 1377 let j = i + 1; 1378 j < Math.min(lines.length, i + 1 + contextAfter); 1379 j++ 1380 ) { 1381 match.contextAfter.push(lines[j]); 1382 } 1383 } 1384 1385 result.matches!.push(match); 1386 } 1387 } 1388 } 1389 } 1390 1391 // Handle files_with_matches mode 1392 if (outputMode === "files_with_matches" && fileHasMatch) { 1393 if (!headLimit || result.files!.length < headLimit) { 1394 result.files!.push(filePath); 1395 } 1396 } 1397 1398 // Handle count mode 1399 if (outputMode === "count" && fileMatchCount > 0) { 1400 result.counts!.set(filePath, fileMatchCount); 1401 } 1402 } catch (error) { 1403 // Skip binary files or files we can't read 1404 return; 1405 } 1406 } 1407 1408 async function searchDirectory( 1409 dirPath: string, 1410 depth: number = 0, 1411 ): Promise<void> { 1412 // Limit recursion depth 1413 if (depth > 10) return; 1414 1415 try { 1416 const entries = await fs.readdir(dirPath, { withFileTypes: true }); 1417 1418 for (const entry of entries) { 1419 const fullPath = path.join(dirPath, entry.name); 1420 1421 // Skip ignored folders 1422 if (entry.isDirectory() && shouldIgnoreFolder(entry.name)) { 1423 continue; 1424 } 1425 1426 if (entry.isDirectory()) { 1427 await searchDirectory(fullPath, depth + 1); 1428 } else if (entry.isFile()) { 1429 await searchFile(fullPath); 1430 1431 // Check global head limit 1432 if (headLimit) { 1433 if ( 1434 outputMode === "content" && 1435 result.matches!.length >= headLimit 1436 ) { 1437 return; // Stop searching 1438 } 1439 if ( 1440 outputMode === "files_with_matches" && 1441 result.files!.length >= headLimit 1442 ) { 1443 return; 1444 } 1445 } 1446 } 1447 } 1448 } catch { 1449 // Skip directories we can't read 1450 return; 1451 } 1452 } 1453 1454 // Execute search 1455 if (isDirectory) { 1456 await searchDirectory(searchPath); 1457 } else { 1458 await searchFile(searchPath); 1459 } 1460 1461 return result; 1462 }