|cat -n format| Display[LLM Sees] Display -->|Strips line numbers| Edit[EditTool] Edit --> Validate{Validation} Validate -->|Pass| Apply[Apply Edit] Validate -->|Fail| Error[Error Result] Apply --> Cache[Update Cache] Cache --> Diff[Generate Diff] Diff --> Confirm[Confirmation] subgraph "Validation Checks" V1[File was read?] V2[File unchanged?] V3[String exists?] V4[Count matches?] V5[Not no-op?] end Validate --> V1 V1 --> V2 V2 --> V3 V3 --> V4 V4 --> V5 end "> |cat -n format| Display[LLM Sees] Display -->|Strips line numbers| Edit[EditTool] Edit --> Validate{Validation} Validate -->|Pass| Apply[Apply Edit] Validate -->|Fail| Error[Error Result] Apply --> Cache[Update Cache] Cache --> Diff[Generate Diff] Diff --> Confirm[Confirmation] subgraph "Validation Checks" V1[File was read?] V2[File unchanged?] V3[String exists?] V4[Count matches?] V5[Not no-op?] end Validate --> V1 V1 --> V2 V2 --> V3 V3 --> V4 V4 --> V5 end "> |cat -n format| Display[LLM Sees] Display -->|Strips line numbers| Edit[EditTool] Edit --> Validate{Validation} Validate -->|Pass| Apply[Apply Edit] Validate -->|Fail| Error[Error Result] Apply --> Cache[Update Cache] Cache --> Diff[Generate Diff] Diff --> Confirm[Confirmation] subgraph "Validation Checks" V1[File was read?] V2[File unchanged?] V3[String exists?] V4[Count matches?] V5[Not no-op?] end Validate --> V1 V1 --> V2 V2 --> V3 V3 --> V4 V4 --> V5 end ">
graph TB
subgraph "File Editing Pipeline"
Read[ReadTool] -->|cat -n format| Display[LLM Sees]
Display -->|Strips line numbers| Edit[EditTool]
Edit --> Validate{Validation}
Validate -->|Pass| Apply[Apply Edit]
Validate -->|Fail| Error[Error Result]
Apply --> Cache[Update Cache]
Cache --> Diff[Generate Diff]
Diff --> Confirm[Confirmation]
subgraph "Validation Checks"
V1[File was read?]
V2[File unchanged?]
V3[String exists?]
V4[Count matches?]
V5[Not no-op?]
end
Validate --> V1
V1 --> V2
V2 --> V3
V3 --> V4
V4 --> V5
end
File editing in Claude Code isn't just about changing text—it's a carefully orchestrated pipeline designed to handle the complexities of AI-assisted code modification:
class FileEditingPipeline {
// The four-phase editing cycle
static async executeEdit(
tool: EditTool,
input: EditInput,
context: ToolContext
): Promise<EditResult> {
// Phase 1: Validation
const validation = await this.validateEdit(input, context);
if (!validation.valid) {
return { success: false, error: validation.error };
}
// Phase 2: Preparation
const prepared = await this.prepareEdit(input, validation.fileState);
// Phase 3: Application
const result = await this.applyEdit(prepared);
// Phase 4: Verification
const verified = await this.verifyEdit(result, input);
return verified;
}
// The state tracking system
private static fileStates = new Map<string, FileState>();
interface FileState {
content: string;
hash: string;
mtime: number;
encoding: BufferEncoding;
lineEndings: '\\\\n' | '\\\\r\\\\n' | '\\\\r';
isBinary: boolean;
size: number;
}
}
Why Multiple Tools Instead of One Universal Editor?
Tool | Purpose | Guarantees | Failure Mode |
---|---|---|---|
EditTool |
Single string replacement | Exact match count | Fails if occurrence ≠ expected |
MultiEditTool |
Sequential edits | Atomic batch | Fails if any edit invalid |
WriteTool |
Full replacement | Complete overwrite | Fails if not read first |
NotebookEditTool |
Cell operations | Structure preserved | Fails if cell missing |
Each tool provides specific guarantees that a universal editor couldn't maintain while remaining LLM-friendly.
The most critical challenge in file editing is the line number prefix problem:
// What the LLM sees from ReadTool:
const readOutput = `
1 function hello() {
2 console.log('Hello, world!');
3 }
`;
// What the LLM might incorrectly try to edit:
const wrongOldString = "2 console.log('Hello, world!');"; // WRONG - includes line number
// What it should use:
const correctOldString = " console.log('Hello, world!');"; // CORRECT - no line number
The line number stripping logic:
class LineNumberHandler {
// The LLM receives extensive instructions about this
static readonly LINE_NUMBER_PATTERN = /^\\\\d+\\\\t/;
static stripLineNumbers(content: string): string {
return content
.split('\\\\n')
.map(line => line.replace(this.LINE_NUMBER_PATTERN, ''))
.join('\\\\n');
}
// But the real challenge is ensuring the LLM does this
static validateOldString(
oldString: string,
fileContent: string
): ValidationResult {
// Check 1: Does oldString contain line number prefix?
if (this.LINE_NUMBER_PATTERN.test(oldString)) {
return {
valid: false,
error: 'old_string appears to contain line number prefix. ' +
'Remove the number and tab at the start.',
suggestion: oldString.replace(this.LINE_NUMBER_PATTERN, '')
};
}
// Check 2: Does the string exist in the file?
const occurrences = this.countOccurrences(fileContent, oldString);
if (occurrences === 0) {
// Try to detect if it's a line number issue
const possibleLineNumber = oldString.match(/^(\\\\d+)\\\\t/);
if (possibleLineNumber) {
const lineNum = parseInt(possibleLineNumber[1]);
const actualLine = this.getLine(fileContent, lineNum);
return {
valid: false,
error: `String not found. Did you include line number ${lineNum}?`,
suggestion: actualLine
};
}
}
return { valid: true, occurrences };
}
}
The EditTool implements exact string matching with zero ambiguity:
class EditToolImplementation {
static async executeEdit(
input: EditInput,
context: ToolContext
): Promise<EditResult> {
const { file_path, old_string, new_string, expected_replacements = 1 } = input;
// Step 1: Retrieve cached file state
const cachedFile = context.readFileState.get(file_path);
if (!cachedFile) {
throw new Error(
'File must be read with ReadFileTool before editing. ' +
'This ensures you have the current file content.'
);
}
// Step 2: Verify file hasn't changed externally
const currentStats = await fs.stat(file_path);
if (currentStats.mtimeMs !== cachedFile.timestamp) {
throw new Error(
'File has been modified externally since last read. ' +
'Please read the file again to see current content.'
);
}
// Step 3: Validate the edit
const validation = this.validateEdit(
old_string,
new_string,
cachedFile.content,
expected_replacements
);
if (!validation.valid) {
throw new Error(validation.error);
}
// Step 4: Apply the replacement
const newContent = this.performReplacement(
cachedFile.content,
old_string,
new_string,
expected_replacements
);
// Step 5: Generate diff for verification
const diff = this.generateDiff(
cachedFile.content,
newContent,
file_path
);
// Step 6: Write with same encoding/line endings
await this.writeFilePreservingFormat(
file_path,
newContent,
cachedFile
);
// Step 7: Update cache
context.readFileState.set(file_path, {
content: newContent,
timestamp: Date.now()
});
// Step 8: Generate context snippet
const snippet = this.generateContextSnippet(
newContent,
new_string,
5 // lines of context
);
return {
success: true,
diff,
snippet,
replacements: expected_replacements
};
}
private static validateEdit(
oldString: string,
newString: string,
fileContent: string,
expectedReplacements: number
): EditValidation {
// No-op check
if (oldString === newString) {
return {
valid: false,
error: 'old_string and new_string are identical. No changes would be made.'
};
}
// Empty old_string special case (insertion)
if (oldString === '') {
return {
valid: false,
error: 'Empty old_string not allowed. Use WriteTool for new files.'
};
}
// Count occurrences with exact string matching
const occurrences = this.countExactOccurrences(fileContent, oldString);
if (occurrences === 0) {
return {
valid: false,
error: 'old_string not found in file. Ensure exact match including whitespace.',
suggestion: this.findSimilarStrings(fileContent, oldString)
};
}
if (occurrences !== expectedReplacements) {
return {
valid: false,
error: `Expected ${expectedReplacements} replacement(s) but found ${occurrences} occurrence(s). ` +
`Set expected_replacements to ${occurrences} or refine old_string.`
};
}
return { valid: true };
}
private static countExactOccurrences(
content: string,
searchString: string
): number {
// Escape special regex characters for exact matching
const escaped = searchString.replace(/[.*+?^${}()|[\\\\]\\\\\\\\]/g, '\\\\\\\\$&');
const regex = new RegExp(escaped, 'g');
return (content.match(regex) || []).length;
}
private static performReplacement(
content: string,
oldString: string,
newString: string,
limit: number
): string {
// Character escaping for special replacement patterns
const escapeReplacement = (str: string) => {
return str
.replace(/\\\\$/g, '$$$$') // $ -> $$
.replace(/\\\\n/g, '\\\\n') // Preserve newlines
.replace(/\\\\r/g, '\\\\r'); // Preserve carriage returns
};
const escapedNew = escapeReplacement(newString);
let result = content;
let count = 0;
let lastIndex = 0;
// Manual replacement to respect limit
while (count < limit) {
const index = result.indexOf(oldString, lastIndex);
if (index === -1) break;
result = result.slice(0, index) +
newString + // Use original, not escaped
result.slice(index + oldString.length);
lastIndex = index + newString.length;
count++;
}
return result;
}
private static generateDiff(
oldContent: string,
newContent: string,
filePath: string
): string {
// Use unified diff format
const diff = createUnifiedDiff(
filePath,
filePath,
oldContent,
newContent,
'before edit',
'after edit',
{ context: 3 }
);
return diff;
}
}
Why expected_replacements
Matters:
// Scenario: Multiple occurrences
const fileContent = `
function processUser(user) {
console.log(user);
return user;
}
`;
// Without expected_replacements:
edit({
old_string: "user",
new_string: "userData"
});
// Result: ALL occurrences replaced (function parameter too!)
// With expected_replacements:
edit({
old_string: "user",
new_string: "userData",
expected_replacements: 2 // Only the uses, not parameter
});
// Result: Fails - forces more specific old_string
MultiEditTool solves the complex problem of multiple related edits:
class MultiEditToolImplementation {
static async executeMultiEdit(
input: MultiEditInput,
context: ToolContext
): Promise<MultiEditResult> {
const { file_path, edits } = input;
// Load file once
const cachedFile = context.readFileState.get(file_path);
if (!cachedFile) {
throw new Error('File must be read before editing');
}
// Validate all edits before applying any
const validationResult = this.validateAllEdits(
edits,
cachedFile.content
);
if (!validationResult.valid) {
throw new Error(validationResult.error);
}
// Apply edits sequentially to working copy
let workingContent = cachedFile.content;
const appliedEdits: AppliedEdit[] = [];
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
try {
// Validate this edit against current working content
const validation = this.validateSingleEdit(
edit,
workingContent,
i
);
if (!validation.valid) {
throw new Error(
`Edit ${i + 1} failed: ${validation.error}`
);
}
// Apply edit
const beforeEdit = workingContent;
workingContent = this.applyEdit(
workingContent,
edit
);
appliedEdits.push({
index: i,
edit,
diff: this.generateEditDiff(beforeEdit, workingContent),
summary: this.summarizeEdit(edit)
});
} catch (error) {
// Atomic failure - no changes written
throw new Error(
`MultiEdit aborted at edit ${i + 1}/${edits.length}: ${error.message}`
);
}
}
// All edits validated and applied - write once
await this.writeFilePreservingFormat(
file_path,
workingContent,
cachedFile
);
// Update cache
context.readFileState.set(file_path, {
content: workingContent,
timestamp: Date.now()
});
return {
success: true,
editsApplied: appliedEdits,
totalDiff: this.generateDiff(
cachedFile.content,
workingContent,
file_path
)
};
}
private static validateAllEdits(
edits: Edit[],
originalContent: string
): ValidationResult {
// Check for empty edits array
if (edits.length === 0) {
return {
valid: false,
error: 'No edits provided'
};
}
// Detect potential conflicts
const conflicts = this.detectEditConflicts(edits, originalContent);
if (conflicts.length > 0) {
return {
valid: false,
error: 'Edit conflicts detected:\\\\n' +
conflicts.map(c => c.description).join('\\\\n')
};
}
// Simulate all edits to ensure they work
let simulatedContent = originalContent;
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
const occurrences = this.countOccurrences(
simulatedContent,
edit.old_string
);
if (occurrences === 0) {
return {
valid: false,
error: `Edit ${i + 1}: old_string not found. ` +
`Previous edits may have removed it.`
};
}
if (occurrences !== (edit.expected_replacements || 1)) {
return {
valid: false,
error: `Edit ${i + 1}: Expected ${edit.expected_replacements || 1} ` +
`replacements but found ${occurrences}`
};
}
// Apply to simulation
simulatedContent = this.applyEdit(simulatedContent, edit);
}
return { valid: true };
}
private static detectEditConflicts(
edits: Edit[],
content: string
): EditConflict[] {
const conflicts: EditConflict[] = [];
for (let i = 0; i < edits.length - 1; i++) {
for (let j = i + 1; j < edits.length; j++) {
const edit1 = edits[i];
const edit2 = edits[j];
// Conflict Type 1: Later edit modifies earlier edit's result
if (edit2.old_string.includes(edit1.new_string)) {
conflicts.push({
type: 'dependency',
edits: [i, j],
description: `Edit ${j + 1} depends on result of edit ${i + 1}`
});
}
// Conflict Type 2: Overlapping replacements
if (this.editsOverlap(edit1, edit2, content)) {
conflicts.push({
type: 'overlap',
edits: [i, j],
description: `Edits ${i + 1} and ${j + 1} affect overlapping text`
});
}
// Conflict Type 3: Same target, different replacements
if (edit1.old_string === edit2.old_string &&
edit1.new_string !== edit2.new_string) {
conflicts.push({
type: 'contradiction',
edits: [i, j],
description: `Edits ${i + 1} and ${j + 1} replace same text differently`
});
}
}
}
return conflicts;
}
private static editsOverlap(
edit1: Edit,
edit2: Edit,
content: string
): boolean {
// Find positions of all occurrences
const positions1 = this.findAllPositions(content, edit1.old_string);
const positions2 = this.findAllPositions(content, edit2.old_string);
// Check if any positions overlap
for (const pos1 of positions1) {
const end1 = pos1 + edit1.old_string.length;
for (const pos2 of positions2) {
const end2 = pos2 + edit2.old_string.length;
// Check for overlap
if (pos1 < end2 && pos2 < end1) {
return true;
}
}
}
return false;
}
}