diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Refactoring/RenameEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Refactoring/RenameEndpoint.cs index 737452b506d..96e354ad6fc 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Refactoring/RenameEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Refactoring/RenameEndpoint.cs @@ -48,15 +48,16 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V protected override string CustomMessageTarget => CustomMessageNames.RazorRenameEndpointName; - protected override Task TryHandleAsync(RenameParams request, RazorRequestContext requestContext, DocumentPositionInfo positionInfo, CancellationToken cancellationToken) + protected override async Task TryHandleAsync(RenameParams request, RazorRequestContext requestContext, DocumentPositionInfo positionInfo, CancellationToken cancellationToken) { var documentContext = requestContext.DocumentContext; if (documentContext is null) { - return SpecializedTasks.Null(); + return null; } - return _renameService.TryGetRazorRenameEditsAsync(documentContext, positionInfo, request.NewName, _projectManager.GetQueryOperations(), cancellationToken); + var result = await _renameService.TryGetRazorRenameEditsAsync(documentContext, positionInfo, request.NewName, _projectManager.GetQueryOperations(), cancellationToken).ConfigureAwait(false); + return result.Edit; } protected override bool IsSupported() diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Rename/IRenameService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Rename/IRenameService.cs index d11ff780f4c..8b89b958f2e 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Rename/IRenameService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Rename/IRenameService.cs @@ -10,10 +10,12 @@ namespace Microsoft.CodeAnalysis.Razor.Rename; internal interface IRenameService { - Task TryGetRazorRenameEditsAsync( + Task TryGetRazorRenameEditsAsync( DocumentContext documentContext, DocumentPositionInfo positionInfo, string newName, ISolutionQueryOperations solutionQueryOperations, CancellationToken cancellationToken); } + +internal readonly record struct RenameResult(WorkspaceEdit? Edit, bool FallbackToCSharp = true); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Rename/RenameService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Rename/RenameService.cs index 210af93d9f0..95d6b3d0db8 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Rename/RenameService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Rename/RenameService.cs @@ -24,12 +24,14 @@ namespace Microsoft.CodeAnalysis.Razor.Rename; internal class RenameService( IRazorComponentSearchEngine componentSearchEngine, + IFileSystem fileSystem, LanguageServerFeatureOptions languageServerFeatureOptions) : IRenameService { private readonly IRazorComponentSearchEngine _componentSearchEngine = componentSearchEngine; + private readonly IFileSystem _fileSystem = fileSystem; private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions; - public async Task TryGetRazorRenameEditsAsync( + public async Task TryGetRazorRenameEditsAsync( DocumentContext documentContext, DocumentPositionInfo positionInfo, string newName, @@ -39,7 +41,7 @@ internal class RenameService( // We only support renaming of .razor components, not .cshtml tag helpers if (!documentContext.FileKind.IsComponent()) { - return null; + return new(Edit: null); } var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); @@ -47,7 +49,7 @@ internal class RenameService( var originTagHelpers = await GetOriginTagHelpersAsync(documentContext, positionInfo.HostDocumentIndex, cancellationToken).ConfigureAwait(false); if (originTagHelpers.IsDefaultOrEmpty) { - return null; + return new(Edit: null); } var originComponentDocumentSnapshot = await _componentSearchEngine @@ -55,20 +57,23 @@ internal class RenameService( .ConfigureAwait(false); if (originComponentDocumentSnapshot is null) { - return null; + return new(Edit: null); } var originComponentDocumentFilePath = originComponentDocumentSnapshot.FilePath; var newPath = MakeNewPath(originComponentDocumentFilePath, newName); - if (File.Exists(newPath)) + if (_fileSystem.FileExists(newPath)) { - return null; + // We found a tag, but the new name would cause a conflict, so we can't proceed with the rename, + // even if C# might have worked. + return new(Edit: null, FallbackToCSharp: false); } using var _ = ListPool>.GetPooledObject(out var documentChanges); - var fileRename = GetFileRenameForComponent(originComponentDocumentSnapshot, newPath); + var fileRename = GetRenameFileEdit(originComponentDocumentFilePath, newPath); documentChanges.Add(fileRename); AddEditsForCodeDocument(documentChanges, originTagHelpers, newName, new(documentContext.Uri), codeDocument); + AddAdditionalFileRenames(documentChanges, originComponentDocumentFilePath, newPath); var documentSnapshots = GetAllDocumentSnapshots(documentContext.FilePath, solutionQueryOperations); @@ -86,10 +91,10 @@ internal class RenameService( } } - return new WorkspaceEdit + return new(new WorkspaceEdit { DocumentChanges = documentChanges.ToArray(), - }; + }); } private static ImmutableArray GetAllDocumentSnapshots(string filePath, ISolutionQueryOperations solutionQueryOperations) @@ -126,11 +131,26 @@ private static ImmutableArray GetAllDocumentSnapshots(string return documentSnapshots.ToImmutableAndClear(); } - private RenameFile GetFileRenameForComponent(IDocumentSnapshot documentSnapshot, string newPath) + private void AddAdditionalFileRenames(List> documentChanges, string oldFilePath, string newFilePath) + { + TryAdd(".cs"); + TryAdd(".css"); + + void TryAdd(string extension) + { + var changedPath = oldFilePath + extension; + if (_fileSystem.FileExists(changedPath)) + { + documentChanges.Add(GetRenameFileEdit(changedPath, newFilePath + extension)); + } + } + } + + private RenameFile GetRenameFileEdit(string oldFilePath, string newFilePath) => new RenameFile { - OldDocumentUri = new(LspFactory.CreateFilePathUri(documentSnapshot.FilePath, _languageServerFeatureOptions)), - NewDocumentUri = new(LspFactory.CreateFilePathUri(newPath, _languageServerFeatureOptions)), + OldDocumentUri = new(LspFactory.CreateFilePathUri(oldFilePath, _languageServerFeatureOptions)), + NewDocumentUri = new(LspFactory.CreateFilePathUri(newFilePath, _languageServerFeatureOptions)), }; private static string MakeNewPath(string originalPath, string newName) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/OOPRenameService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/OOPRenameService.cs index 497d828954b..ea4b1c0c133 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/OOPRenameService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/OOPRenameService.cs @@ -11,7 +11,8 @@ namespace Microsoft.CodeAnalysis.Remote.Razor.Rename; [method: ImportingConstructor] internal sealed class OOPRenameService( IRazorComponentSearchEngine componentSearchEngine, + IFileSystem fileSystem, LanguageServerFeatureOptions languageServerFeatureOptions) - : RenameService(componentSearchEngine, languageServerFeatureOptions) + : RenameService(componentSearchEngine, fileSystem, languageServerFeatureOptions) { } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/RemoteRenameService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/RemoteRenameService.cs index 468d824b511..55cf816b4ce 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/RemoteRenameService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/RemoteRenameService.cs @@ -58,9 +58,9 @@ protected override IRemoteRenameService CreateService(in ServiceArgs args) .TryGetRazorRenameEditsAsync(context, positionInfo, newName, context.GetSolutionQueryOperations(), cancellationToken) .ConfigureAwait(false); - if (razorEdit is not null) + if (razorEdit.Edit is { } edit) { - return Results(razorEdit); + return Results(edit); } if (positionInfo.LanguageKind != CodeAnalysis.Razor.Protocol.RazorLanguageKind.CSharp) @@ -68,6 +68,11 @@ protected override IRemoteRenameService CreateService(in ServiceArgs args) return CallHtml; } + if (!razorEdit.FallbackToCSharp) + { + return NoFurtherHandling; + } + var csharpEdit = await ExternalHandlers.Rename .GetRenameEditAsync(generatedDocument, positionInfo.Position.ToLinePosition(), newName, cancellationToken) .ConfigureAwait(false); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Refactoring/RenameEndpointDelegationTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Refactoring/RenameEndpointDelegationTest.cs index 66bc04b9384..d26c3acad87 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Refactoring/RenameEndpointDelegationTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Refactoring/RenameEndpointDelegationTest.cs @@ -64,7 +64,7 @@ await projectManager.UpdateAsync(updater => var searchEngine = new RazorComponentSearchEngine(LoggerFactory); - var renameService = new RenameService(searchEngine, LanguageServerFeatureOptions); + var renameService = new RenameService(searchEngine, new FileSystem(), LanguageServerFeatureOptions); var endpoint = new RenameEndpoint( renameService, diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Refactoring/RenameEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Refactoring/RenameEndpointTest.cs index 6c911d4ab00..5145ce72968 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Refactoring/RenameEndpointTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Refactoring/RenameEndpointTest.cs @@ -710,7 +710,7 @@ await projectManager.UpdateAsync(updater => clientConnection ??= StrictMock.Of(); - var renameService = new RenameService(searchEngine, options); + var renameService = new RenameService(searchEngine, new FileSystem(), options); var endpoint = new RenameEndpoint( renameService, options, diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/TestFileSystem.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/TestFileSystem.cs new file mode 100644 index 00000000000..03837ca83a7 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/TestFileSystem.cs @@ -0,0 +1,32 @@ +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor.Utilities; +using Microsoft.CodeAnalysis.Razor.Workspaces; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +internal sealed class TestFileSystem((string filePath, string contents)[]? files) : IFileSystem +{ + public bool FileExists(string filePath) + => files?.Any(f => FilePathNormalizingComparer.Instance.Equals(f.filePath, filePath)) ?? false; + + public string ReadFile(string filePath) + => files.AssumeNotNull().Single(f => FilePathNormalizingComparer.Instance.Equals(f.filePath, filePath)).contents; + + public Stream OpenReadStream(string filePath) + => new MemoryStream(Encoding.UTF8.GetBytes(ReadFile(filePath))); + + public IEnumerable GetDirectories(string workspaceDirectory) + => throw new NotImplementedException(); + + public IEnumerable GetFiles(string workspaceDirectory, string searchPattern, SearchOption searchOption) + => throw new NotImplementedException(); +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/AssertExtensions.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/AssertExtensions.cs index e03a5a47bbd..54aafd5fd23 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/AssertExtensions.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/AssertExtensions.cs @@ -1,6 +1,16 @@ // 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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor; +using Roslyn.Test.Utilities; using Roslyn.Text.Adornments; using Xunit; @@ -18,4 +28,83 @@ internal static void AssertExpectedClassification( Assert.Equal(expectedClassificationType, run.ClassificationTypeName); Assert.Equal(expectedClassificationStyle, run.Style); } + + public static async Task AssertWorkspaceEditAsync(this WorkspaceEdit workspaceEdit, Solution solution, IEnumerable<(Uri fileUri, string contents)> expectedChanges, CancellationToken cancellationToken) + { + var changes = Assert.NotNull(workspaceEdit.DocumentChanges); + + foreach (var change in Flatten(changes)) + { + if (change.TryGetFirst(out var textDocumentEdit)) + { + var uri = textDocumentEdit.TextDocument.DocumentUri.GetRequiredParsedUri(); + var documentId = solution.GetDocumentIdsWithFilePath(RazorUri.GetDocumentFilePathFromUri(uri)).Single(); + var document = solution.GetDocument(documentId) ?? solution.GetAdditionalDocument(documentId); + Assert.NotNull(document); + var text = await document.GetTextAsync(cancellationToken); + + text = text.WithChanges(textDocumentEdit.Edits.Select(e => text.GetTextChange((TextEdit)e))); + + solution = document is Document + ? solution.WithDocumentText(document.Id, text) + : solution.WithAdditionalDocumentText(document.Id, text); + } + else if (change.TryGetSecond(out var createFile)) + { + var uri = createFile.DocumentUri.GetRequiredParsedUri(); + var documentId = DocumentId.CreateNewId(solution.ProjectIds.Single()); + var filePath = createFile.DocumentUri.GetRequiredParsedUri().GetDocumentFilePath(); + var documentInfo = DocumentInfo.Create(documentId, Path.GetFileName(filePath), filePath: filePath); + solution = solution.AddDocument(documentInfo); + } + else if (change.TryGetThird(out var renameFile)) + { + var (oldUri, newUri) = (renameFile.OldDocumentUri.GetRequiredParsedUri(), renameFile.NewDocumentUri.GetRequiredParsedUri()); + var documentId = solution.GetDocumentIdsWithFilePath(RazorUri.GetDocumentFilePathFromUri(oldUri)).Single(); + var document = solution.GetDocument(documentId) ?? solution.GetAdditionalDocument(documentId); + Assert.NotNull(document); + if (document is Document) + { + solution = solution.WithDocumentFilePath(document.Id, newUri.GetDocumentFilePath()); + } + else + { + var filePath = newUri.GetDocumentFilePath(); + var text = await document.GetTextAsync(cancellationToken); + solution = document.Project + .RemoveAdditionalDocument(document.Id) + .AddAdditionalDocument(Path.GetFileName(filePath), text, filePath: filePath).Project.Solution; + } + } + else + { + Assert.Fail($"Don't know how to process a {change.Value?.GetType().Name}."); + } + } + + foreach (var (uri, contents) in expectedChanges) + { + var document = solution.GetTextDocuments(uri).First(); + var text = await document.GetTextAsync(cancellationToken); + AssertEx.EqualOrDiff(contents, text.ToString()); + } + + static IEnumerable> Flatten(SumType[]> documentChanges) + { + if (documentChanges.TryGetFirst(out var textDocumentEdits)) + { + foreach (var edit in textDocumentEdits) + { + yield return edit; + } + } + else if (documentChanges.TryGetSecond(out var changes)) + { + foreach (var change in changes) + { + yield return change; + } + } + } + } } diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs index 82af8af4f46..643d48b74ed 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs @@ -15,7 +15,6 @@ using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.ExternalAccess.Razor; -using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.CodeActions.Models; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; @@ -52,11 +51,14 @@ private protected async Task VerifyCodeActionAsync( return; } + Assert.NotNull(expected); + var workspaceEdit = codeAction.Data is null ? codeAction.Edit.AssumeNotNull() : await ResolveCodeActionAsync(document, codeAction); - await VerifyCodeActionResultAsync(document, workspaceEdit, expected, additionalExpectedFiles); + var expectedChanges = (additionalExpectedFiles ?? []).Concat([(document.CreateUri(), expected)]); + await workspaceEdit.AssertWorkspaceEditAsync(document.Project.Solution, expectedChanges, DisposalToken); } private protected TextDocument CreateRazorDocument(TestCode input, RazorFileKind? fileKind = null, string? documentFilePath = null, (string filePath, string contents)[]? additionalFiles = null, bool addDefaultImports = true) @@ -164,62 +166,6 @@ Could not find code action with name '{codeActionName}'. return await endpoint.GetTestAccessor().HandleRequestAsync(document, request, DisposalToken); } - private async Task VerifyCodeActionResultAsync(TextDocument document, WorkspaceEdit workspaceEdit, string? expected, (Uri fileUri, string contents)[]? additionalExpectedFiles = null) - { - var solution = document.Project.Solution; - var validated = false; - - if (workspaceEdit.DocumentChanges?.Value is SumType[] sumTypeArray) - { - using var builder = new PooledArrayBuilder(); - foreach (var sumType in sumTypeArray) - { - if (sumType.Value is CreateFile createFile) - { - validated = true; - Assert.Single(additionalExpectedFiles.AssumeNotNull(), f => f.fileUri == createFile.DocumentUri.GetRequiredParsedUri()); - var documentId = DocumentId.CreateNewId(document.Project.Id); - var filePath = createFile.DocumentUri.GetRequiredParsedUri().GetDocumentFilePath(); - var documentInfo = DocumentInfo.Create(documentId, filePath, filePath: filePath); - solution = solution.AddDocument(documentInfo); - } - } - } - - if (workspaceEdit.TryGetTextDocumentEdits(out var documentEdits)) - { - foreach (var edit in documentEdits) - { - var textDocument = solution.GetTextDocuments(edit.TextDocument.DocumentUri.GetRequiredParsedUri()).First(); - var text = await textDocument.GetTextAsync(DisposalToken).ConfigureAwait(false); - if (textDocument is Document) - { - solution = solution.WithDocumentText(textDocument.Id, text.WithChanges(edit.Edits.Select(e => text.GetTextChange((TextEdit)e)))); - } - else - { - solution = solution.WithAdditionalDocumentText(textDocument.Id, text.WithChanges(edit.Edits.Select(e => text.GetTextChange((TextEdit)e)))); - } - } - - if (additionalExpectedFiles is not null) - { - foreach (var (uri, contents) in additionalExpectedFiles) - { - var additionalDocument = solution.GetTextDocuments(uri).First(); - var text = await additionalDocument.GetTextAsync(DisposalToken).ConfigureAwait(false); - AssertEx.EqualOrDiff(contents, text.ToString()); - } - } - - validated = true; - var actual = await solution.GetAdditionalDocument(document.Id).AssumeNotNull().GetTextAsync(DisposalToken).ConfigureAwait(false); - AssertEx.EqualOrDiff(expected, actual.ToString()); - } - - Assert.True(validated, "Test did not validate anything. Code action response type is presumably not supported."); - } - private async Task ResolveCodeActionAsync(CodeAnalysis.TextDocument document, CodeAction codeAction) { var requestInvoker = new TestHtmlRequestInvoker(); @@ -230,22 +176,4 @@ private async Task ResolveCodeActionAsync(CodeAnalysis.TextDocume Assert.NotNull(result?.Edit); return result.Edit; } - - private class TestFileSystem((string filePath, string contents)[]? files) : IFileSystem - { - public bool FileExists(string filePath) - => files?.Any(f => FilePathNormalizingComparer.Instance.Equals(f.filePath, filePath)) ?? false; - - public string ReadFile(string filePath) - => files.AssumeNotNull().Single(f => FilePathNormalizingComparer.Instance.Equals(f.filePath, filePath)).contents; - - public Stream OpenReadStream(string filePath) - => new MemoryStream(Encoding.UTF8.GetBytes(ReadFile(filePath))); - - public IEnumerable GetDirectories(string workspaceDirectory) - => throw new NotImplementedException(); - - public IEnumerable GetFiles(string workspaceDirectory, string searchPattern, SearchOption searchOption) - => throw new NotImplementedException(); - } } diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CreateComponentFromTagTests.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CreateComponentFromTagTests.cs index 14705ce8450..c2e93882ed6 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CreateComponentFromTagTests.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CreateComponentFromTagTests.cs @@ -22,7 +22,7 @@ await VerifyCodeActionAsync( expected: """
- + """, codeActionName: LanguageServerConstants.CodeActions.CreateComponentFromTag, additionalExpectedFiles: [ @@ -40,8 +40,8 @@ await VerifyCodeActionAsync( """, expected: """
- - + + """, codeActionName: LanguageServerConstants.CodeActions.CreateComponentFromTag, additionalExpectedFiles: [ diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostRenameEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostRenameEndpointTest.cs index 1e089c54cb8..d78affddf37 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostRenameEndpointTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostRenameEndpointTest.cs @@ -3,16 +3,15 @@ using System; using System.Linq; -using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.ExternalAccess.Razor; -using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Remote.Razor; using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; -using Roslyn.Test.Utilities; using Xunit; using Xunit.Abstractions; @@ -21,7 +20,7 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; public class CohostRenameEndpointTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) { [Fact] - public Task CSharp_Method() + public Task CSharp_SameFile() => VerifyRenamesAsync( input: """ This is a Razor document. @@ -55,6 +54,287 @@ public string CallThisFunction() The end. """); + [Fact] + public Task CSharp_WithOtherFile() + => VerifyRenamesAsync( + input: """ + This is a Razor document. + +

@_other.MyMethod()

+ + @code + { + private OtherClass _other; + + public string MyMethod() + { + _other.MyMet$$hod(); + return $"Hi from {nameof(OtherClass.MyMethod)}"; + } + } + + The end. + """, + additionalFiles: [ + (FilePath("OtherFile.cs"), """ + public class OtherClass + { + public void MyMethod() + { + } + } + """) + ], + newName: "CallThisFunction", + expected: """ + This is a Razor document. + +

@_other.CallThisFunction()

+ + @code + { + private OtherClass _other; + + public string MyMethod() + { + _other.CallThisFunction(); + return $"Hi from {nameof(OtherClass.CallThisFunction)}"; + } + } + + The end. + """, + additionalExpectedFiles: [ + (FileUri("OtherFile.cs"), """ + public class OtherClass + { + public void CallThisFunction() + { + } + } + """) + ]); + + [Fact] + public Task CSharp_Inherits() + => VerifyRenamesAsync( + input: """ + @inherits MyComponent$$Base + + This is a Razor document. + + The end. + """, + additionalFiles: [ + (FilePath("OtherFile.cs"), """ + using Microsoft.AspNetCore.Components; + + public class MyComponentBase : ComponentBase + { + } + """) + ], + newName: "OtherName", + expected: """ + @inherits OtherName + + This is a Razor document. + + The end. + """, + additionalExpectedFiles: [ + (FileUri("OtherFile.cs"), """ + using Microsoft.AspNetCore.Components; + + public class OtherName : ComponentBase + { + } + """) + ]); + + [Fact] + public Task CSharp_Model() + => VerifyRenamesAsync( + input: """ + @model MyMod$$el + + This is a Razor document. + + The end. + """, + additionalFiles: [ + (FilePath("OtherFile.cs"), """ + public class MyModel + { + } + """) + ], + newName: "OtherModel", + expected: """ + @model OtherModel + + This is a Razor document. + + The end. + """, + additionalExpectedFiles: [ + (FileUri("OtherFile.cs"), """ + public class OtherModel + { + } + """) + ], + fileKind: RazorFileKind.Legacy); + + [Fact] + public Task CSharp_Implements() + => VerifyRenamesAsync( + input: """ + @implements MyInter$$face + + This is a Razor document. + + The end. + """, + additionalFiles: [ + (FilePath("OtherFile.cs"), """ + public interface MyInterface + { + } + """) + ], + newName: "IMyFace", + expected: """ + @implements IMyFace + + This is a Razor document. + + The end. + """, + additionalExpectedFiles: [ + (FileUri("OtherFile.cs"), """ + public interface IMyFace + { + } + """) + ]); + + [Fact] + public Task CSharp_TypeParam() + => VerifyRenamesAsync( + input: """ + @typeparam TItem where TItem : MyInter$$face + + This is a Razor document. + + The end. + """, + additionalFiles: [ + (FilePath("OtherFile.cs"), """ + public interface MyInterface + { + } + """) + ], + newName: "IMyFace", + expected: """ + @typeparam TItem where TItem : IMyFace + + This is a Razor document. + + The end. + """, + additionalExpectedFiles: [ + (FileUri("OtherFile.cs"), """ + public interface IMyFace + { + } + """) + ]); + + [Fact] + public Task CSharp_Attribute() + => VerifyRenamesAsync( + input: """ + @attribute [HasPa$$nts] + + This is a Razor document. + + The end. + """, + additionalFiles: [ + (FilePath("OtherFile.cs"), """ + public class HasPantsAttribute : Attribute + { + } + """) + ], + newName: "HasJacketAttribute", + expected: """ + @attribute [HasJacket] + + This is a Razor document. + + The end. + """, + additionalExpectedFiles: [ + (FileUri("OtherFile.cs"), """ + public class HasJacketAttribute : Attribute + { + } + """) + ]); + + [Fact] + public Task CSharp_Attribute_FullName() + => VerifyRenamesAsync( + input: """ + @attribute [HasPa$$ntsAttribute] + + This is a Razor document. + + The end. + """, + additionalFiles: [ + (FilePath("OtherFile.cs"), """ + public class HasPantsAttribute : Attribute + { + } + """) + ], + newName: "HasJacketAttribute", + expected: """ + @attribute [HasJacketAttribute] + + This is a Razor document. + + The end. + """, + additionalExpectedFiles: [ + (FileUri("OtherFile.cs"), """ + public class HasJacketAttribute : Attribute + { + } + """) + ]); + + [Fact] + public Task Component_ExistingFile() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor"), ""), + (FilePath("DifferentName.razor"), "") + ], + newName: "DifferentName", + expected: ""); + [Theory] [InlineData("$$Component")] [InlineData("Com$$ponent")] @@ -101,7 +381,77 @@ This is a Razor document. The end. """, - renames: [("Component.razor", "DifferentName.razor")]); + additionalExpectedFiles: + [(FileUri("DifferentName.razor"), "")]); + + [Theory] + [InlineData("$$My.Foo.Component")] + [InlineData("M$$y.Foo.Component")] + [InlineData("My$$.Foo.Component")] + [InlineData("My.$$Foo.Component")] + [InlineData("My.F$$oo.Component")] + [InlineData("My.Foo$$.Component")] + [InlineData("My.Foo.$$Component")] + [InlineData("My.Foo.Com$$ponent")] + [InlineData("My.Foo.Component$$")] + public Task Component_StartTag_FullyQualified(string startTag) + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + +
+ <{startTag} /> + + + +
+ + + + +
+
+ + The end. + """, + additionalFiles: [ + (FilePath("Component.razor"), """ + @namespace My.Foo + """) + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + + + + + +
+ + + + +
+ + + + +
+
+ + The end. + """, + additionalExpectedFiles: + [(FileUri("DifferentName.razor"), """ + @namespace My.Foo + """)]); [Theory] [InlineData("$$Component")] @@ -149,7 +499,8 @@ This is a Razor document. The end. """, - renames: [("Component.razor", "DifferentName.razor")]); + additionalExpectedFiles: + [(FileUri("DifferentName.razor"), "")]); [Fact] public Task Component_Attribute() @@ -231,13 +582,458 @@ The end. expected: "", fileKind: RazorFileKind.Legacy); + [Fact] + public Task Component_WithContent() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + Hello + + Hello + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor"), "") + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + Hello + + Hello + + + The end. + """, + additionalExpectedFiles: + [(FileUri("DifferentName.razor"), "")]); + + [Fact] + public Task Component_WithContent_FullyQualified() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + Hello + + Hello + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor"), """ + @namespace My.Namespace + """) + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + Hello + + Hello + + + The end. + """, + additionalExpectedFiles: + [(FileUri("DifferentName.razor"), """ + @namespace My.Namespace + """)]); + + [Fact] + public Task Component_WithOtherFile() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor"), ""), + (FilePath("OtherComponent.razor"), """ + + + + + """) + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + additionalExpectedFiles: [ + (FileUri("DifferentName.razor"), ""), + (FileUri("OtherComponent.razor"), """ + + + + + """) + ]); + + [Fact] + public Task Component_FullyQualified() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor"), """ + @namespace My.Namespace + """) + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + additionalExpectedFiles: + [(FileUri("DifferentName.razor"), """ + @namespace My.Namespace + """)]); + + [Fact] + public Task Component_WithOtherFile_FullyQualified() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor"), """ + @namespace My.Namespace + """), + (FilePath("OtherComponent.razor"), """ + + + + + """) + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + additionalExpectedFiles: [ + (FileUri("DifferentName.razor"), """ + @namespace My.Namespace + """), + (FileUri("OtherComponent.razor"), """ + + + + + """) + ]); + + [Fact] + public Task Component_OwnFile() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + newName: "ABetterName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + newFileUri: FileUri("ABetterName.razor")); + + [Fact] + public Task Component_WithOtherFile_OwnFile() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor"), """ + + + + + """), + (FilePath("OtherComponent.razor"), """ + + + + + """) + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + additionalExpectedFiles: [ + (FileUri("DifferentName.razor"), """ + + + + + """), + (FileUri("OtherComponent.razor"), """ + + + + + """) + ]); + + [Fact] + public Task Component_OwnFile_WithCss() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + additionalFiles: [ + (FilePath("File1.razor.css"), "") + ], + newName: "ABetterName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + newFileUri: FileUri("ABetterName.razor"), + additionalExpectedFiles: [ + (FileUri("ABetterName.razor.css"), "")]); + + [Fact] + public Task Component_OwnFile_WithCodeBehind() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + additionalFiles: [ + (FilePath("File1.razor.cs"), """ + namespace SomeProject; + + // This class name should change, but we don't support that yet + public partial class File1 + { + } + """) + ], + newName: "ABetterName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + newFileUri: FileUri("ABetterName.razor"), + additionalExpectedFiles: [ + (FileUri("ABetterName.razor.cs"), """ + namespace SomeProject; + + // This class name should change, but we don't support that yet + public partial class File1 + { + } + """)]); + + [Fact] + public Task Component_WithOtherFile_WithCss() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor.css"), ""), + (FilePath("Component.razor"), """ + + + + + """) + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + additionalExpectedFiles: [ + (FileUri("DifferentName.razor.css"), ""), + (FileUri("DifferentName.razor"), """ + + + + + """), + ]); + + [Fact] + public Task Component_WithOtherFile_WithCodeBehindAndCss() + => VerifyRenamesAsync( + input: $""" + This is a Razor document. + + + + + + + The end. + """, + additionalFiles: [ + (FilePath("Component.razor.css"), ""), + (FilePath("Component.razor.cs"), """ + namespace SomeProject; + + // This class name should change, but we don't support that yet + public partial class Component + { + } + """), + (FilePath("Component.razor"), """ + + + + + """) + ], + newName: "DifferentName", + expected: """ + This is a Razor document. + + + + + + + The end. + """, + additionalExpectedFiles: [ + (FileUri("DifferentName.razor.css"), ""), + (FileUri("DifferentName.razor.cs"), """ + namespace SomeProject; + + // This class name should change, but we don't support that yet + public partial class Component + { + } + """), + (FileUri("DifferentName.razor"), """ + + + + + """), + ]); + private async Task VerifyRenamesAsync( string input, string newName, string expected, RazorFileKind? fileKind = null, + Uri? newFileUri = null, (string fileName, string contents)[]? additionalFiles = null, - (string oldName, string newName)[]? renames = null, (Uri fileUri, string contents)[]? additionalExpectedFiles = null) { TestFileMarkupParser.GetPosition(input, out var source, out var cursorPosition); @@ -247,6 +1043,9 @@ private async Task VerifyRenamesAsync( var requestInvoker = new TestHtmlRequestInvoker([(Methods.TextDocumentRenameName, (object?)null)]); + var fileSystem = (RemoteFileSystem)OOPExportProvider.GetExportedValue(); + fileSystem.GetTestAccessor().SetFileSystem(new TestFileSystem(additionalFiles)); + var endpoint = new CohostRenameEndpoint(IncompatibleProjectService, RemoteServiceInvoker, requestInvoker); var renameParams = new RenameParams @@ -260,64 +1059,14 @@ private async Task VerifyRenamesAsync( if (expected.Length == 0) { - Assert.True(renames is null or []); Assert.Null(result); return; } Assert.NotNull(result); - if (result.DocumentChanges.AssumeNotNull().TryGetSecond(out var changes)) - { - Assert.NotNull(renames); - - foreach (var change in changes) - { - if (change.TryGetThird(out var renameEdit)) - { - Assert.Contains(renames, - r => renameEdit.OldDocumentUri.GetRequiredParsedUri().GetDocumentFilePath().EndsWith(r.oldName) && - renameEdit.NewDocumentUri.GetRequiredParsedUri().GetDocumentFilePath().EndsWith(r.newName)); - } - } - } - - await ProcessRazorDocumentEditsAsync(inputText, expected, document, additionalExpectedFiles, result, DisposalToken).ConfigureAwait(false); - - } - - private static async Task ProcessRazorDocumentEditsAsync(SourceText inputText, string expected, TextDocument razorDocument, (Uri fileUri, string contents)[]? additionalExpectedFiles, WorkspaceEdit result, CancellationToken cancellationToken) - { - var razorDocumentUri = razorDocument.CreateUri(); - var solution = razorDocument.Project.Solution; - - Assert.True(result.TryGetTextDocumentEdits(out var textDocumentEdits)); - foreach (var textDocumentEdit in textDocumentEdits) - { - if (textDocumentEdit.TextDocument.DocumentUri.GetRequiredParsedUri() == razorDocumentUri) - { - foreach (var edit in textDocumentEdit.Edits) - { - inputText = inputText.WithChanges(inputText.GetTextChange((TextEdit)edit)); - } - } - else if (additionalExpectedFiles is not null) - { - foreach (var (uri, contents) in additionalExpectedFiles) - { - var additionalDocument = solution.GetTextDocuments(uri).First(); - var text = await additionalDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); - - foreach (var edit in textDocumentEdit.Edits) - { - text = text.WithChanges(text.GetTextChange((TextEdit)edit)); - } - - AssertEx.EqualOrDiff(contents, text.ToString()); - } - } - } - - AssertEx.EqualOrDiff(expected, inputText.ToString()); + var documentUri = newFileUri ?? document.CreateUri(); + var expectedChanges = (additionalExpectedFiles ?? []).Concat([(documentUri, expected)]); + await result.AssertWorkspaceEditAsync(document.Project.Solution, expectedChanges, DisposalToken); } }