File: Cohost\CohostRoslynRenameTest.cs
Web Access
Project: src\src\Razor\src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.UnitTests\Microsoft.VisualStudio.LanguageServices.Razor.UnitTests.csproj (Microsoft.VisualStudio.LanguageServices.Razor.UnitTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Test.Common;
using Microsoft.AspNetCore.Razor.Test.Common.Mef;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.CohostingShared;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Rename;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Test.Utilities;
using Xunit;
using Xunit.Abstractions;
 
namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;
 
using Rename = Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers.Rename;
 
public class CohostRoslynRenameTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper)
{
    [Theory]
    [CombinatorialData]
    public Task CSharp_Method(bool useLsp, bool fromRazor)
        => VerifyRenamesAsync(
            csharpFile: """
                public class MyClass
                {
                    public string MyMe$$thod()
                    {
                        return $"Hi from {nameof(MyMethod)}";
                    }
                }
                """,
            razorFile: """
                This is a Razor document.
 
                <h1>@_myClass.MyM$$ethod()</h1>
 
                @code
                {
                    private MyClass _myClass = new MyClass();
 
                    public string M()
                    {
                        return _myClass.MyMethod();
                    }
                }
 
                The end.
                """,
            newName: "CallThisFunction",
            expectedCSharpFile: """
                public class MyClass
                {
                    public string CallThisFunction()
                    {
                        return $"Hi from {nameof(CallThisFunction)}";
                    }
                }
                """,
            expectedRazorFile: """
                This is a Razor document.
                
                <h1>@_myClass.CallThisFunction()</h1>
                
                @code
                {
                    private MyClass _myClass = new MyClass();
                
                    public string M()
                    {
                        return _myClass.CallThisFunction();
                    }
                }
                
                The end.
                """,
            useLsp,
            fromRazor);
 
    [Theory]
    [CombinatorialData]
    public Task Component(bool useLsp, bool fromRazor)
        => VerifyRenamesAsync(
            csharpFile: """
                using Microsoft.AspNetCore.Components;
 
                public class MyComp$$onent : ComponentBase
                {
                }
                """,
            razorFile: """
                This is a Razor document.
 
                <MyComponent></MyComponent>
 
                @code
                {
                    private string componentnName = nameof(MyCompo$$nent);
                }
 
                The end.
                """,
            newName: "YetAnotherComponent",
            expectedCSharpFile: """
                using Microsoft.AspNetCore.Components;
                
                public class YetAnotherComponent : ComponentBase
                {
                }
                """,
            expectedRazorFile: """
                This is a Razor document.
                
                <YetAnotherComponent></YetAnotherComponent>
                
                @code
                {
                    private string componentnName = nameof(YetAnotherComponent);
                }
                
                The end.
                """,
            useLsp,
            fromRazor);
 
    [Theory]
    [CombinatorialData]
    public Task CSharp_Local(bool useLsp)
       => VerifyRenamesAsync(
           csharpFile: """
                public class MyClass
                {
                    public string MyMethod()
                    {
                        return $"Hi from {nameof(MyMethod)}";
                    }
                }
                """,
           razorFile: """
                This is a Razor document.
 
                @{var someV$$ariable = true;} @someVariable
 
                @code
                {
                    private MyClass _myClass = new MyClass();
 
                    public string M()
                    {
                        return _myClass.MyMethod();
                    }
                }
 
                The end.
                """,
           newName: "fishPants",
           expectedCSharpFile: """
                public class MyClass
                {
                    public string MyMethod()
                    {
                        return $"Hi from {nameof(MyMethod)}";
                    }
                }
                """,
           expectedRazorFile: """
                This is a Razor document.
                
                @{var fishPants = true;} @fishPants
                
                @code
                {
                    private MyClass _myClass = new MyClass();
                
                    public string M()
                    {
                        return _myClass.MyMethod();
                    }
                }
                
                The end.
                """,
           useLsp);
 
