diff --git a/.gitignore b/.gitignore index 7fba826..f84a5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ coverage/ .idea .history -server.log \ No newline at end of file +server.log-e +# Temporary and backup files +backup/ +*.bak diff --git a/docs/enhanced-edit-block.md b/docs/enhanced-edit-block.md new file mode 100644 index 0000000..52250fb --- /dev/null +++ b/docs/enhanced-edit-block.md @@ -0,0 +1,152 @@ +# Enhanced Edit Block Functionality + +The `edit_block` tool has been enhanced with several new features to make it more powerful and flexible. This document explains the new capabilities and how to use them. + +## Core Enhancements + +1. **Multiple Block Support**: You can now include multiple search/replace blocks in a single command +2. **Global Replacement**: Replace all occurrences of a pattern, not just the first one +3. **Counted Replacements**: Replace only a specific number of occurrences +4. **Case-Insensitive Matching**: Match patterns regardless of case +5. **Dry Run Mode**: Simulate replacements without actually modifying files +6. **Enhanced Error Handling**: Better error reporting with details + +## Usage + +### Basic Syntax with Flags + +The `edit_block` command now supports optional flags in the search block delimiter: + +```text +filepath +<<<<<<< SEARCH[:flags] +content to find +======= +new content +>>>>>>> REPLACE +```text + +Where `flags` can be any combination of: +- `g`: Global replacement (replace all occurrences) +- `i`: Case-insensitive matching +- `d`: Dry run (don't actually modify the file) +- `n:X`: Replace only X occurrences (where X is a positive number) + +For example: +```text +/path/to/file.txt +<<<<<<< SEARCH:gi +mixed case text +======= +REPLACED TEXT +>>>>>>> REPLACE +```text + +### Multiple Blocks + +You can include multiple search/replace blocks in a single edit_block command: + +```text +/path/to/file.txt +<<<<<<< SEARCH +first pattern +======= +first replacement +>>>>>>> REPLACE +<<<<<<< SEARCH:g +second pattern +======= +second replacement (global) +>>>>>>> REPLACE +<<<<<<< SEARCH:i +THIRD pattern +======= +third replacement (case-insensitive) +>>>>>>> REPLACE +```text + +## Examples + +### Global Replacement + +Replace all occurrences of a pattern: + +```text +/path/to/file.txt +<<<<<<< SEARCH:g +function oldName +======= +function newName +>>>>>>> REPLACE +```text + +### Counted Replacement + +Replace only the first 3 occurrences of a pattern: + +```text +/path/to/file.txt +<<<<<<< SEARCH:n:3 +repeated text +======= +limited replacement +>>>>>>> REPLACE +```text + +### Case-Insensitive Matching + +Match a pattern regardless of case: + +```text +/path/to/file.txt +<<<<<<< SEARCH:i +WARNING +======= +ERROR +>>>>>>> REPLACE +```text + +This will match "WARNING", "Warning", "warning", etc. + +### Combined Flags + +Use multiple flags together: + +```text +/path/to/file.txt +<<<<<<< SEARCH:n:2:i +error +======= +warning +>>>>>>> REPLACE +```text + +This will replace the first 2 occurrences of "error", "Error", "ERROR", etc. with "warning". + +### Dry Run + +Test replacements without modifying the file: + +```text +/path/to/file.txt +<<<<<<< SEARCH:d +sensitive change +======= +test replacement +>>>>>>> REPLACE +```text + +The output will show what would be changed, but the file remains unmodified. + +## Error Handling + +The enhanced implementation provides better error messages for common issues: + +1. **Malformed blocks**: Detailed information about syntax errors +2. **Pattern size limits**: Warnings for excessively large patterns +3. **Missing blocks**: Clear indication when no valid blocks are found +4. **Block-specific errors**: Each block's errors are reported separately + +## Backward Compatibility + +All existing `edit_block` usage patterns continue to work as before. The enhancements are fully backward compatible with the original implementation. diff --git a/fix_markdown.sh b/fix_markdown.sh new file mode 100755 index 0000000..f42217c --- /dev/null +++ b/fix_markdown.sh @@ -0,0 +1,2 @@ +#!/bin/bash +sed -i '' 's/^```$/```text/' docs/enhanced-edit-block.md diff --git a/src/handlers/edit-search-handlers.ts b/src/handlers/edit-search-handlers.ts index 9f7c221..fefcb67 100644 --- a/src/handlers/edit-search-handlers.ts +++ b/src/handlers/edit-search-handlers.ts @@ -17,17 +17,37 @@ import {capture, withTimeout} from '../utils.js'; import {createErrorResponse} from '../error-handlers.js'; /** - * Handle edit_block command + * Handle edit_block command with enhanced capabilities */ export async function handleEditBlock(args: unknown): Promise { - const parsed = EditBlockArgsSchema.parse(args); - const {filePath, searchReplace, error} = await parseEditBlock(parsed.blockContent); - - if (error) { - return createErrorResponse(error); + try { + // Parse input arguments + const parsed = EditBlockArgsSchema.parse(args); + + // Parse the block content (enhanced implementation) + const { filePath, searchReplace, errors } = await parseEditBlock(parsed.blockContent); + + // Handle parsing errors + if (errors?.global) { + return createErrorResponse(errors.global); + } + + // Report block-specific errors if present + if (errors?.blocks && errors.blocks.length > 0) { + const errorMessages = errors.blocks.map(error => + `Block ${error.index + 1}${error.lineNumber ? ` (line ${error.lineNumber})` : ''}: ${error.error}` + ).join('\n'); + + return createErrorResponse(`Parse errors in edit blocks:\n${errorMessages}`); + } + + // Use enhanced implementation for all replacements + return await performSearchReplace(filePath, searchReplace); + } catch (error) { + // Maintain existing error handling + const errorMessage = error instanceof Error ? error.message : String(error); + return createErrorResponse(errorMessage); } - - return performSearchReplace(filePath, searchReplace); } /** @@ -98,4 +118,4 @@ export async function handleSearchCode(args: unknown): Promise { return { content: [{type: "text", text: formattedResults.trim()}], }; -} \ No newline at end of file +} diff --git a/src/tools/edit.ts b/src/tools/edit.ts index 43d102d..e16a085 100644 --- a/src/tools/edit.ts +++ b/src/tools/edit.ts @@ -1,70 +1,479 @@ import { readFile, writeFile } from './filesystem.js'; import { ServerResult } from '../types.js'; +interface SearchReplaceFlags { + global?: boolean; // Replace all occurrences + ignoreCase?: boolean; // Case-insensitive matching + dryRun?: boolean; // Don't apply changes, just simulate + count?: number; // Replace only N occurrences +} + interface SearchReplace { - search: string; - replace: string; + search: string; + replace: string; + flags?: SearchReplaceFlags; } -export async function performSearchReplace(filePath: string, block: SearchReplace): Promise { - // Read file as plain string (don't pass true to get just the string) - const {content} = await readFile(filePath); - - // Make sure content is a string - if( typeof content !== 'string') { - throw new Error('Wrong content for file ' + filePath); - }; - - // Find first occurrence - const searchIndex = content.indexOf(block.search); - if (searchIndex === -1) { - return { - content: [{ type: "text", text: `Search content not found in ${filePath}.` }], - }; - } +// Enhanced result interface supporting multiple replacements and errors +interface EditBlockResult { + filePath: string; + searchReplace: SearchReplace[]; + errors?: { + global?: string; // Global error affecting all blocks + blocks?: Array<{ // Block-specific errors + index: number; // Block index (0-based) + lineNumber?: number; // Line number in original content + error: string; // Error description + }>; + }; +} - // Replace content - const newContent = - content.substring(0, searchIndex) + - block.replace + - content.substring(searchIndex + block.search.length); +// Enhanced replacement result tracking +interface ReplacementResult { + search: string; + replace: string; + applied: boolean; + error?: string; // Error message if this replacement failed + count?: number; // For global replacement, how many were replaced + actualMatch?: string; // For case-insensitive, what was actually matched +} - await writeFile(filePath, newContent); +// Options for search/replace operation +interface SearchReplaceOptions { + dryRun?: boolean; // Only simulate replacements without writing changes +} +// Maximum file and pattern size constraints +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_PATTERN_SIZE = 100 * 1024; // 100KB + +/** + * Parse the edit block content with enhanced support for multiple blocks and flags + * Maintains backward compatibility while supporting multiple search/replace pairs + */ +export async function parseEditBlock(blockContent: string): Promise { + const lines = blockContent.split('\n'); + + // First line is always file path + const filePath = lines[0].trim(); + + // Initialize result array for multiple replacements + const searchReplace: SearchReplace[] = []; + const errors: EditBlockResult['errors'] = { blocks: [] }; + + // Track the current parsing state + let currentSearch = ''; + let currentReplace = ''; + let inSearchBlock = false; + let inReplaceBlock = false; + let currentBlockIndex = 0; + let currentBlockStartLine = 0; + let currentFlags: SearchReplaceFlags = {}; + + // Parse line by line - this handles multiple blocks naturally + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + + // Start of a search block + if (line.startsWith('<<<<<<< SEARCH')) { + if (inSearchBlock || inReplaceBlock) { + errors.blocks?.push({ + index: currentBlockIndex, + lineNumber: i, + error: `Unexpected search block start while already in ${inSearchBlock ? 'search' : 'replace'} block` + }); + } + + inSearchBlock = true; + currentSearch = ''; + currentBlockStartLine = i; + currentBlockIndex = searchReplace.length; + + // Parse flags if present + currentFlags = { global: false, ignoreCase: false, dryRun: false }; + if (line.includes(':')) { + // Strip the leading "<<<<<<< SEARCH" part and re-join everything after the first ":" + const flagStr = line.split(':').slice(1).join(':').trim().toLowerCase(); + + // Process each flag character + for (let j = 0; j < flagStr.length; j++) { + const flag = flagStr[j]; + + // Handle standard single-char flags + if (flag === 'g') currentFlags.global = true; + if (flag === 'i') currentFlags.ignoreCase = true; + if (flag === 'd') currentFlags.dryRun = true; + + // Handle n:X flag for counted replacements + if (flag === 'n' && j < flagStr.length - 2 && flagStr[j+1] === ':') { + // Extract the number after n: + const numberMatch = flagStr.substring(j+2).match(/^\d+/); + if (numberMatch) { + const count = parseInt(numberMatch[0], 10); + if (!isNaN(count) && count > 0) { + currentFlags.count = count; + // Skip past the number characters + j += 1 + numberMatch[0].length; + } else { + errors.blocks?.push({ + index: currentBlockIndex, + lineNumber: i, + error: `Invalid replacement count in n:X flag: "${numberMatch[0]}"` + }); + } + } + } + } + + // Validate incompatible flag combinations + if (currentFlags.global && currentFlags.count !== undefined) { + errors.blocks?.push({ + index: currentBlockIndex, + lineNumber: i, + error: "Flags 'g' and 'n:X' are mutually exclusive" + }); + } + } + } + // Divider between search and replace + else if (line === '=======' && inSearchBlock) { + inSearchBlock = false; + inReplaceBlock = true; + currentReplace = ''; + } + // End of a replace block + else if (line === '>>>>>>> REPLACE' && inReplaceBlock) { + inReplaceBlock = false; + // Add the completed search/replace pair to the result + searchReplace.push({ + search: currentSearch, + replace: currentReplace, + flags: currentFlags + }); + } + // Content within search block + else if (inSearchBlock) { + currentSearch += (currentSearch ? '\n' : '') + line; + } + // Content within replace block + else if (inReplaceBlock) { + currentReplace += (currentReplace ? '\n' : '') + line; + } + } + + // Final validation + if (inSearchBlock || inReplaceBlock) { + errors.blocks?.push({ + index: currentBlockIndex, + lineNumber: lines.length, + error: `Unclosed ${inSearchBlock ? 'search' : 'replace'} block at end of content` + }); + } + + // Validation + if (searchReplace.length === 0) { + errors.global = 'No valid search/replace blocks found in input'; return { - content: [{ type: "text", text: `Successfully applied edit to ${filePath}` }], + filePath, + searchReplace: [], + errors }; + } + + // Only include errors field if we have errors + if (errors.blocks?.length === 0 && !errors.global) { + return { filePath, searchReplace }; + } + + return { filePath, searchReplace, errors }; } -export async function parseEditBlock(blockContent: string): Promise<{ - filePath: string; - searchReplace: SearchReplace; - error?: string; -}> { - const lines = blockContent.split('\n'); - - // First line should be the file path - const filePath = lines[0].trim(); - - // Find the markers - const searchStart = lines.indexOf('<<<<<<< SEARCH'); - const divider = lines.indexOf('======='); - const replaceEnd = lines.indexOf('>>>>>>> REPLACE'); - - if (searchStart === -1 || divider === -1 || replaceEnd === -1) { - return { - filePath: '', - searchReplace: { search: '', replace: '' }, - error: 'Invalid edit block format - missing markers' - }; +/** + * Helper to escape regular expression special characters + */ +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Perform multiple search/replace operations with extended capabilities + * Maintains identical behavior for single blocks while supporting multiple blocks and options + */ +export async function performSearchReplace( + filePath: string, + blocksOrBlock: SearchReplace[] | SearchReplace, + options: SearchReplaceOptions = {} +): Promise { + // Convert single block to array for unified processing + const blocks = Array.isArray(blocksOrBlock) ? blocksOrBlock : [blocksOrBlock]; + + // Extract global options (if provided at the operation level) + const globalDryRun = options.dryRun || false; + + // Read file content + const { content } = await readFile(filePath); + + // Ensure content is a string + if (typeof content !== 'string') { + throw new Error(`Wrong content type for file ${filePath}`); + } + + // Validate file size + if (content.length > MAX_FILE_SIZE) { + return { + content: [{ + type: "text", + text: `Error: File ${filePath} exceeds maximum size (${content.length} > ${MAX_FILE_SIZE} bytes)` + }], + }; + } + + // Initialize tracking variables + let newContent = content; + let totalReplacements = 0; + const replacementSummary: ReplacementResult[] = []; + + // Process each search/replace pair sequentially with enhanced error handling + for (const block of blocks) { + try { + const { search, replace, flags = {} } = block; + + // Combine block-level flags with operation-level options + const effectiveDryRun = flags.dryRun || globalDryRun; + + // Validate empty search pattern + if (search.length === 0) { + replacementSummary.push({ + search, + replace, + applied: false, + error: 'Search pattern must not be empty' + }); + continue; + } + + // Validate search pattern length + if (search.length > MAX_PATTERN_SIZE) { + replacementSummary.push({ + search, + replace, + applied: false, + error: `Search pattern exceeds maximum length (${search.length} > ${MAX_PATTERN_SIZE})` + }); + continue; + } + + // Handle counted replacements (n:X flag) + if (flags.count !== undefined && flags.count > 0) { + const searchRegex = flags.ignoreCase + ? new RegExp(escapeRegExp(search), 'gi') + : new RegExp(escapeRegExp(search), 'g'); + + let replacementCount = 0; + let match: RegExpExecArray | null; + let lastIndex = 0; + let resultContent = ''; + + // Build the result content by replacing only up to the specified count + while ((match = searchRegex.exec(newContent)) !== null && replacementCount < flags.count) { + // Add content up to the match + resultContent += newContent.substring(lastIndex, match.index); + // Add the replacement instead of the matched text + resultContent += replace; + // Move lastIndex past this match + lastIndex = match.index + match[0].length; + // Increment count + replacementCount++; + + // Prevent infinite loops with zero-length matches + if (match.index === searchRegex.lastIndex) { + searchRegex.lastIndex++; + } + } + + // Add any remaining content after the last replacement + if (lastIndex < newContent.length) { + resultContent += newContent.substring(lastIndex); + } + + // Only update content if not in dry run mode + if (!effectiveDryRun) { + newContent = resultContent; + } + + // Track the result + totalReplacements += replacementCount; + replacementSummary.push({ + search, + replace, + applied: replacementCount > 0, + count: replacementCount + }); + } + // If global replacement is enabled + else if (flags.global) { + // Create regex with appropriate flags + const searchRegex = flags.ignoreCase + ? new RegExp(escapeRegExp(search), 'gi') + : new RegExp(escapeRegExp(search), 'g'); + + // Make a copy for comparison + const originalContent = newContent; + + // Count matches but only apply if not in dry run mode + const matches = newContent.match(searchRegex) || []; + const replacementCount = matches.length; + + if (!effectiveDryRun && replacementCount > 0) { + // Apply the replacement + newContent = newContent.replace(searchRegex, replace); + } + + totalReplacements += replacementCount; + + // Track the result + replacementSummary.push({ + search, + replace, + applied: replacementCount > 0, + count: replacementCount + }); + } + // Case-insensitive search (without global flag) + else if (flags.ignoreCase) { + // Find the first case-insensitive match + const lowerContent = newContent.toLowerCase(); + const lowerSearch = search.toLowerCase(); + const searchIndex = lowerContent.indexOf(lowerSearch); + + if (searchIndex === -1) { + replacementSummary.push({ search, replace, applied: false }); + continue; + } + + // Extract the actual matched text (preserving original case) + const actualMatch = newContent.substring(searchIndex, searchIndex + search.length); + + // Apply the replacement if not in dry run mode + if (!effectiveDryRun) { + newContent = + newContent.substring(0, searchIndex) + + replace + + newContent.substring(searchIndex + search.length); + } + + // Track the result + replacementSummary.push({ + search, + replace, + applied: true, + actualMatch, + count: 1 + }); + totalReplacements++; + } + // Original behavior - exact match, first occurrence only + else { + // Find first occurrence of this search text + const searchIndex = newContent.indexOf(search); + + // If not found, track it but continue with others + if (searchIndex === -1) { + replacementSummary.push({ search, replace, applied: false }); + continue; + } + + // Apply the replacement if not in dry run mode + if (!effectiveDryRun) { + newContent = + newContent.substring(0, searchIndex) + + replace + + newContent.substring(searchIndex + search.length); + } + + // Track the successful replacement + replacementSummary.push({ + search, + replace, + applied: true, + count: 1 + }); + totalReplacements++; + } + } catch (error) { + // Capture specific errors for each block + replacementSummary.push({ + search: block.search, + replace: block.replace, + applied: false, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + // If no replacements were made at all, return informative message + if (totalReplacements === 0) { + // For backward compatibility, return the exact same message as the original + if (blocks.length === 1) { + return { + content: [{ type: "text", text: `Search content not found in ${filePath}.` }], + }; } - - // Extract search and replace content - const search = lines.slice(searchStart + 1, divider).join('\n'); - const replace = lines.slice(divider + 1, replaceEnd).join('\n'); return { - filePath, - searchReplace: { search, replace } + content: [{ type: "text", text: `No matches found in ${filePath} for any search patterns.` }], }; -} \ No newline at end of file + } + + // If not dry run and no blocks have dry run flag, apply the changes to the file + if ( + !globalDryRun && + totalReplacements > 0 && + !blocks.some(b => b.flags?.dryRun) + ) { + await writeFile(filePath, newContent); + } + + // Generate result message + // For backward compatibility with single block + if (blocks.length === 1 && replacementSummary[0].applied && !globalDryRun && !blocks[0].flags?.dryRun) { + return { + content: [{ type: "text", text: `Successfully applied edit to ${filePath}` }], + }; + } + + // Enhanced message for multiple blocks or dry run + let resultText = globalDryRun ? '[DRY RUN] ' : ''; + resultText += `Successfully applied ${totalReplacements} replacement${totalReplacements !== 1 ? 's' : ''} to ${filePath}:`; + + replacementSummary.forEach(summary => { + if (summary.applied) { + let details = `\n- '${truncateForDisplay(summary.search)}' -> '${truncateForDisplay(summary.replace)}'`; + if (summary.count && summary.count > 1) { + details += ` (${summary.count} occurrences)`; + } + if (summary.actualMatch && summary.actualMatch !== summary.search) { + details += ` (matched: '${truncateForDisplay(summary.actualMatch)}')`; + } + resultText += details; + } else { + let details = `\n- '${truncateForDisplay(summary.search)}' (not found)`; + if (summary.error) { + details += `: ${summary.error}`; + } + resultText += details; + } + }); + + return { + content: [{ type: "text", text: resultText }], + }; +} + +/** + * Helper to truncate long strings for display + */ +function truncateForDisplay(str: string, maxLength: number = 50): string { + if (str.length <= maxLength) return str; + return str.substring(0, maxLength - 3) + '...'; +} diff --git a/test-advanced-edit-block.js b/test-advanced-edit-block.js new file mode 100644 index 0000000..6d0e20e --- /dev/null +++ b/test-advanced-edit-block.js @@ -0,0 +1,165 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parseEditBlock, performSearchReplace } from './dist/tools/edit.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function runTest() { + try { + console.log('=== Advanced Edit Block Feature Test ===\n'); + + // Create test files + const testFilePath = path.join(__dirname, 'test-advanced-edit.txt'); + + // Test content with multiple occurrences and mixed case + const testContent = `This is a test file for the advanced edit_block functionality. +It contains MULTIPLE instances of the SAME text. +This is to test global replacement. +This is to test global replacement. +This is to test global replacement. +It also contains text with MIXED case for case-insensitive testing. +Some text with MiXeD CaSe for testing case-insensitive matching.`; + + await fs.writeFile(testFilePath, testContent); + console.log('Created test file with content:\n', testContent); + + // Test 1: Global replacement + console.log('\n--- Test 1: Global Replacement ---'); + const globalBlockContent = `${testFilePath} +<<<<<<< SEARCH:g +This is to test global replacement. +======= +This line has been replaced globally. +>>>>>>> REPLACE`; + + const globalParsed = await parseEditBlock(globalBlockContent); + console.log('Parsed block with global flag:', JSON.stringify(globalParsed, null, 2)); + + const globalResult = await performSearchReplace(testFilePath, globalParsed.searchReplace); + console.log('Global replacement result:', JSON.stringify(globalResult, null, 2)); + + const globalContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Content after global replacement:\n', globalContent); + + // Restore original content + await fs.writeFile(testFilePath, testContent); + + // Test 2: Case-insensitive replacement + console.log('\n--- Test 2: Case-Insensitive Replacement ---'); + const caseBlockContent = `${testFilePath} +<<<<<<< SEARCH:i +text with MIXED case +======= +text with case-insensitive match +>>>>>>> REPLACE`; + + const caseParsed = await parseEditBlock(caseBlockContent); + console.log('Parsed block with case-insensitive flag:', JSON.stringify(caseParsed, null, 2)); + + const caseResult = await performSearchReplace(testFilePath, caseParsed.searchReplace); + console.log('Case-insensitive replacement result:', JSON.stringify(caseResult, null, 2)); + + const caseContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Content after case-insensitive replacement:\n', caseContent); + + // Restore original content + await fs.writeFile(testFilePath, testContent); + + // Test 3: Combined flags (global & case-insensitive) + console.log('\n--- Test 3: Combined Flags (Global & Case-Insensitive) ---'); + const combinedBlockContent = `${testFilePath} +<<<<<<< SEARCH:gi +mixed case +======= +CASE-INSENSITIVE AND GLOBAL match +>>>>>>> REPLACE`; + + const combinedParsed = await parseEditBlock(combinedBlockContent); + console.log('Parsed block with combined flags:', JSON.stringify(combinedParsed, null, 2)); + + const combinedResult = await performSearchReplace(testFilePath, combinedParsed.searchReplace); + console.log('Combined flags replacement result:', JSON.stringify(combinedResult, null, 2)); + + const combinedContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Content after combined flags replacement:\n', combinedContent); + + // Test 4: Dry run + console.log('\n--- Test 4: Dry Run ---'); + const dryRunBlockContent = `${testFilePath} +<<<<<<< SEARCH:d +This is a test file +======= +THIS SHOULD NOT BE APPLIED +>>>>>>> REPLACE`; + + const dryRunParsed = await parseEditBlock(dryRunBlockContent); + console.log('Parsed block with dry run flag:', JSON.stringify(dryRunParsed, null, 2)); + + const dryRunResult = await performSearchReplace(testFilePath, dryRunParsed.searchReplace); + console.log('Dry run replacement result:', JSON.stringify(dryRunResult, null, 2)); + + const dryRunContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Content after dry run (should be unchanged):\n', dryRunContent); + + // Test 5: Error handling (pattern not found) + console.log('\n--- Test 5: Error Handling (Pattern Not Found) ---'); + const notFoundBlockContent = `${testFilePath} +<<<<<<< SEARCH +This pattern doesn't exist in the file +======= +This replacement won't be applied +>>>>>>> REPLACE`; + + const notFoundParsed = await parseEditBlock(notFoundBlockContent); + console.log('Parsed block with non-existent pattern:', JSON.stringify(notFoundParsed, null, 2)); + + const notFoundResult = await performSearchReplace(testFilePath, notFoundParsed.searchReplace); + console.log('Non-existent pattern result:', JSON.stringify(notFoundResult, null, 2)); + + // Test 6: Multiple blocks in a single operation + console.log('\n--- Test 6: Multiple Blocks with Mix of Flags ---'); + const multiBlockContent = `${testFilePath} +<<<<<<< SEARCH:g +This is to test +======= +Testing multi-block +>>>>>>> REPLACE +<<<<<<< SEARCH:i +MIXED case +======= +mixed-case fixed +>>>>>>> REPLACE +<<<<<<< SEARCH +non-existent pattern +======= +won't be applied +>>>>>>> REPLACE`; + + const multiParsed = await parseEditBlock(multiBlockContent); + console.log('Parsed multiple blocks:', JSON.stringify(multiParsed, null, 2)); + + const multiResult = await performSearchReplace(testFilePath, multiParsed.searchReplace); + console.log('Multi-block replacement result:', JSON.stringify(multiResult, null, 2)); + + const multiContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Content after multi-block replacement:\n', multiContent); + + // Clean up + await fs.unlink(testFilePath); + console.log('\nTest file cleaned up'); + + console.log('\n=== All Tests Completed Successfully ==='); + return true; + } catch (error) { + console.error('Error during test:', error); + return false; + } +} + +// Run the test +runTest().then(success => { + console.log('Test result:', success ? 'PASSED' : 'FAILED'); + process.exit(success ? 0 : 1); +}); diff --git a/test-counted-examples.txt b/test-counted-examples.txt new file mode 100644 index 0000000..4d1febe --- /dev/null +++ b/test-counted-examples.txt @@ -0,0 +1,6 @@ +./test-edit-block.txt +<<<<<<< SEARCH:n:2 +pattern +======= +PATTERN (replace only first 2) +>>>>>>> REPLACE \ No newline at end of file diff --git a/test-counted-replacements.js b/test-counted-replacements.js new file mode 100644 index 0000000..30ba211 --- /dev/null +++ b/test-counted-replacements.js @@ -0,0 +1,116 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parseEditBlock, performSearchReplace } from './dist/tools/edit.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function runTest() { + try { + console.log('=== Counted Replacements Test ===\n'); + + // Create test file + const testFilePath = path.join(__dirname, 'test-counted.txt'); + + // Test content with multiple occurrences + const testContent = `This is line 1 with the test pattern. +This is line 2 with the test pattern. +This is line 3 with the test pattern. +This is line 4 with the test pattern. +This is line 5 with the test pattern.`; + + await fs.writeFile(testFilePath, testContent); + console.log('Created test file with content:\n', testContent); + + // Test 1: Replace just 2 occurrences + console.log('\n--- Test 1: Replace First 2 Occurrences ---'); + const countBlockContent = `${testFilePath} +<<<<<<< SEARCH:n:2 +test pattern +======= +REPLACED PATTERN +>>>>>>> REPLACE`; + + const countParsed = await parseEditBlock(countBlockContent); + console.log('Parsed block with n:2 flag:', JSON.stringify(countParsed, null, 2)); + + const countResult = await performSearchReplace(testFilePath, countParsed.searchReplace); + console.log('Counted replacement result:', JSON.stringify(countResult, null, 2)); + + const countContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Content after replacing first 2 occurrences:\n', countContent); + + // Restore original content + await fs.writeFile(testFilePath, testContent); + + // Test 2: Case-insensitive with count + console.log('\n--- Test 2: Case-Insensitive with Count ---'); + const mixedContent = `This is line 1 with the TEST pattern. +This is line 2 with the test PATTERN. +This is line 3 with the Test Pattern. +This is line 4 with the TEST PATTERN. +This is line 5 with the test pattern.`; + + await fs.writeFile(testFilePath, mixedContent); + + const caseCountBlock = `${testFilePath} +<<<<<<< SEARCH:i:n:3 +test pattern +======= +counted case-insensitive +>>>>>>> REPLACE`; + + const caseCountParsed = await parseEditBlock(caseCountBlock); + console.log('Parsed block with i:n:3 flags:', JSON.stringify(caseCountParsed, null, 2)); + + const caseCountResult = await performSearchReplace(testFilePath, caseCountParsed.searchReplace); + console.log('Case-insensitive counted replacement result:', JSON.stringify(caseCountResult, null, 2)); + + const caseCountContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Content after case-insensitive counted replacement:\n', caseCountContent); + + // Test 3: Multiple blocks with count + console.log('\n--- Test 3: Multiple Blocks with Different Counts ---'); + + // Restore original content + await fs.writeFile(testFilePath, testContent); + + const multiCountBlock = `${testFilePath} +<<<<<<< SEARCH:n:1 +line 1 +======= +FIRST LINE +>>>>>>> REPLACE +<<<<<<< SEARCH:n:2 +line +======= +LINE +>>>>>>> REPLACE`; + + const multiCountParsed = await parseEditBlock(multiCountBlock); + console.log('Parsed multiple blocks with counts:', JSON.stringify(multiCountParsed, null, 2)); + + const multiCountResult = await performSearchReplace(testFilePath, multiCountParsed.searchReplace); + console.log('Multiple blocks with counts result:', JSON.stringify(multiCountResult, null, 2)); + + const multiCountContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Content after multiple blocks with counts:\n', multiCountContent); + + // Clean up + await fs.unlink(testFilePath); + console.log('\nTest file cleaned up'); + + console.log('\n=== All Tests Completed Successfully ==='); + return true; + } catch (error) { + console.error('Error during test:', error); + return false; + } +} + +// Run the test +runTest().then(success => { + console.log('Test result:', success ? 'PASSED' : 'FAILED'); + process.exit(success ? 0 : 1); +}); diff --git a/test-edit-block-original.txt b/test-edit-block-original.txt new file mode 100644 index 0000000..7870dbf --- /dev/null +++ b/test-edit-block-original.txt @@ -0,0 +1,11 @@ +This is a test file for the enhanced edit_block functionality. + +It contains several lines of text that we can search and replace. + +First pattern: This line will be replaced first. + +Second pattern: This line will be replaced second. + +Third pattern: This line won't be found in our test. + +The end of the test file. \ No newline at end of file diff --git a/test-edit-block.txt b/test-edit-block.txt new file mode 100644 index 0000000..7870dbf --- /dev/null +++ b/test-edit-block.txt @@ -0,0 +1,11 @@ +This is a test file for the enhanced edit_block functionality. + +It contains several lines of text that we can search and replace. + +First pattern: This line will be replaced first. + +Second pattern: This line will be replaced second. + +Third pattern: This line won't be found in our test. + +The end of the test file. \ No newline at end of file diff --git a/test-error-handling.js b/test-error-handling.js new file mode 100644 index 0000000..e09fbb6 --- /dev/null +++ b/test-error-handling.js @@ -0,0 +1,99 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parseEditBlock, performSearchReplace } from './dist/tools/edit.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function runTest() { + try { + console.log('=== Edit Block Error Handling Test ===\n'); + + // Test 1: Malformed block (missing separator) + console.log('--- Test 1: Malformed Block (Missing Separator) ---'); + const malformedBlock = `/Users/davidleathers/dev/desktop-commander/test-error.txt +<<<<<<< SEARCH +Search content +>>>>>>> REPLACE`; // Missing separator + + const malformedResult = await parseEditBlock(malformedBlock); + console.log('Malformed block parsing result:', JSON.stringify(malformedResult, null, 2)); + + // Test 2: Unclosed block + console.log('\n--- Test 2: Unclosed Block ---'); + const unclosedBlock = `/Users/davidleathers/dev/desktop-commander/test-error.txt +<<<<<<< SEARCH +Search content +======= +Replace content`; // Missing closing tag + + const unclosedResult = await parseEditBlock(unclosedBlock); + console.log('Unclosed block parsing result:', JSON.stringify(unclosedResult, null, 2)); + + // Test 3: Empty blocks + console.log('\n--- Test 3: Empty Search/Replace Pair ---'); + const emptyBlock = `/Users/davidleathers/dev/desktop-commander/test-error.txt +<<<<<<< SEARCH +======= +>>>>>>> REPLACE`; + + const emptyResult = await parseEditBlock(emptyBlock); + console.log('Empty block parsing result:', JSON.stringify(emptyResult, null, 2)); + + // Test 4: Nested blocks (not supported) + console.log('\n--- Test 4: Nested Blocks (Not Supported) ---'); + const nestedBlock = `/Users/davidleathers/dev/desktop-commander/test-error.txt +<<<<<<< SEARCH +Outer search +<<<<<<< SEARCH +Inner search +======= +Inner replace +>>>>>>> REPLACE +======= +Outer replace +>>>>>>> REPLACE`; + + const nestedResult = await parseEditBlock(nestedBlock); + console.log('Nested block parsing result:', JSON.stringify(nestedResult, null, 2)); + + // Test 5: Large pattern + console.log('\n--- Test 5: Large Pattern Test ---'); + // Create a large pattern (just over the max size) + const largePattern = 'A'.repeat(101 * 1024); // 101KB + + // Create a test file + const testFilePath = path.join(__dirname, 'test-error.txt'); + await fs.writeFile(testFilePath, 'Test content with a small amount of text.'); + + const largeBlock = `${testFilePath} +<<<<<<< SEARCH +${largePattern} +======= +Small replacement +>>>>>>> REPLACE`; + + const largeParsed = await parseEditBlock(largeBlock); + console.log('Large pattern parsed successfully:', !!largeParsed); + + // Attempt to perform the replacement (should fail with size validation) + const largeResult = await performSearchReplace(testFilePath, largeParsed.searchReplace); + console.log('Large pattern replacement result:', JSON.stringify(largeResult, null, 2)); + + // Clean up + await fs.unlink(testFilePath); + + console.log('\n=== All Error Handling Tests Completed ==='); + return true; + } catch (error) { + console.error('Error during test:', error); + return false; + } +} + +// Run the test +runTest().then(success => { + console.log('Test result:', success ? 'PASSED' : 'FAILED'); + process.exit(success ? 0 : 1); +}); diff --git a/test-multi-block.js b/test-multi-block.js new file mode 100644 index 0000000..85a231a --- /dev/null +++ b/test-multi-block.js @@ -0,0 +1,70 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parseEditBlock, performSearchReplace } from './dist/tools/edit.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function testMultipleBlocks() { + try { + console.log('Starting multi-block test'); + + // Create test file + const testFilePath = path.join(__dirname, 'test-edit-block.txt'); + + // Read original content to verify at the end + console.log('Reading original file content'); + const originalContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Original content:\n', originalContent); + + // Read the multi-block file + console.log('Parsing multi-block file'); + const blockContent = await fs.readFile(path.join(__dirname, 'test-multiple-blocks.txt'), 'utf8'); + + // Parse the blocks + const parsed = await parseEditBlock(blockContent); + console.log('Parsed block result:', JSON.stringify(parsed, null, 2)); + + // Perform the replacements + console.log('Performing replacements'); + const result = await performSearchReplace(parsed.filePath, parsed.searchReplace); + console.log('Replacement result:', JSON.stringify(result, null, 2)); + + // Read the modified content + const modifiedContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Modified content:\n', modifiedContent); + + // Create expected content with the specific replacements + let expectedContent = originalContent + .replace('First pattern: This line will be replaced first.', 'First block replacement successful.') + .replace('Second pattern: This line will be replaced globally.', 'Second block replacement successful.') + .replace('Second pattern: This line will be replaced globally.', 'Second block replacement successful.'); + + // The third pattern in the test file doesn't exist in the original content, so no further replacements + + // Verify the replacements were successful + if (modifiedContent !== expectedContent) { + throw new Error('Content does not match expected replacements'); + } + + // Restore the original content + await fs.writeFile(testFilePath, originalContent); + console.log('Test file restored to original content'); + + console.log('Multi-block test completed successfully'); + return true; + } catch (error) { + console.error('Test failed:', error); + return false; + } +} + +// Run the test +testMultipleBlocks().then(success => { + console.log('Test result:', success ? 'PASSED' : 'FAILED'); + process.exit(success ? 0 : 1); +}).catch(err => { + console.error('Unhandled error:', err); + process.exit(1); +}); diff --git a/test-multiple-blocks-enhanced.txt b/test-multiple-blocks-enhanced.txt new file mode 100644 index 0000000..d7f82aa --- /dev/null +++ b/test-multiple-blocks-enhanced.txt @@ -0,0 +1,16 @@ +./test-edit-block.txt +<<<<<<< SEARCH:g +pattern +======= +PATTERN (global replacement) +>>>>>>> REPLACE +<<<<<<< SEARCH:i +MIXED +======= +mixed (case-insensitive) +>>>>>>> REPLACE +<<<<<<< SEARCH:gi +line +======= +LINE (global & case-insensitive) +>>>>>>> REPLACE \ No newline at end of file diff --git a/test-multiple-blocks.txt b/test-multiple-blocks.txt new file mode 100644 index 0000000..21ddc94 --- /dev/null +++ b/test-multiple-blocks.txt @@ -0,0 +1,16 @@ +./test-edit-block.txt +<<<<<<< SEARCH +First pattern: This line will be replaced first. +======= +First pattern: This line has been successfully replaced. +>>>>>>> REPLACE +<<<<<<< SEARCH +Second pattern: This line will be replaced second. +======= +Second pattern: This line has also been successfully replaced. +>>>>>>> REPLACE +<<<<<<< SEARCH +Pattern that doesn't exist in the file. +======= +This replacement won't be applied. +>>>>>>> REPLACE \ No newline at end of file diff --git a/test-single-block.js b/test-single-block.js new file mode 100644 index 0000000..bbcc8af --- /dev/null +++ b/test-single-block.js @@ -0,0 +1,69 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parseEditBlock, performSearchReplace } from './dist/tools/edit.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function testSingleBlock() { + try { + console.log('Starting single-block test (backward compatibility)'); + + // Create test file + const testFilePath = path.join(__dirname, 'test-edit-block.txt'); + + // Read original content to verify at the end + console.log('Reading original file content'); + const originalContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Original content:\n', originalContent); + + // Create simple block content + const blockContent = `${testFilePath}\n<<<<<<< SEARCH\nFirst pattern: This line will be replaced first.\n=======\nSingle block replacement successful.\n>>>>>>> REPLACE`; + + // Parse the block + console.log('Parsing single block content'); + const parsed = await parseEditBlock(blockContent); + console.log('Parsed block result:', JSON.stringify(parsed, null, 2)); + + // Try the original direct format (backward compatibility) + console.log('Testing backward compatibility with single SearchReplace object'); + const directResult = await performSearchReplace(testFilePath, { + search: 'First pattern: This line will be replaced first.', + replace: 'Direct single object replacement successful.' + }); + console.log('Direct replacement result:', JSON.stringify(directResult, null, 2)); + + // Read the modified content + const modifiedContent = await fs.readFile(testFilePath, 'utf8'); + console.log('Modified content:\n', modifiedContent); + + // Verify the replacement was successful + const expectedContent = originalContent.replace( + 'First pattern: This line will be replaced first.', + 'Direct single object replacement successful.' + ); + if (modifiedContent !== expectedContent) { + throw new Error('Content does not match expected replacement'); + } + + // Restore the original content + await fs.writeFile(testFilePath, originalContent); + console.log('Test file restored to original content'); + + console.log('Single-block backward compatibility test completed successfully'); + return true; + } catch (error) { + console.error('Test failed:', error); + return false; + } +} + +// Run the test +testSingleBlock().then(success => { + console.log('Test result:', success ? 'PASSED' : 'FAILED'); + process.exit(success ? 0 : 1); +}).catch(err => { + console.error('Unhandled error:', err); + process.exit(1); +});