Skip to content

Commit 804bd34

Browse files
committed
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
1 parent b188b57 commit 804bd34

2 files changed

Lines changed: 92 additions & 7 deletions

File tree

src/CodingWithCalvin.MCPServer.Server/Tools/DocumentTools.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ public async Task<string> ReadDocumentAsync(
110110
var result = new System.Text.StringBuilder();
111111
for (int i = 0; i < selectedLines.Length; i++)
112112
{
113-
result.AppendLine($"{startIndex + i + 1}\t{selectedLines[i].TrimEnd('\r')}");
113+
result.Append($"{startIndex + i + 1}\t{selectedLines[i].TrimEnd('\r')}\n");
114114
}
115115

116116
var header = $"Lines {startIndex + 1}-{startIndex + count} of {totalLines}";

src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
using EnvDTE;
1212
using EnvDTE80;
1313
using Microsoft.VisualStudio;
14+
using Microsoft.VisualStudio.ComponentModelHost;
1415
using Microsoft.VisualStudio.Editor;
15-
using Microsoft.VisualStudio.Package;
1616
using Microsoft.VisualStudio.Shell;
1717
using Microsoft.VisualStudio.Shell.Interop;
1818
using Microsoft.VisualStudio.Shell.TableManager;
1919
using Microsoft.VisualStudio.Shell.TableControl;
20-
using Microsoft.VisualStudio.Text.Editor;
20+
using Microsoft.VisualStudio.TextManager.Interop;
2121

2222
namespace CodingWithCalvin.MCPServer.Services;
2323

@@ -43,6 +43,73 @@ private static string NormalizePath(string path)
4343
return Path.GetFullPath(path.Replace('/', '\\'));
4444
}
4545

46+
private static string DetectLineEnding(string content)
47+
{
48+
if (content.Contains("\r\n")) return "\r\n";
49+
if (content.Contains("\r")) return "\r";
50+
if (content.Contains("\n")) return "\n";
51+
return Environment.NewLine;
52+
}
53+
54+
private static string NormalizeToLineEnding(string content, string lineEnding)
55+
{
56+
return content.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", lineEnding);
57+
}
58+
59+
private string? TryGetVsDocumentLineEnding(string documentPath)
60+
{
61+
ThreadHelper.ThrowIfNotOnUIThread();
62+
63+
try
64+
{
65+
var componentModel = ServiceProvider.GetService(typeof(SComponentModel)) as IComponentModel;
66+
if (componentModel == null) return null;
67+
68+
var editorAdapters = componentModel.GetService<IVsEditorAdaptersFactoryService>();
69+
if (editorAdapters == null) return null;
70+
71+
var rdt = ServiceProvider.GetService(typeof(SVsRunningDocumentTable)) as IVsRunningDocumentTable;
72+
if (rdt == null) return null;
73+
74+
rdt.FindAndLockDocument(
75+
(uint)_VSRDTFLAGS.RDT_NoLock,
76+
NormalizePath(documentPath),
77+
out _,
78+
out _,
79+
out IntPtr punkDocData,
80+
out _);
81+
82+
if (punkDocData == IntPtr.Zero) return null;
83+
84+
try
85+
{
86+
var vsTextBuffer = Marshal.GetObjectForIUnknown(punkDocData) as IVsTextBuffer;
87+
if (vsTextBuffer == null) return null;
88+
89+
var textBuffer = editorAdapters.GetDataBuffer(vsTextBuffer);
90+
if (textBuffer == null) return null;
91+
92+
var snapshot = textBuffer.CurrentSnapshot;
93+
if (snapshot.LineCount > 0)
94+
{
95+
var lineBreak = snapshot.GetLineFromLineNumber(0).GetLineBreakText();
96+
if (!string.IsNullOrEmpty(lineBreak))
97+
return lineBreak;
98+
}
99+
}
100+
finally
101+
{
102+
Marshal.Release(punkDocData);
103+
}
104+
}
105+
catch
106+
{
107+
// ignored — fall back to content scan
108+
}
109+
110+
return null;
111+
}
112+
46113
private static bool PathsEqual(string path1, string path2)
47114
{
48115
return NormalizePath(path1).Equals(NormalizePath(path2), StringComparison.OrdinalIgnoreCase);
@@ -333,8 +400,12 @@ public async Task<bool> WriteDocumentAsync(string path, string content)
333400
if (textDoc != null)
334401
{
335402
var editPoint = textDoc.StartPoint.CreateEditPoint();
403+
var existingContent = editPoint.GetText(textDoc.EndPoint);
404+
var lineEnding = TryGetVsDocumentLineEnding(doc.FullName) ?? DetectLineEnding(existingContent);
405+
var normalizedContent = NormalizeToLineEnding(content, lineEnding);
406+
editPoint = textDoc.StartPoint.CreateEditPoint();
336407
editPoint.Delete(textDoc.EndPoint);
337-
editPoint.Insert(content);
408+
editPoint.Insert(normalizedContent);
338409
return true;
339410
}
340411
}
@@ -423,7 +494,15 @@ public async Task<bool> InsertTextAsync(string text)
423494
return false;
424495
}
425496

426-
textDoc.Selection.Insert(text);
497+
var lineEnding = TryGetVsDocumentLineEnding(doc.FullName);
498+
if (lineEnding == null)
499+
{
500+
var samplePoint = textDoc.StartPoint.CreateEditPoint();
501+
var sample = samplePoint.GetLines(1, Math.Min(textDoc.EndPoint.Line + 1, 3));
502+
lineEnding = DetectLineEnding(sample);
503+
}
504+
505+
textDoc.Selection.Insert(NormalizeToLineEnding(text, lineEnding));
427506
return true;
428507
}
429508

@@ -444,11 +523,17 @@ public async Task<int> ReplaceTextAsync(string oldText, string newText)
444523
return 0;
445524
}
446525

526+
var contentPoint = textDoc.StartPoint.CreateEditPoint();
527+
var existingContent = contentPoint.GetText(textDoc.EndPoint);
528+
var lineEnding = TryGetVsDocumentLineEnding(doc.FullName) ?? DetectLineEnding(existingContent);
529+
var normalizedOldText = NormalizeToLineEnding(oldText, lineEnding);
530+
var normalizedNewText = NormalizeToLineEnding(newText, lineEnding);
531+
447532
var count = 0;
448533
var searchPoint = textDoc.StartPoint.CreateEditPoint();
449534
EditPoint? matchEnd = null;
450535

451-
while (searchPoint.FindPattern(oldText, (int)vsFindOptions.vsFindOptionsMatchCase, ref matchEnd))
536+
while (searchPoint.FindPattern(normalizedOldText, (int)vsFindOptions.vsFindOptionsMatchCase, ref matchEnd))
452537
{
453538
count++;
454539
searchPoint = matchEnd;
@@ -457,7 +542,7 @@ public async Task<int> ReplaceTextAsync(string oldText, string newText)
457542
if (count > 0)
458543
{
459544
TextRanges? tags = null;
460-
textDoc.ReplacePattern(oldText, newText, (int)vsFindOptions.vsFindOptionsMatchCase, ref tags);
545+
textDoc.ReplacePattern(normalizedOldText, normalizedNewText, (int)vsFindOptions.vsFindOptionsMatchCase, ref tags);
461546
}
462547

463548
return count;

0 commit comments

Comments
 (0)