    private protected override TestComposition ConfigureLocalComposition(TestComposition composition)
    {
        return composition
            .AddParts(typeof(RazorSourceGeneratedDocumentSpanMappingService))
            .AddParts(typeof(ExportableRemoteServiceInvoker));
    }
 
    private async Task VerifyRenamesAsync(
        TestCode csharpFile,
        TestCode razorFile,
        string newName,
        string expectedCSharpFile,
        string expectedRazorFile,
        bool useLsp,
        bool fromRazor = true)
    {
        var razorDocument = CreateProjectAndRazorDocument(razorFile.Text, additionalFiles: [(Path.Combine(TestProjectData.SomeProjectPath, "File.cs"), csharpFile.Text)]);
        var project = razorDocument.Project;
        var csharpDocument = project.Documents.First();
 
        var compilation = await project.GetCompilationAsync(DisposalToken);
 
        var generatedDocument = await project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(razorDocument, DisposalToken);
 
        var originDocument = fromRazor
            ? generatedDocument.AssumeNotNull()
            : csharpDocument;
        var originPosition = fromRazor
            ? razorFile.Position
            : csharpFile.Position;
 
        if (useLsp)
        {
            var (_, csharpPosition) = await GetCSharpPositionAsync(originDocument, originPosition, razorDocument);
            await VerifyLspRenameAsync(newName, expectedCSharpFile, expectedRazorFile, originDocument, csharpPosition, razorDocument, csharpDocument);
        }
        else
        {
            var node = await GetSyntaxNodeAsync(originDocument, originPosition, razorDocument);
            var symbol = FindSymbolToRename(compilation.AssumeNotNull(), node);
            await VerifyVSRenameAsync(newName, expectedCSharpFile, expectedRazorFile, razorDocument, project, csharpDocument, symbol);
        }
    }
 
    private ISymbol FindSymbolToRename(Compilation compilation, SyntaxNode node)
    {
        var semanticModel = compilation.GetSemanticModel(node.SyntaxTree);
        var symbol = semanticModel.GetDeclaredSymbol(node, DisposalToken);
        if (symbol is null)
        {
            symbol = semanticModel.GetSymbolInfo(node, DisposalToken).Symbol;
        }
 
        Assert.NotNull(symbol);
        return symbol;
    }
 
    private async Task<SyntaxNode> GetSyntaxNodeAsync(Document document, int position, TextDocument razorDocument)
    {
        var (sourceText, csharpPosition) = await GetCSharpPositionAsync(document, position, razorDocument);
 
        var span = sourceText.GetTextSpan(csharpPosition, csharpPosition);
        var tree = await document.AssumeNotNull().GetSyntaxTreeAsync(DisposalToken);
        var root = await tree.AssumeNotNull().GetRootAsync(DisposalToken);
 
        return root.FindNode(span, getInnermostNodeForTie: true);
    }
 
    private async Task<(SourceText sourceText, LinePosition csharpPosition)> GetCSharpPositionAsync(Document document, int position, TextDocument razorDocument)
    {
        var sourceText = await document.GetTextAsync(DisposalToken);
 
        LinePosition csharpPosition;
        if (document is SourceGeneratedDocument)
        {
            var snapshotManager = OOPExportProvider.GetExportedValue<RemoteSnapshotManager>();
            var documentMappingService = OOPExportProvider.GetExportedValue<IDocumentMappingService>();
            var snapshot = snapshotManager.GetSnapshot(razorDocument);
            var codeDocument = await snapshot.GetGeneratedOutputAsync(DisposalToken);
 
            Assert.True(documentMappingService.TryMapToCSharpDocumentPosition(codeDocument.GetCSharpDocument().AssumeNotNull(), position, out csharpPosition, out _));
        }
        else
        {
            csharpPosition = sourceText.GetLinePosition(position);
        }
 
        return (sourceText, csharpPosition);
    }
 
