Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public async Task<string> 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}";
Expand Down
97 changes: 91 additions & 6 deletions src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<IVsEditorAdaptersFactoryService>();
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);
Expand Down Expand Up @@ -333,8 +400,12 @@ public async Task<bool> 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;
}
}
Expand Down Expand Up @@ -423,7 +494,15 @@ public async Task<bool> 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;
}

Expand All @@ -444,11 +523,17 @@ public async Task<int> 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;
Expand All @@ -457,7 +542,7 @@ public async Task<int> 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;
Expand Down