searchReplace.tsโข4.32 kB
import _ from 'lodash'
import { z } from 'zod'
import env from '../env.js'
import { defineTool } from '../tools.js'
import util from '../util.js'
const CONTEXT_LINES = 2
const ID = 'search_replace'
const searchReplace = defineTool({
  id: ID,
  name: `${env.OVERRIDE_S_R ? '': 'better_'}${ID}`,
  schema: z.object({
    file_path: z.string().min(1).describe('Path to the file (supports relative and absolute paths)'),
    old_string: z.string().min(1).describe('Exact text to replace (must be unique in file)'),
    new_string: z.string().describe('Replacement text'),
    allow_multiple_matches: z.boolean().optional().describe('Allow multiple matches to be replaced. If false, throws error when multiple matches found (default: true)'),
  }),
  description: 'Search and replace with intelligent whitespace handling and automation-friendly multiple match resolution. Tries exact match first, falls back to flexible whitespace matching only when no matches found.',
  isReadOnly: false,
  isEnabled: env.DEBUG,
  fromArgs: ([filePath, oldString, newString]) => ({
    file_path: filePath, old_string: oldString, new_string: newString,
  }),
  handler: (args) => {
    const { file_path: filePath, old_string: oldString, new_string: newString, allow_multiple_matches: allowMultiple = true } = args
    const fullPath = util.resolve(filePath)
    const content = util.readFile(fullPath)
    // Validate that the replacement would actually change something
    if (oldString.includes(newString)) {
      throw new Error(`Redundant replacement: old_string already contains new_string. Old: "${oldString}", New: "${newString}"`)
    }
    // Core strategies: exact match + whitespace flexibility (matching Cursor's capability)
    const patterns = [oldString, createCursorLikePattern(oldString)]
    // Try each strategy until one works
    for (const pattern of patterns) {
      const parts = content.split(pattern)
      const matches = parts.length - 1
      if (!matches) {
        continue
      }
      if (matches > 1 && !allowMultiple) {
        throw new Error(`Multiple matches found (${matches}) for "${oldString}" in ${filePath}. Set allow_multiple_matches=true to allow replacing first occurrence, or make your search string more specific.`)
      }
      const newContent = parts.join(newString)
      util.writeFile(fullPath, newContent)
      return formatDiff(content, newContent)
    }
    throw new Error(`Could not find the specified text in ${filePath}`)
  },
})
// Helper function for Cursor-like whitespace handling
function createCursorLikePattern(text: string) {
  // Match Cursor's whitespace handling: flexible with spaces/tabs, preserve structure
  return new RegExp(_.escapeRegExp(text)
    .replace(/\s+/g, '\\s+')           // Any whitespace sequence becomes flexible
    .replace(/^\s*/, '\\s*')           // Optional leading whitespace
    .replace(/\s*$/, '\\s*')           // Optional trailing whitespace
  , 'gm')
}
export default searchReplace
function formatDiff(original: string, updated: string): string {
  const originalLines = original.split('\n')
  const newLines = updated.split('\n')
  // Find the range of lines that changed
  let startLine = -1
  let endLine = -1
  for (let i = 0; i < Math.max(originalLines.length, newLines.length); i++) {
    if (originalLines[i] !== newLines[i]) {
      if (startLine === -1) startLine = i
      endLine = i
    }
  }
  if (startLine === -1) {
    throw new Error('Could not generate a diff for changes to the file')
  }
  // Generate diff with context
  const displayStart = Math.max(0, startLine - CONTEXT_LINES)
  const displayEnd = Math.min(originalLines.length - 1, endLine + CONTEXT_LINES)
  const diffLines: string[] = []
  for (let i = displayStart; i <= displayEnd; i++) {
    const oldLine = originalLines[i] || ''
    const newLine = newLines[i] || ''
    if (i < startLine || i > endLine) {
      // Context line
      diffLines.push(`  ${oldLine}`)
    } else if (oldLine !== newLine) {
      // Changed line
      if (oldLine) diffLines.push(`- ${oldLine}`)
      if (newLine) diffLines.push(`+ ${newLine}`)
    } else {
      // Unchanged line within change range
      diffLines.push(`  ${oldLine}`)
    }
  }
  return `The following diff was applied to the file:
\`\`\`\n${diffLines.join('\n')}\n\`\`\``
}