File: Rename\RenameService.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// 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.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode;
 
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<RenameResult> TryGetRazorRenameEditsAsync(
        DocumentContext documentContext,
        DocumentPositionInfo positionInfo,
        string newName,
        ISolutionQueryOperations solutionQueryOperations,
        CancellationToken cancellationToken)
    {
        // We only support renaming of .razor components, not .cshtml tag helpers
        if (!documentContext.Snapshot.FileKind.IsComponent())
        {
            return new(Edit: null);
        }
 
        var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
 
        if (!TryGetOriginTagHelpers(codeDocument, positionInfo.HostDocumentIndex, out var originTagHelpers))
        {
            return new(Edit: null);
        }
 
        var originComponentDocumentSnapshot = await _componentSearchEngine
            .TryLocateComponentAsync(originTagHelpers.Primary, solutionQueryOperations, cancellationToken)
            .ConfigureAwait(false);
        if (originComponentDocumentSnapshot is null)
        {
            return new(Edit: null);
        }
 
        var originComponentDocumentFilePath = originComponentDocumentSnapshot.FilePath;
        var newPath = MakeNewPath(originComponentDocumentFilePath, newName);
        if (_fileSystem.FileExists(newPath))
        {
            // 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 documentChanges = new PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>();
 
        var fileRename = GetRenameFileEdit(originComponentDocumentFilePath, newPath);
        documentChanges.Add(fileRename);
 
        AddAdditionalFileRenames(ref documentChanges.AsRef(), originComponentDocumentFilePath, newPath);
 
        foreach (var documentChange in documentChanges)
        {
            if (documentChange.TryGetFirst(out var textDocumentEdit) &&
                textDocumentEdit.TextDocument.DocumentUri == fileRename.OldDocumentUri)
            {
                textDocumentEdit.TextDocument.DocumentUri = fileRename.NewDocumentUri;
            }
        }
 
        return new(Edit: new()
        {
            DocumentChanges = documentChanges.ToArrayAndClear()
        });
    }
 
    public bool TryGetRazorFileRenameEdit(
        DocumentContext documentContext,
        string newName,
        [NotNullWhen(true)] out WorkspaceEdit? workspaceEdit)
    {
        var oldPath = documentContext.Snapshot.FilePath;
        var newPath = MakeNewPath(oldPath, newName);
 
        using var documentChanges = new PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>();
 
        AddAdditionalFileRenames(ref documentChanges.AsRef(), oldPath, newPath);
 
        if (documentChanges.Count == 0)
        {
            workspaceEdit = null;
            return false;
        }
 
        workspaceEdit = new()
        {
            DocumentChanges = documentChanges.ToArrayAndClear()
        };
        return true;
    }
 
    private void AddAdditionalFileRenames(
        ref PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>> documentChanges,
        string oldFilePath, string newFilePath)
    {
        TryAdd(".cs", ref documentChanges);
        TryAdd(".css", ref documentChanges);
 
        void TryAdd(
            string extension,
            ref PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>> documentChanges)
        {
            var changedPath = oldFilePath + extension;
 
            if (_fileSystem.FileExists(changedPath))
            {
                documentChanges.Add(GetRenameFileEdit(changedPath, newFilePath + extension));
            }
        }
    }
 
    private RenameFile GetRenameFileEdit(string oldFilePath, string newFilePath)
        => new()
        {
            OldDocumentUri = new(LspFactory.CreateFilePathUri(oldFilePath, _languageServerFeatureOptions)),
            NewDocumentUri = new(LspFactory.CreateFilePathUri(newFilePath, _languageServerFeatureOptions)),
        };
 
    private static string MakeNewPath(string originalPath, string newName)
    {
        var newFileName = $"{newName}{Path.GetExtension(originalPath)}";
        var directoryName = Path.GetDirectoryName(originalPath).AssumeNotNull();
        return Path.Combine(directoryName, newFileName);
    }
 
    private readonly record struct OriginTagHelpers(TagHelperDescriptor Primary, TagHelperDescriptor? Associated);
 
    private static bool TryGetOriginTagHelpers(RazorCodeDocument codeDocument, int absoluteIndex, out OriginTagHelpers originTagHelpers)
    {
        var owner = codeDocument.GetRequiredSyntaxRoot().FindInnermostNode(absoluteIndex);
        if (owner is null)
        {
            Debug.Fail("Owner should never be null.");
            originTagHelpers = default;
            return false;
        }
 
        if (!TryGetTagHelperBinding(owner, absoluteIndex, out var binding))
        {
            originTagHelpers = default;
            return false;
        }
 
        // Can only have 1 component TagHelper belonging to an element at a time
        var primaryTagHelper = binding.TagHelpers.FirstOrDefault(static d => d.Kind == TagHelperKind.Component);
        if (primaryTagHelper is null)
        {
            originTagHelpers = default;
            return false;
        }
 
        var tagHelpers = codeDocument.GetRequiredTagHelpers();
        var associatedTagHelper = TryFindAssociatedTagHelper(primaryTagHelper, tagHelpers);
 
        originTagHelpers = new(primaryTagHelper, associatedTagHelper);
        return true;
    }
 
    private static bool TryGetTagHelperBinding(RazorSyntaxNode owner, int absoluteIndex, [NotNullWhen(true)] out TagHelperBinding? binding)
    {
        // End tags are easy, because there is only one possible binding result
        if (owner is MarkupTagHelperEndTagSyntax { Parent: MarkupTagHelperElementSyntax { TagHelperInfo.BindingResult: var endTagBindingResult } })
        {
            binding = endTagBindingResult;
            return true;
        }
 
        // A rename of a start tag could have an "owner" of one of its attributes, so we do a bit more checking
        // to support this case
        if (owner.FirstAncestorOrSelf<MarkupTagHelperStartTagSyntax>() is not { } tagHelperStartTag)
        {
            binding = null;
            return false;
        }
 
        // Ensure the rename action was invoked on the component name instead of a component parameter. This serves as an issue
        // mitigation till `textDocument/prepareRename` is supported and we can ensure renames aren't triggered in unsupported
        // contexts. (https://github.com/dotnet/razor/issues/4285)
        if (!tagHelperStartTag.Name.Span.IntersectsWith(absoluteIndex))
        {
            binding = null;
            return false;
        }
 
        if (tagHelperStartTag is { Parent: MarkupTagHelperElementSyntax { TagHelperInfo.BindingResult: var startTagBindingResult } })
        {
            binding = startTagBindingResult;
 
            // If the component is fully qualified, we need to make sure that the caret is in the actual component name part
            // not a namespace part.
            if (binding.TagHelpers is [{ IsFullyQualifiedNameMatch: true }, ..])
            {
                var lastDotIndex = tagHelperStartTag.Name.Content.LastIndexOf('.');
                Debug.Assert(lastDotIndex != -1, "Fully qualified component names should contain a dot.");
                if (absoluteIndex < tagHelperStartTag.Name.SpanStart + lastDotIndex + 1)
                {
                    binding = null;
                    return false;
                }
            }
 
            return true;
        }
 
        binding = null;
        return false;
    }
 
    private static TagHelperDescriptor? TryFindAssociatedTagHelper(
        TagHelperDescriptor primary,
        TagHelperCollection tagHelpers)
    {
        var typeName = primary.TypeName;
        var assemblyName = primary.AssemblyName;
 
        foreach (var tagHelper in tagHelpers)
        {
            if (typeName == tagHelper.TypeName &&
                assemblyName == tagHelper.AssemblyName &&
                !tagHelper.Equals(primary))
            {
                return tagHelper;
            }
        }
 
        return null;
    }
}