    private async Task VerifyVSRenameAsync(string newName, string expectedCSharpFile, string expectedRazorFile, TextDocument razorDocument, Project project, Document csharpDocument, ISymbol symbol)
    {
        var solution = await Renamer.RenameSymbolAsync(project.Solution, symbol, new SymbolRenameOptions(), newName, DisposalToken);
 
        Assert.NotSame(project.Solution, solution);
 
        // Make sure the rename worked in the C# document
        var csharpDocumentAfterRename = solution.GetDocument(csharpDocument.Id).AssumeNotNull();
        var csharpText = await csharpDocumentAfterRename.GetTextAsync(DisposalToken);
        AssertEx.EqualOrDiff(expectedCSharpFile, csharpText.ToString());
 
        // Normally in VS, TryApplyChanges would be called, and that calls into our edit mapping service.
        var generatedDoc = await project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(razorDocument, DisposalToken);
        Assert.NotNull(generatedDoc);
        var renamedGeneratedDoc = await solution.GetRequiredProject(project.Id).TryGetSourceGeneratedDocumentForRazorDocumentAsync(razorDocument, DisposalToken);
        Assert.NotNull(renamedGeneratedDoc);
 
        // It could be argued this class is really a RazorSourceGeneratedDocumentSpanMappingService test :)
        var mappingService = new RazorSourceGeneratedDocumentSpanMappingService(RemoteServiceInvoker);
        var changes = await mappingService.GetMappedTextChangesAsync(generatedDoc, renamedGeneratedDoc, DisposalToken);
 
        var razorDocumentAfterRename = solution.GetAdditionalDocument(razorDocument.Id).AssumeNotNull();
        var razorText = await razorDocumentAfterRename.GetTextAsync(DisposalToken);
 
        foreach (var change in changes)
        {
            Assert.Equal(razorDocument.FilePath, change.FilePath);
            razorText = razorText.WithChanges(change.TextChanges);
        }
 
        AssertEx.EqualOrDiff(expectedRazorFile, razorText.ToString());
    }
 
    private async Task VerifyLspRenameAsync(string newName, string expectedCSharpFile, string expectedRazorFile, Document renameDocument, LinePosition renamePosition, TextDocument razorDocument, Document csharpDocument)
    {
        // Normally in cohosting tests we directly construct and invoke the endpoints, but in this scenario Roslyn is going to do it
        // using a service in their MEF composition, so we have to jump through an extra hook to hook up our test invoker.
        var invoker = LocalExportProvider.AssumeNotNull().GetExportedValue<ExportableRemoteServiceInvoker>();
        invoker.SetInvoker(RemoteServiceInvoker);
 
        var workspaceEdit = await Rename.GetRenameEditAsync(renameDocument, renamePosition, newName, DisposalToken);
        Assert.NotNull(workspaceEdit);
 
        var csharpSourceText = await csharpDocument.GetTextAsync(DisposalToken);
        var csharpDocAfterRename = ApplyDocumentEdits(csharpSourceText, csharpDocument.CreateUri(), workspaceEdit);
        AssertEx.EqualOrDiff(expectedCSharpFile, csharpDocAfterRename);
 
        var razorSourceText = await razorDocument.GetTextAsync(DisposalToken);
        var razorDocAfterRename = ApplyDocumentEdits(razorSourceText, razorDocument.CreateUri(), workspaceEdit);
        AssertEx.EqualOrDiff(expectedRazorFile, razorDocAfterRename);
    }
 
    private static string ApplyDocumentEdits(SourceText inputText, Uri documentUri, WorkspaceEdit result)
    {
        var textDocumentEdits = result.EnumerateTextDocumentEdits().ToArray();
        Assert.NotEmpty(textDocumentEdits);
        var changes = textDocumentEdits
            .Where(e => e.TextDocument.DocumentUri.GetRequiredParsedUri() == documentUri)
            .SelectMany(e => e.Edits)
            .Select(e => inputText.GetTextChange((TextEdit)e));
        inputText = inputText.WithChanges(changes);
 
        return inputText.ToString();
    }
}