Skip to content

Commit 4a3b007

Browse files
author
Dimitar Grigorov
committed
Deduplicate atomic write in edit_file.go
1 parent e5671fd commit 4a3b007

File tree

1 file changed

+11
-79
lines changed

1 file changed

+11
-79
lines changed

filetoolsserver/handler/edit_file.go

Lines changed: 11 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package handler
22

33
import (
44
"context"
5-
"crypto/rand"
6-
"encoding/hex"
75
"fmt"
86
"log/slog"
97
"os"
@@ -14,8 +12,7 @@ import (
1412
"github.com/pmezard/go-difflib/difflib"
1513
)
1614

17-
// HandleEditFile applies line-based edits to a text file.
18-
// Supports non-UTF-8 encodings via auto-detection or explicit encoding parameter.
15+
// HandleEditFile applies line-based edits to a text file with encoding support.
1916
func (h *Handler) HandleEditFile(ctx context.Context, req *mcp.CallToolRequest, input EditFileInput) (*mcp.CallToolResult, EditFileOutput, error) {
2017
if len(input.Edits) == 0 {
2118
return errorResult(ErrEditsRequired.Error()), EditFileOutput{}, nil
@@ -26,15 +23,12 @@ func (h *Handler) HandleEditFile(ctx context.Context, req *mcp.CallToolRequest,
2623
return v.Result, EditFileOutput{}, nil
2724
}
2825

29-
// Check file size - warn if large file will be loaded to memory
3026
if loadToMemory, size := h.shouldLoadEntireFile(v.Path); !loadToMemory {
3127
slog.Warn("loading large file into memory", "path", input.Path, "size", size, "threshold", h.config.MemoryThreshold)
3228
}
3329

34-
// Preserve original file permissions
3530
originalMode := getFileMode(v.Path)
3631

37-
// Handle read-only files
3832
readOnlyCleared := false
3933
forceWritable := input.ForceWritable == nil || *input.ForceWritable // default: true
4034
if isReadOnly(originalMode) {
@@ -55,20 +49,17 @@ func (h *Handler) HandleEditFile(ctx context.Context, req *mcp.CallToolRequest,
5549
return errorResult(fmt.Sprintf("failed to read file: %v", err)), EditFileOutput{}, nil
5650
}
5751

58-
// Detect line endings before any processing
5952
// TODO: Use DetectLineEndingsFromFile for streaming when file > MemoryThreshold
6053
lineEndings := DetectLineEndings(data)
6154
if lineEndings.Style == LineEndingMixed {
6255
slog.Warn("file has mixed line endings", "path", input.Path, "crlf", lineEndings.CRLFCount, "lf", lineEndings.LFCount)
6356
}
6457

65-
// Resolve encoding: explicit > auto-detect from data
6658
encodingName, err := h.resolveEncodingFromData(input.Encoding, data, input.Path)
6759
if err != nil {
6860
return errorResult(err.Error()), EditFileOutput{}, nil
6961
}
7062

71-
// Decode file content to UTF-8 for matching
7263
var content string
7364
if encoding.IsUTF8(encodingName) {
7465
content = string(data)
@@ -83,22 +74,15 @@ func (h *Handler) HandleEditFile(ctx context.Context, req *mcp.CallToolRequest,
8374
slog.Debug("edit_file: decoded content", "path", input.Path, "encoding", encodingName, "originalSize", len(data), "decodedSize", len(decoded))
8475
}
8576

86-
// Normalize line endings (CRLF -> LF) for consistent processing
8777
content = ConvertLineEndings(content, LineEndingLF)
88-
89-
// Apply edits sequentially
9078
modifiedContent, err := applyEdits(content, input.Edits)
9179
if err != nil {
9280
return errorResult(err.Error()), EditFileOutput{}, nil
9381
}
9482

95-
// Generate unified diff
9683
diff := createUnifiedDiff(content, modifiedContent, input.Path)
97-
98-
// Format diff with markdown code fence
9984
formattedDiff := formatDiffOutput(diff)
10085

101-
// Write file if not dry run (atomic write with encoding and line ending preservation)
10286
if !input.DryRun {
10387
if err := atomicWriteFileWithEncoding(v.Path, modifiedContent, encodingName, lineEndings.Style, originalMode); err != nil {
10488
return errorResult(fmt.Sprintf("failed to write file: %v", err)), EditFileOutput{}, nil
@@ -108,13 +92,11 @@ func (h *Handler) HandleEditFile(ctx context.Context, req *mcp.CallToolRequest,
10892
return &mcp.CallToolResult{}, EditFileOutput{Diff: formattedDiff, ReadOnlyCleared: readOnlyCleared}, nil
10993
}
11094

111-
// applyEdits applies a sequence of edit operations to content.
112-
// Each edit is tried first as an exact match, then with whitespace-flexible matching.
95+
// applyEdits applies edits sequentially, trying exact match then whitespace-flexible match.
11396
func applyEdits(content string, edits []EditOperation) (string, error) {
11497
modifiedContent := content
11598

11699
for _, edit := range edits {
117-
// Validate that oldText is not empty
118100
if edit.OldText == "" {
119101
return "", ErrOldTextEmpty
120102
}
@@ -141,21 +123,18 @@ func applyEdits(content string, edits []EditOperation) (string, error) {
141123
return modifiedContent, nil
142124
}
143125

144-
// tryFlexibleMatch attempts to match oldText in content with whitespace flexibility.
145-
// It compares lines with trimmed whitespace and preserves original indentation.
126+
// tryFlexibleMatch matches oldText ignoring whitespace differences, preserving file indentation.
146127
func tryFlexibleMatch(content, oldText, newText string) (bool, string) {
147128
oldLines := strings.Split(oldText, "\n")
148129
contentLines := strings.Split(content, "\n")
149130

150-
// Need at least as many content lines as old lines to match
151131
if len(contentLines) < len(oldLines) {
152132
return false, ""
153133
}
154134

155135
for i := 0; i <= len(contentLines)-len(oldLines); i++ {
156136
potentialMatch := contentLines[i : i+len(oldLines)]
157137

158-
// Compare lines with trimmed whitespace
159138
isMatch := true
160139
for j, oldLine := range oldLines {
161140
if strings.TrimSpace(oldLine) != strings.TrimSpace(potentialMatch[j]) {
@@ -165,22 +144,17 @@ func tryFlexibleMatch(content, oldText, newText string) (bool, string) {
165144
}
166145

167146
if isMatch {
168-
// Preserve original indentation from the file
169147
originalIndent := getLeadingWhitespace(contentLines[i])
170148
newLines := strings.Split(newText, "\n")
171149

172-
// Apply indentation to replacement lines
173150
for j := range newLines {
174151
if j == 0 {
175-
// First line: use original file's indentation + trimmed new content
176152
newLines[j] = originalIndent + strings.TrimLeft(newLines[j], " \t")
177153
} else {
178-
// Subsequent lines: preserve relative indentation
179154
newLines[j] = adjustRelativeIndent(oldLines, newLines[j], j, originalIndent)
180155
}
181156
}
182157

183-
// Replace in content
184158
result := make([]string, 0, len(contentLines)-len(oldLines)+len(newLines))
185159
result = append(result, contentLines[:i]...)
186160
result = append(result, newLines...)
@@ -193,22 +167,16 @@ func tryFlexibleMatch(content, oldText, newText string) (bool, string) {
193167
return false, ""
194168
}
195169

196-
// adjustRelativeIndent calculates and applies relative indentation for a replacement line.
197-
// It compares the indentation of the new line to the old line at the same position,
198-
// then applies the base indentation plus any relative change.
170+
// adjustRelativeIndent applies baseIndent plus the indentation delta between old and new lines.
199171
func adjustRelativeIndent(oldLines []string, newLine string, lineIndex int, baseIndent string) string {
200-
// If we don't have a corresponding old line, return the line as-is
201172
if lineIndex >= len(oldLines) {
202173
return newLine
203174
}
204175

205176
oldIndent := getLeadingWhitespace(oldLines[lineIndex])
206177
newIndent := getLeadingWhitespace(newLine)
207178

208-
// Calculate the relative indentation change
209179
relativeIndent := len(newIndent) - len(oldIndent)
210-
211-
// Apply base indentation + relative change
212180
trimmedContent := strings.TrimLeft(newLine, " \t")
213181
switch {
214182
case relativeIndent > 0:
@@ -225,7 +193,6 @@ func adjustRelativeIndent(oldLines []string, newLine string, lineIndex int, base
225193
}
226194
}
227195

228-
// getLeadingWhitespace extracts leading spaces and tabs from a string
229196
func getLeadingWhitespace(s string) string {
230197
for i, c := range s {
231198
if c != ' ' && c != '\t' {
@@ -235,7 +202,6 @@ func getLeadingWhitespace(s string) string {
235202
return s // entire string is whitespace
236203
}
237204

238-
// createUnifiedDiff generates a git-style unified diff between original and modified content
239205
func createUnifiedDiff(original, modified, filepath string) string {
240206
diff := difflib.UnifiedDiff{
241207
A: difflib.SplitLines(original),
@@ -248,8 +214,7 @@ func createUnifiedDiff(original, modified, filepath string) string {
248214
return text
249215
}
250216

251-
// formatDiffOutput wraps the diff in a markdown code fence with 'diff' syntax highlighting.
252-
// It handles cases where the diff itself contains backticks by using more backticks.
217+
// formatDiffOutput wraps diff in a markdown code fence, escaping backticks if needed.
253218
func formatDiffOutput(diff string) string {
254219
numBackticks := 3
255220
for strings.Contains(diff, strings.Repeat("`", numBackticks)) {
@@ -259,29 +224,10 @@ func formatDiffOutput(diff string) string {
259224
return fmt.Sprintf("%sdiff\n%s%s\n\n", fence, diff, fence)
260225
}
261226

262-
// atomicWriteFileWithEncoding writes content to a file atomically with encoding conversion.
263-
// Content is expected to be UTF-8 (with LF line endings) and will be encoded to the specified encoding.
264-
// Line endings are restored to the original style before writing.
265-
// File permissions are preserved from the original file.
266-
func atomicWriteFileWithEncoding(filepath, content, encodingName, lineEndingStyle string, mode os.FileMode) (err error) {
267-
// Generate random temp filename
268-
randBytes := make([]byte, 16)
269-
if _, err := rand.Read(randBytes); err != nil {
270-
return fmt.Errorf("failed to generate temp filename: %w", err)
271-
}
272-
tempPath := fmt.Sprintf("%s.%s.tmp", filepath, hex.EncodeToString(randBytes))
273-
274-
// Ensure cleanup on any error or panic
275-
defer func() {
276-
if err != nil {
277-
os.Remove(tempPath)
278-
}
279-
}()
280-
281-
// Restore original line endings before encoding
227+
// atomicWriteFileWithEncoding encodes UTF-8 content to the target encoding and writes atomically.
228+
func atomicWriteFileWithEncoding(path, content, encodingName, lineEndingStyle string, mode os.FileMode) error {
282229
content = ConvertLineEndings(content, lineEndingStyle)
283230

284-
// Encode content if not UTF-8
285231
var dataToWrite []byte
286232
if encoding.IsUTF8(encodingName) {
287233
dataToWrite = []byte(content)
@@ -299,29 +245,15 @@ func atomicWriteFileWithEncoding(filepath, content, encodingName, lineEndingStyl
299245
slog.Debug("edit_file: encoded content for write", "encoding", encodingName, "utf8Size", len(content), "encodedSize", len(encoded))
300246
}
301247

302-
// Write to temp file with original permissions
303-
if err = os.WriteFile(tempPath, dataToWrite, mode); err != nil {
304-
return err
305-
}
306-
307-
// Atomic rename
308-
if err = os.Rename(tempPath, filepath); err != nil {
309-
return err
310-
}
311-
312-
return nil
248+
return atomicWriteFile(path, dataToWrite, mode)
313249
}
314250

315-
// isReadOnly checks if the file mode indicates the file is read-only.
316-
// On Unix, checks if the owner write bit is not set.
317-
// On Windows, this also works as Go maps the read-only attribute to mode bits.
318251
func isReadOnly(mode os.FileMode) bool {
319-
return mode&0200 == 0 // owner write bit not set
252+
return mode&0200 == 0
320253
}
321254

322-
// clearReadOnly removes the read-only flag from a file.
323-
// Adds owner write permission (0200) to the existing mode.
255+
// clearReadOnly adds owner write permission to the file.
324256
func clearReadOnly(path string, currentMode os.FileMode) error {
325-
newMode := currentMode | 0200 // add owner write permission
257+
newMode := currentMode | 0200
326258
return os.Chmod(path, newMode)
327259
}

0 commit comments

Comments
 (0)