Skip to main content
Glama
edit_tools.js12.1 kB
const fs = require('fs'); const path = require('path'); const diff = require('diff'); const logger = require('../logger'); /** * Apply surgical text replacements to files */ function editBlock(filePath, oldString, newString, options = {}) { try { // Validate inputs if (!filePath || !oldString || newString === undefined) { return { success: false, message: 'file_path, old_string, and new_string are required' }; } // Resolve file path const resolvedPath = path.resolve(filePath); // Check if file exists if (!fs.existsSync(resolvedPath)) { return { success: false, message: `File not found: ${resolvedPath}` }; } // Read the file let content = fs.readFileSync(resolvedPath, 'utf8'); // Count expected replacements (default is 1) const expectedReplacements = options.expected_replacements || 1; // Check for normalize_whitespace option (new feature) const normalizeWhitespace = options.normalize_whitespace || false; // If normalize_whitespace is true, normalize both content and search strings let searchString = oldString; let replaceString = newString; let originalContent = content; if (normalizeWhitespace) { // Normalize line endings and multiple spaces searchString = oldString.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/[ \t]+/g, ' '); replaceString = newString.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); content = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/[ \t]+/g, ' '); } // Find all occurrences const occurrences = findOccurrences(content, searchString); if (occurrences.length === 0) { // Try fuzzy matching if exact match not found const fuzzyMatch = findFuzzyMatch(originalContent, oldString); if (fuzzyMatch) { return { success: false, message: `No exact match found. Did you mean this?`, suggestion: fuzzyMatch.suggestion, diff: fuzzyMatch.diff, hint: 'The search string was not found exactly as provided. Check whitespace and formatting. You can also try setting normalize_whitespace: true.' }; } return { success: false, message: 'Search string not found in file', searchString: oldString, hint: 'Try setting normalize_whitespace: true if whitespace differences are causing issues' }; } if (occurrences.length !== expectedReplacements) { return { success: false, message: `Expected ${expectedReplacements} occurrence(s), but found ${occurrences.length}`, occurrences: occurrences.map(o => ({ line: o.line, column: o.column, preview: o.preview })), hint: expectedReplacements === 1 ? 'Include more context to make the search string unique' : `Set expected_replacements to ${occurrences.length} to replace all occurrences` }; } // Perform the replacement let newContent = normalizeWhitespace ? originalContent : content; let replacementCount = 0; if (expectedReplacements === 1) { // Replace only the first occurrence if (normalizeWhitespace) { // For normalized whitespace, we need to be more careful const index = content.indexOf(searchString); if (index !== -1) { // Find the actual position in original content let originalIndex = findOriginalPosition(originalContent, content, index); if (originalIndex !== -1) { // Determine the actual length to replace in original content let endIndex = findOriginalPosition(originalContent, content, index + searchString.length); newContent = originalContent.substring(0, originalIndex) + replaceString + originalContent.substring(endIndex); replacementCount = 1; } } } else { newContent = content.replace(oldString, newString); replacementCount = 1; } } else { // Replace specified number of occurrences if (normalizeWhitespace) { // Complex case - need to map positions let normalizedContent = content; let workingContent = originalContent; for (let i = 0; i < expectedReplacements && i < occurrences.length; i++) { const index = normalizedContent.indexOf(searchString); if (index !== -1) { let originalIndex = findOriginalPosition(workingContent, normalizedContent, index); let endIndex = findOriginalPosition(workingContent, normalizedContent, index + searchString.length); if (originalIndex !== -1 && endIndex !== -1) { workingContent = workingContent.substring(0, originalIndex) + replaceString + workingContent.substring(endIndex); // Update normalized content for next iteration normalizedContent = normalizedContent.substring(0, index) + replaceString.replace(/[ \t]+/g, ' ') + normalizedContent.substring(index + searchString.length); replacementCount++; } } } newContent = workingContent; } else { // Simple case - direct replacement let lastIndex = 0; for (let i = 0; i < expectedReplacements && i < occurrences.length; i++) { const index = content.indexOf(oldString, lastIndex); if (index !== -1) { newContent = newContent.substring(0, index) + newString + newContent.substring(index + oldString.length); lastIndex = index + newString.length; replacementCount++; } } } } // Check line limit warning const lineCount = newContent.split('\n').length; const lineLimit = parseInt(process.env.FILE_WRITE_LINE_LIMIT || '50'); if (lineCount > lineLimit * 2) { logger.warn(`File has ${lineCount} lines, which exceeds recommended limit of ${lineLimit * 2} for edit_block`); } // Write the file fs.writeFileSync(resolvedPath, newContent, 'utf8'); // Generate a diff for confirmation const patches = diff.createPatch(filePath, originalContent, newContent); return { success: true, message: `Successfully replaced ${replacementCount} occurrence(s)`, path: resolvedPath, replacements: replacementCount, diff: patches, lineCount, normalizedWhitespace: normalizeWhitespace }; } catch (error) { logger.error(`Error in editBlock: ${error.message}`); return { success: false, message: error.message }; } } /** * Find the original position in content given a position in normalized content */ function findOriginalPosition(original, normalized, normalizedPos) { let origPos = 0; let normPos = 0; while (normPos < normalizedPos && origPos < original.length) { // Skip whitespace differences if (/\s/.test(original[origPos]) && /\s/.test(normalized[normPos])) { // Skip all whitespace in original let origStart = origPos; while (origPos < original.length && /\s/.test(original[origPos])) { origPos++; } // Skip single space in normalized normPos++; } else if (original[origPos] === normalized[normPos]) { origPos++; normPos++; } else { // Mismatch - this shouldn't happen if normalization is correct return -1; } } return origPos; } /** * Find all occurrences of a string in content */ function findOccurrences(content, searchString) { const occurrences = []; const lines = content.split('\n'); let totalIndex = 0; for (let lineNum = 0; lineNum < lines.length; lineNum++) { const line = lines[lineNum]; let columnIndex = 0; while ((columnIndex = line.indexOf(searchString, columnIndex)) !== -1) { occurrences.push({ line: lineNum + 1, column: columnIndex + 1, index: totalIndex + columnIndex, preview: getPreview(lines, lineNum, columnIndex, searchString.length) }); columnIndex += searchString.length; } totalIndex += line.length + 1; // +1 for newline } return occurrences; } /** * Get a preview of the occurrence with context */ function getPreview(lines, lineNum, column, length) { const line = lines[lineNum]; const start = Math.max(0, column - 30); const end = Math.min(line.length, column + length + 30); let preview = ''; if (start > 0) preview += '...'; preview += line.substring(start, end); if (end < line.length) preview += '...'; return preview; } /** * Try to find a fuzzy match for the search string */ function findFuzzyMatch(content, searchString) { // Normalize whitespace in search string const normalizedSearch = searchString.replace(/\s+/g, ' ').trim(); const searchLines = searchString.split('\n').map(l => l.trim()); if (searchLines.length > 1) { // Multi-line search - try to find similar content const contentLines = content.split('\n'); for (let i = 0; i <= contentLines.length - searchLines.length; i++) { let match = true; let matchedLines = []; for (let j = 0; j < searchLines.length; j++) { const contentLine = contentLines[i + j].trim(); const searchLine = searchLines[j]; if (!contentLine.includes(searchLine) && !searchLine.includes(contentLine)) { match = false; break; } matchedLines.push(contentLines[i + j]); } if (match) { const suggestion = matchedLines.join('\n'); const patches = diff.createPatch('fuzzy', searchString, suggestion); return { suggestion, diff: patches, startLine: i + 1, endLine: i + searchLines.length }; } } } else { // Single line search - find best match const lines = content.split('\n'); let bestMatch = null; let bestScore = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const score = similarityScore(normalizedSearch, line); if (score > bestScore && score > 0.6) { bestScore = score; bestMatch = { line: line, lineNum: i + 1, score: score }; } } if (bestMatch) { const patches = diff.createPatch('fuzzy', searchString, bestMatch.line); return { suggestion: bestMatch.line, diff: patches, line: bestMatch.lineNum, similarity: Math.round(bestMatch.score * 100) + '%' }; } } return null; } /** * Calculate similarity score between two strings */ function similarityScore(str1, str2) { const longer = str1.length > str2.length ? str1 : str2; const shorter = str1.length > str2.length ? str2 : str1; if (longer.length === 0) { return 1.0; } const editDistance = levenshteinDistance(longer, shorter); return (longer.length - editDistance) / longer.length; } /** * Calculate Levenshtein distance between two strings */ function levenshteinDistance(str1, str2) { const matrix = []; for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= str1.length; j++) { matrix[0][j] = j; } for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.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[str2.length][str1.length]; } module.exports = { editBlock };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/FutureAtoms/agentic-control-framework'

If you have feedback or need assistance with the MCP directory API, please join our Discord server