@@ -2,8 +2,6 @@ package handler
22
33import (
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.
1916func (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.
11396func 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.
146127func 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.
199171func 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
229196func 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
239205func 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.
253218func 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.
318251func 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.
324256func 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