diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/FileSystem.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/FileSystem.cs index e3e65df1fc5..0c528ea7a1a 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/FileSystem.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/FileSystem.cs @@ -22,4 +22,7 @@ public string ReadFile(string filePath) public Stream OpenReadStream(string filePath) => new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + public void Move(string sourceFilePath, string destinationFilePath) + => File.Move(sourceFilePath, destinationFilePath); } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/IFileSystem.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/IFileSystem.cs index 890162e313f..99fbb0135e6 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/IFileSystem.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/IFileSystem.cs @@ -17,4 +17,6 @@ internal interface IFileSystem string ReadFile(string filePath); Stream OpenReadStream(string filePath); + + void Move(string sourceFilePath, string destinationFilePath); } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RemoteFileSystem.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RemoteFileSystem.cs index 19c97452771..15ef1b1e2ee 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RemoteFileSystem.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RemoteFileSystem.cs @@ -28,6 +28,9 @@ public IEnumerable GetDirectories(string workspaceDirectory) public IEnumerable GetFiles(string workspaceDirectory, string searchPattern, SearchOption searchOption) => _fileSystem.GetFiles(workspaceDirectory, searchPattern, searchOption); + public void Move(string sourceFilePath, string destinationFilePath) + => _fileSystem.Move(sourceFilePath, destinationFilePath); + internal TestAccessor GetTestAccessor() => new(this); internal readonly struct TestAccessor(RemoteFileSystem instance) diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Rename/RazorRefactorNotifyService.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Rename/RazorRefactorNotifyService.cs new file mode 100644 index 00000000000..4b0fe7aef1e --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Rename/RazorRefactorNotifyService.cs @@ -0,0 +1,162 @@ +// 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.ComponentModel.Composition; +using System.Diagnostics; +using System.IO; +using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.NET.Sdk.Razor.SourceGenerators; + +namespace Microsoft.VisualStudio.Razor.Rename; + +[Export(typeof(IRazorRefactorNotifyService))] +[method: ImportingConstructor] +internal sealed class RazorRefactorNotifyService( + ILoggerFactory loggerFactory) : IRazorRefactorNotifyService +{ + private readonly IFileSystem _fileSystem = new FileSystem(); + private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + + public bool TryOnAfterGlobalSymbolRenamed(CodeAnalysis.Workspace workspace, IEnumerable changedDocumentIDs, ISymbol symbol, string newName, bool throwOnFailure) + { + return OnAfterGlobalSymbolRenamed(symbol, newName, throwOnFailure, _fileSystem); + } + + private bool OnAfterGlobalSymbolRenamed(ISymbol symbol, string newName, bool throwOnFailure, IFileSystem fileSystem) + { + // If the user is renaming a Razor component, we need to rename the .razor file or things will break for them, + // however this method gets called for every symbol rename in Roslyn, so chances are low that it's a Razor component. + // We have a few heuristics we can use to quickly work out if we care about the symbol, and go from there. + + ClassDeclarationSyntax? classDecl = null; + foreach (var reference in symbol.OriginalDefinition.DeclaringSyntaxReferences) + { + var syntaxTree = reference.SyntaxTree; + + // First, we can check the file path of the syntax tree. Razor generated files have a very specific path format + if (syntaxTree.FilePath.IndexOf(typeof(RazorSourceGenerator).FullName) == -1 || + !syntaxTree.FilePath.EndsWith("_razor.g.cs")) + { + continue; + } + + // Now, we try to get the class declaration from the syntax tree. This method checks specifically for the + // structure that Razor generates, so it acts as an extra check that this is actually a Razor file, but + // it doesn't have anything to do with what is actually being renamed. + if (!syntaxTree.GetRoot().TryGetClassDeclaration(out var thisClassDecl)) + { + continue; + } + + // We're pretty sure by now that the rename is of a symbol in a Razor file, but it might not be the component + // itself. Let's check. + if (reference.Span != thisClassDecl.Span) + { + continue; + } + + classDecl = thisClassDecl; + break; + } + + // If we didn't find a class declaration that matches the symbol reference, its not a Razor file, or not the component + if (classDecl is null) + { + return true; + } + + // Now for the actual renaming, which is potentially the dodgiest bit. We need to figure out the original .razor file + // name, but we have no idea which document we're dealing with, nor which project, and there is no API we can use from + // Roslyn that can give us that info from the symbol. Additionally the changedDocumentIds parameter won't have it, + // because edits to generated files are filtered out before we get called, and even if it did, we wouldn't know which + // one was the right one. + // So we can do one final check, which is that all Razor generated documents begin with a pragma checksum that + // contains the Razor file name, and not only does that give us final validation, it also lets us know which file + // to rename. To do this "properly" would mean jumping over to OOP, and probably running all generators in all + // projects, and then looking at host outputs etc. In other words, it would be very slow and inefficient. This is + // quick. + + if (classDecl.Parent is null || + classDecl.Parent.GetLeadingTrivia() is not [{ } firstTrivia, ..] || + !firstTrivia.IsKind(CodeAnalysis.CSharp.SyntaxKind.PragmaChecksumDirectiveTrivia) || + firstTrivia.ToString().Split(' ') is not ["#pragma", "checksum", { } quotedRazorFileName, ..]) + { + return true; + } + + // Let's make sure the pragma actually contained a Razor file name, just in case the compiler changes + if (quotedRazorFileName.Trim('"') is not { } razorFileName || + !FileUtilities.IsRazorComponentFilePath(razorFileName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!fileSystem.FileExists(razorFileName)) + { + return true; + } + + Debug.Assert(Path.GetExtension(razorFileName) == ".razor"); + var newFileName = Path.Combine(Path.GetDirectoryName(razorFileName), newName + ".razor"); + + // If the new file name already exists, then the rename can continue, it will just be moving from ComponentA to + // ComponentB, but ComponentA remains. Hopefully this is what the user intended :) + if (fileSystem.FileExists(newFileName)) + { + return true; + } + + try + { + // Roslyn has no facility to rename an additional file, so there is no real benefit to do anything but + // rename the file on disk, and let all of the other systems handle it. + fileSystem.Move(razorFileName, newFileName); + + // Now try to rename the associated files too + if (fileSystem.FileExists($"{razorFileName}.cs")) + { + fileSystem.Move($"{razorFileName}.cs", $"{newFileName}.cs"); + } + + if (fileSystem.FileExists($"{razorFileName}.css")) + { + fileSystem.Move($"{razorFileName}.css", $"{newFileName}.css"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rename Razor component file during symbol rename."); + if (throwOnFailure) + { + throw; + } + + // If we've tried to actually rename the file, and it didn't work, then we can be okay to block the rename operation + // because otherwise the user will just get lots of broken references. Chances are its too late to block them anyway + // but here we are. + return false; + } + + return true; + } + + public bool TryOnBeforeGlobalSymbolRenamed(CodeAnalysis.Workspace workspace, IEnumerable changedDocumentIDs, ISymbol symbol, string newName, bool throwOnFailure) + { + return true; + } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(RazorRefactorNotifyService instance) + { + public bool OnAfterGlobalSymbolRenamed(ISymbol symbol, string newName, bool throwOnFailure, IFileSystem fileSystem) + => instance.OnAfterGlobalSymbolRenamed(symbol, newName, throwOnFailure, fileSystem); + } +} 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 index 03837ca83a7..e554a2224c5 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/TestFileSystem.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/TestFileSystem.cs @@ -15,6 +15,8 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; internal sealed class TestFileSystem((string filePath, string contents)[]? files) : IFileSystem { + public List<(string source, string destination)> MovedFiles { get; } = new(); + public bool FileExists(string filePath) => files?.Any(f => FilePathNormalizingComparer.Instance.Equals(f.filePath, filePath)) ?? false; @@ -29,4 +31,7 @@ public IEnumerable GetDirectories(string workspaceDirectory) public IEnumerable GetFiles(string workspaceDirectory, string searchPattern, SearchOption searchOption) => throw new NotImplementedException(); + + public void Move(string sourceFilePath, string destinationFilePath) + => MovedFiles.Add((sourceFilePath, destinationFilePath)); } diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs index cf3545a3889..b03e16ec93d 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs @@ -11,7 +11,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.ExternalAccess.Razor; -using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers; using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.CohostingShared; using Microsoft.CodeAnalysis.Razor.DocumentMapping; @@ -24,6 +23,8 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; +using Rename = Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers.Rename; + public class CohostRoslynRenameTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) { [Theory] diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/RazorRefactorNotifyServiceTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/RazorRefactorNotifyServiceTest.cs new file mode 100644 index 00000000000..78cf451a438 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/RazorRefactorNotifyServiceTest.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Rename; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.Razor.LanguageClient.Cohost; +using Microsoft.VisualStudio.Razor.Rename; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost; + +public class RazorRefactorNotifyServiceTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) +{ + [Fact] + public async Task Component() + { + var movedFiles = await GetRefactorRenamesAsync( + razorContents: """ +
+
+ """, + additionalFiles: [ + (FilePath("File.cs"), """ + using SomeProject; + + nameof(Comp$$onent).ToString(); + """)], + newName: "DifferentName"); + + var move = Assert.Single(movedFiles); + Assert.Equal(FilePath("Component.razor"), move.source); + Assert.Equal(FilePath("DifferentName.razor"), move.destination); + } + + [Fact] + public async Task NotComponent() + { + var movedFiles = await GetRefactorRenamesAsync( + razorContents: """ +
+
+ + @code { + public class NotAComponent + { + } + } + """, + additionalFiles: [ + (FilePath("File.cs"), """ + using SomeProject; + + nameof(Component.NotAComp$$onent).ToString(); + """)], + newName: "DifferentName"); + + Assert.Empty(movedFiles); + } + + [Fact] + public async Task Component_WithCodeBehind() + { + var movedFiles = await GetRefactorRenamesAsync( + razorContents: """ +
+
+ """, + additionalFiles: [ + (FilePath("File.cs"), """ + using SomeProject; + + nameof(Comp$$onent).ToString(); + """), + (FilePath("Component.razor.cs"), """ + namespace SomeProject; + + public partial class Component + { + } + """)], + newName: "DifferentName"); + + Assert.Collection(movedFiles, + m => + { + Assert.Equal(FilePath("Component.razor"), m.source); + Assert.Equal(FilePath("DifferentName.razor"), m.destination); + }, + m => + { + Assert.Equal(FilePath("Component.razor.cs"), m.source); + Assert.Equal(FilePath("DifferentName.razor.cs"), m.destination); + }); + } + + private async Task> GetRefactorRenamesAsync(string razorContents, string newName, params (string fileName, TestCode contents)[] additionalFiles) + { + var additionalContent = additionalFiles.Select(f => (f.fileName, f.contents.Text)).ToArray(); + var razorDocument = CreateProjectAndRazorDocument(razorContents, documentFilePath: FilePath("Component.razor"), additionalFiles: additionalContent); + var project = razorDocument.Project; + var csharpDocument = project.Documents.First(); + + var compilation = await project.GetCompilationAsync(DisposalToken); + + var csharpPosition = additionalFiles.Single(d => d.contents.Positions.Length == 1).contents.Position; + var node = await GetSyntaxNodeAsync(csharpDocument, csharpPosition); + var symbol = FindSymbolToRename(compilation.AssumeNotNull(), node); + + var solution = await Renamer.RenameSymbolAsync(project.Solution, symbol, new SymbolRenameOptions(), newName, DisposalToken); + + Assert.True(LocalWorkspace.TryApplyChanges(solution)); + + var expectedChanges = (additionalContent ?? []).Concat([(razorDocument.FilePath!, razorContents)]); + var fileSystem = new TestFileSystem([.. expectedChanges]); + var service = new RazorRefactorNotifyService(LoggerFactory); + Assert.True(service.GetTestAccessor().OnAfterGlobalSymbolRenamed(symbol, newName, throwOnFailure: true, fileSystem)); + return fileSystem.MovedFiles; + } + + 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 GetSyntaxNodeAsync(Document document, int position) + { + var sourceText = await document.GetTextAsync(DisposalToken); + var csharpPosition = sourceText.GetLinePosition(position); + + var span = sourceText.GetTextSpan(csharpPosition, csharpPosition); + var tree = await document.GetSyntaxTreeAsync(DisposalToken); + var root = await tree.AssumeNotNull().GetRootAsync(DisposalToken); + + return root.FindNode(span, getInnermostNodeForTie: true); + } +}