UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

164 lines (163 loc) 7.5 kB
/** * SPDX-License-Identifier: PolyForm-Small-Business-1.0.0 * * Copyright (c) 2025 Desktope Commander MCP Contributors * * This file is licensed under the PolyForm Small Business License 1.0.0 * See the LICENSE file in the /src/polyform directory for the full license text. */ import { readFile, writeFile } from '../../tools/filesystem.js'; import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js'; import { capture } from '../../utils.js'; /** * Threshold for fuzzy matching - similarity must be at least this value to be considered * (0-1 scale where 1 is perfect match and 0 is completely different) */ const FUZZY_THRESHOLD = 0.7; export async function performSearchReplace(filePath, block, expectedReplacements = 1) { // Check for empty search string to prevent infinite loops if (block.search === "") { return { content: [{ type: "text", text: "Empty search strings are not allowed. Please provide a non-empty string to search for." }], }; } // Read file as plain string const { content } = await readFile(filePath); // Make sure content is a string if (typeof content !== 'string') { throw new Error('Wrong content for file ' + filePath); } // First try exact match let tempContent = content; let count = 0; let pos = tempContent.indexOf(block.search); while (pos !== -1) { count++; pos = tempContent.indexOf(block.search, pos + 1); } // If exact match found and count matches expected replacements, proceed with exact replacement if (count > 0 && count === expectedReplacements) { // Replace all occurrences let newContent = content; // If we're only replacing one occurrence, replace it directly if (expectedReplacements === 1) { const searchIndex = newContent.indexOf(block.search); newContent = newContent.substring(0, searchIndex) + block.replace + newContent.substring(searchIndex + block.search.length); } else { // Replace all occurrences using split and join for multiple replacements newContent = newContent.split(block.search).join(block.replace); } await writeFile(filePath, newContent); return { content: [{ type: "text", text: `Successfully applied ${expectedReplacements} edit${expectedReplacements > 1 ? 's' : ''} to ${filePath}` }], }; } // If exact match found but count doesn't match expected, inform the user if (count > 0 && count !== expectedReplacements) { return { content: [{ type: "text", text: `Expected ${expectedReplacements} occurrences but found ${count} in ${filePath}. ` + `If you want to replace all ${count} occurrences, set expected_replacements to ${count}. ` + `If you want to replace a specific occurrence, make your search string more unique by adding context.` }], }; } // If exact match not found, try fuzzy search if (count === 0) { // Track fuzzy search time const startTime = performance.now(); // Perform fuzzy search const fuzzyResult = recursiveFuzzyIndexOf(content, block.search); const similarity = getSimilarityRatio(block.search, fuzzyResult.value); // Calculate execution time in milliseconds const executionTime = performance.now() - startTime; // Check if the fuzzy match is "close enough" if (similarity >= FUZZY_THRESHOLD) { // Format differences for clearer output const diff = highlightDifferences(block.search, fuzzyResult.value); // Capture the fuzzy search event capture('server_fuzzy_search_performed', { similarity: similarity, execution_time_ms: executionTime, search_length: block.search.length, file_size: content.length, threshold: FUZZY_THRESHOLD, found_text_length: fuzzyResult.value.length }); // If we allow fuzzy matches, we would make the replacement here // For now, we'll return a detailed message about the fuzzy match return { content: [{ type: "text", text: `Exact match not found, but found a similar text with ${Math.round(similarity * 100)}% similarity (found in ${executionTime.toFixed(2)}ms):\n\n` + `Differences:\n${diff}\n\n` + `To replace this text, use the exact text found in the file.` }], }; } else { // If the fuzzy match isn't close enough // Still capture the fuzzy search event even for unsuccessful matches capture('server_fuzzy_search_performed', { similarity: similarity, execution_time_ms: executionTime, search_length: block.search.length, file_size: content.length, threshold: FUZZY_THRESHOLD, found_text_length: fuzzyResult.value.length, file_path: filePath, below_threshold: true }); return { content: [{ type: "text", text: `Search content not found in ${filePath}. The closest match was "${fuzzyResult.value}" ` + `with only ${Math.round(similarity * 100)}% similarity, which is below the ${Math.round(FUZZY_THRESHOLD * 100)}% threshold. ` + `(Fuzzy search completed in ${executionTime.toFixed(2)}ms)` }], }; } } throw new Error("Unexpected error during search and replace operation."); } /** * Generates a character-level diff using standard {-removed-}{+added+} format * @param expected The string that was searched for * @param actual The string that was found * @returns A formatted string showing character-level differences */ function highlightDifferences(expected, actual) { // Implementation of a simplified character-level diff // Find common prefix and suffix let prefixLength = 0; const minLength = Math.min(expected.length, actual.length); // Determine common prefix length while (prefixLength < minLength && expected[prefixLength] === actual[prefixLength]) { prefixLength++; } // Determine common suffix length let suffixLength = 0; while (suffixLength < minLength - prefixLength && expected[expected.length - 1 - suffixLength] === actual[actual.length - 1 - suffixLength]) { suffixLength++; } // Extract the common and different parts const commonPrefix = expected.substring(0, prefixLength); const commonSuffix = expected.substring(expected.length - suffixLength); const expectedDiff = expected.substring(prefixLength, expected.length - suffixLength); const actualDiff = actual.substring(prefixLength, actual.length - suffixLength); // Format the output as a character-level diff return `${commonPrefix}{-${expectedDiff}-}{+${actualDiff}+}${commonSuffix}`; }