From 804bd3459b4e577f8f01937297e562c29cc12cb1 Mon Sep 17 00:00:00 2001 From: Gh61 Date: Mon, 8 Jun 2026 16:20:51 +0200 Subject: [PATCH] fix(tools): normalize line endings on document write and replace operations Agents receive document content with LF line endings but write back with LF regardless of the document native line ending (CRLF on Windows). - detect document line ending via VS ITextBuffer snapshot (same source as the status bar CRLF/LF indicator), with content scan as fallback - normalize incoming content to the document native line ending in document_write, editor_replace, and editor_insert - document_read now consistently outputs LF so agents always see \n --- .../Tools/DocumentTools.cs | 2 +- .../Services/VisualStudioService.cs | 97 +++++++++++++++++-- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/DocumentTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/DocumentTools.cs index f1aaee2..cea3155 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Tools/DocumentTools.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/DocumentTools.cs @@ -110,7 +110,7 @@ public async Task ReadDocumentAsync( var result = new System.Text.StringBuilder(); for (int i = 0; i < selectedLines.Length; i++) { - result.AppendLine($"{startIndex + i + 1}\t{selectedLines[i].TrimEnd('\r')}"); + result.Append($"{startIndex + i + 1}\t{selectedLines[i].TrimEnd('\r')}\n"); } var header = $"Lines {startIndex + 1}-{startIndex + count} of {totalLines}"; diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index e8d94d3..95dd653 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -11,13 +11,13 @@ using EnvDTE; using EnvDTE80; using Microsoft.VisualStudio; +using Microsoft.VisualStudio.ComponentModelHost; using Microsoft.VisualStudio.Editor; -using Microsoft.VisualStudio.Package; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Shell.TableManager; using Microsoft.VisualStudio.Shell.TableControl; -using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.TextManager.Interop; namespace CodingWithCalvin.MCPServer.Services; @@ -43,6 +43,73 @@ private static string NormalizePath(string path) return Path.GetFullPath(path.Replace('/', '\\')); } + private static string DetectLineEnding(string content) + { + if (content.Contains("\r\n")) return "\r\n"; + if (content.Contains("\r")) return "\r"; + if (content.Contains("\n")) return "\n"; + return Environment.NewLine; + } + + private static string NormalizeToLineEnding(string content, string lineEnding) + { + return content.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", lineEnding); + } + + private string? TryGetVsDocumentLineEnding(string documentPath) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + var componentModel = ServiceProvider.GetService(typeof(SComponentModel)) as IComponentModel; + if (componentModel == null) return null; + + var editorAdapters = componentModel.GetService(); + if (editorAdapters == null) return null; + + var rdt = ServiceProvider.GetService(typeof(SVsRunningDocumentTable)) as IVsRunningDocumentTable; + if (rdt == null) return null; + + rdt.FindAndLockDocument( + (uint)_VSRDTFLAGS.RDT_NoLock, + NormalizePath(documentPath), + out _, + out _, + out IntPtr punkDocData, + out _); + + if (punkDocData == IntPtr.Zero) return null; + + try + { + var vsTextBuffer = Marshal.GetObjectForIUnknown(punkDocData) as IVsTextBuffer; + if (vsTextBuffer == null) return null; + + var textBuffer = editorAdapters.GetDataBuffer(vsTextBuffer); + if (textBuffer == null) return null; + + var snapshot = textBuffer.CurrentSnapshot; + if (snapshot.LineCount > 0) + { + var lineBreak = snapshot.GetLineFromLineNumber(0).GetLineBreakText(); + if (!string.IsNullOrEmpty(lineBreak)) + return lineBreak; + } + } + finally + { + Marshal.Release(punkDocData); + } + } + catch + { + // ignored — fall back to content scan + } + + return null; + } + private static bool PathsEqual(string path1, string path2) { return NormalizePath(path1).Equals(NormalizePath(path2), StringComparison.OrdinalIgnoreCase); @@ -333,8 +400,12 @@ public async Task WriteDocumentAsync(string path, string content) if (textDoc != null) { var editPoint = textDoc.StartPoint.CreateEditPoint(); + var existingContent = editPoint.GetText(textDoc.EndPoint); + var lineEnding = TryGetVsDocumentLineEnding(doc.FullName) ?? DetectLineEnding(existingContent); + var normalizedContent = NormalizeToLineEnding(content, lineEnding); + editPoint = textDoc.StartPoint.CreateEditPoint(); editPoint.Delete(textDoc.EndPoint); - editPoint.Insert(content); + editPoint.Insert(normalizedContent); return true; } } @@ -423,7 +494,15 @@ public async Task InsertTextAsync(string text) return false; } - textDoc.Selection.Insert(text); + var lineEnding = TryGetVsDocumentLineEnding(doc.FullName); + if (lineEnding == null) + { + var samplePoint = textDoc.StartPoint.CreateEditPoint(); + var sample = samplePoint.GetLines(1, Math.Min(textDoc.EndPoint.Line + 1, 3)); + lineEnding = DetectLineEnding(sample); + } + + textDoc.Selection.Insert(NormalizeToLineEnding(text, lineEnding)); return true; } @@ -444,11 +523,17 @@ public async Task ReplaceTextAsync(string oldText, string newText) return 0; } + var contentPoint = textDoc.StartPoint.CreateEditPoint(); + var existingContent = contentPoint.GetText(textDoc.EndPoint); + var lineEnding = TryGetVsDocumentLineEnding(doc.FullName) ?? DetectLineEnding(existingContent); + var normalizedOldText = NormalizeToLineEnding(oldText, lineEnding); + var normalizedNewText = NormalizeToLineEnding(newText, lineEnding); + var count = 0; var searchPoint = textDoc.StartPoint.CreateEditPoint(); EditPoint? matchEnd = null; - while (searchPoint.FindPattern(oldText, (int)vsFindOptions.vsFindOptionsMatchCase, ref matchEnd)) + while (searchPoint.FindPattern(normalizedOldText, (int)vsFindOptions.vsFindOptionsMatchCase, ref matchEnd)) { count++; searchPoint = matchEnd; @@ -457,7 +542,7 @@ public async Task ReplaceTextAsync(string oldText, string newText) if (count > 0) { TextRanges? tags = null; - textDoc.ReplacePattern(oldText, newText, (int)vsFindOptions.vsFindOptionsMatchCase, ref tags); + textDoc.ReplacePattern(normalizedOldText, normalizedNewText, (int)vsFindOptions.vsFindOptionsMatchCase, ref tags); } return count;