File: CodeActions\Razor\ComponentAccessibilityCodeActionProvider.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;
using System.Collections.Generic;
using System.Collections.Immutable;
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.Components;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Utilities;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.VisualStudio.Editor.Razor;
 
namespace Microsoft.CodeAnalysis.Razor.CodeActions;
 
internal class ComponentAccessibilityCodeActionProvider(IFileSystem fileSystem) : IRazorCodeActionProvider
{
    private readonly IFileSystem _fileSystem = fileSystem;
 
    public async Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(
        RazorCodeActionContext context, CancellationToken cancellationToken)
    {
        // Locate cursor
        var node = context.CodeDocument.GetRequiredSyntaxRoot().FindInnermostNode(context.StartAbsoluteIndex);
        if (node is null)
        {
            return [];
        }
 
        // Find start tag. We allow this code action to work from anywhere in the start tag, which includes
        // embedded C#, so we just have to traverse up the tree to find a start tag if there is one.
        // We also check for tag helper start tags here, because an invalid start tag with a valid tag helper
        // anywhere in it  would otherwise not match. We rely on the IsTagUnknown method below, to ensure we
        // only offer on actual potential component tags (because it checks for the compiler diagnostic)
        var startTag = node.FirstAncestorOrSelf<BaseMarkupStartTagSyntax>();
        if (startTag is null)
        {
            return [];
        }
 
        if (context.StartAbsoluteIndex < startTag.SpanStart)
        {
            // Cursor is before the start tag, so we shouldn't show a light bulb. This can happen
            // in cases where the cursor is in whitespace at the beginning of the document
            // eg: $$ <Component></Component>
            return [];
        }
 
        // Ignore if start tag has dots, as we only handle short tags
        if (startTag.Name.Content.Contains('.'))
        {
            return [];
        }
 
        if (!IsApplicableTag(startTag))
        {
            return [];
        }
 
        if (!IsTagUnknown(startTag, context))
        {
            return [];
        }
 
        using var _ = ListPool<RazorVSInternalCodeAction>.GetPooledObject(out var codeActions);
        await AddComponentAccessFromTagAsync(context, startTag, codeActions, cancellationToken).ConfigureAwait(false);
        AddCreateComponentFromTag(context, startTag, codeActions);
 
        return [.. codeActions];
    }
 
    private static bool IsApplicableTag(BaseMarkupStartTagSyntax startTag)
    {
        if (startTag.Name.Width == 0)
        {
            // Empty tag name, we shouldn't show a light bulb just to create an empty file.
            return false;
        }
 
        return true;
    }
 
    private void AddCreateComponentFromTag(
        RazorCodeActionContext context, BaseMarkupStartTagSyntax startTag, List<RazorVSInternalCodeAction> container)
    {
        if (!context.SupportsFileCreation)
        {
            return;
        }
 
        var path = context.Request.TextDocument.DocumentUri.GetAbsoluteOrUNCPath();
        path = FilePathNormalizer.Normalize(path);
 
        var directoryName = Path.GetDirectoryName(path);
        Assumes.NotNull(directoryName);
 
        var newComponentPath = Path.Combine(directoryName, $"{startTag.Name.Content}.razor");
        if (_fileSystem.FileExists(newComponentPath))
        {
            return;
        }
 
        var actionParams = new CreateComponentCodeActionParams
        {
            Path = newComponentPath,
        };
 
        var resolutionParams = new RazorCodeActionResolutionParams
        {
            TextDocument = context.Request.TextDocument,
            Action = LanguageServerConstants.CodeActions.CreateComponentFromTag,
            Language = RazorLanguageKind.Razor,
            DelegatedDocumentUri = context.DelegatedDocumentUri,
            Data = actionParams,
        };
 
        var codeAction = RazorCodeActionFactory.CreateComponentFromTag(resolutionParams);
        container.Add(codeAction);
    }
 
    private static async Task AddComponentAccessFromTagAsync(
        RazorCodeActionContext context,
        BaseMarkupStartTagSyntax startTag,
        List<RazorVSInternalCodeAction> container,
        CancellationToken cancellationToken)
    {
        var haveAddedNonQualifiedFix = false;
 
        // First see if there are any components that match in name, but not case, without qualification
        foreach (var t in context.CodeDocument.GetRequiredTagHelperContext().TagHelpers)
        {
            if (t.TagMatchingRules is [{ CaseSensitive: true } rule] &&
                rule.TagName.Equals(startTag.Name.Content, StringComparison.OrdinalIgnoreCase) &&
                rule.TagName != startTag.Name.Content)
            {
                var renameTagWorkspaceEdit = CreateRenameTagEdit(context, startTag, rule.TagName);
                var fixCasingCodeAction = RazorCodeActionFactory.CreateFullyQualifyComponent(rule.TagName, renameTagWorkspaceEdit);
                container.Add(fixCasingCodeAction);
                haveAddedNonQualifiedFix = true;
                break;
            }
        }
 
        var matching = await FindMatchingTagHelpersAsync(context, startTag, cancellationToken).ConfigureAwait(false);
 
        // For all the matches, add options for add @using and fully qualify
        foreach (var tagHelperPair in matching)
        {
            if (tagHelperPair.FullyQualified is null)
            {
                continue;
            }
 
            // If they have a typo, eg <CounTer /> and we've offered them <Counter /> above, then it would be odd to offer
            // them <BlazorApp.Pages.Counter /> as well. We will offer them <BlazorApp.MisTypedPages.CounTer /> though, if it
            // exists.
            if (!haveAddedNonQualifiedFix || !tagHelperPair.CaseInsensitiveMatch)
            {
                // if fqn contains a generic typeparam, we should strip it out. Otherwise, replacing tag name will leave generic parameters in razor code, which are illegal
                // e.g. <Component /> -> <Component<T> />
                var fullyQualifiedName = RazorComponentSearchEngine.RemoveGenericContent(tagHelperPair.Short.Name.AsMemory()).ToString();
 
                // If the match was case insensitive, then see if we can work out a new tag name to use as part of adding a using statement
                TextDocumentEdit? additionalEdit = null;
                string? newTagName = null;
                if (tagHelperPair.CaseInsensitiveMatch)
                {
                    newTagName = tagHelperPair.Short.TagMatchingRules.FirstOrDefault()?.TagName;
                    if (newTagName is not null)
                    {
                        additionalEdit = CreateRenameTagEdit(context, startTag, newTagName).DocumentChanges!.Value.First().First.AssumeNotNull();
                    }
                }
 
                // We only want to add a using statement if this was a case sensitive match, or if we were able to determine a new tag
                // name to give the tag.
                if (!tagHelperPair.CaseInsensitiveMatch || newTagName is not null)
                {
                    if (AddUsingsCodeActionResolver.TryCreateAddUsingResolutionParams(fullyQualifiedName, context.Request.TextDocument, additionalEdit, context.DelegatedDocumentUri, out var @namespace, out var resolutionParams))
                    {
                        var addUsingCodeAction = RazorCodeActionFactory.CreateAddComponentUsing(@namespace, newTagName, resolutionParams);
                        container.Add(addUsingCodeAction);
                    }
                }
 
                // Fully qualify
                var renameTagWorkspaceEdit = CreateRenameTagEdit(context, startTag, fullyQualifiedName);
                var fullyQualifiedCodeAction = RazorCodeActionFactory.CreateFullyQualifyComponent(fullyQualifiedName, renameTagWorkspaceEdit);
                container.Add(fullyQualifiedCodeAction);
            }
        }
    }
 
    private static async Task<ImmutableArray<TagHelperPair>> FindMatchingTagHelpersAsync(
        RazorCodeActionContext context, BaseMarkupStartTagSyntax startTag, CancellationToken cancellationToken)
    {
        // Get all data necessary for matching
        var tagName = startTag.Name.Content;
        string? parentTagName = null;
        if (startTag.Parent?.Parent is BaseMarkupElementSyntax parentElement)
        {
            parentTagName = parentElement.StartTag?.Name.Content ?? parentElement.EndTag?.Name.Content;
        }
 
        var attributes = TagHelperFacts.StringifyAttributes(startTag.Attributes);
 
        // Find all matching tag helpers
        using var _ = DictionaryPool<string, TagHelperPair>.GetPooledObject(out var matching);
 
        var tagHelpers = await context.DocumentSnapshot.Project.GetTagHelpersAsync(cancellationToken).ConfigureAwait(false);
 
        foreach (var tagHelper in tagHelpers)
        {
            if (SatisfiesRules(tagHelper.TagMatchingRules, tagName.AsSpan(), parentTagName.AsSpan(), attributes, out var caseInsensitiveMatch))
            {
                matching.Add(tagHelper.Name, new TagHelperPair(tagHelper, caseInsensitiveMatch));
            }
        }
 
        // Iterate and find the fully qualified version
        foreach (var tagHelper in tagHelpers)
        {
            if (matching.TryGetValue(tagHelper.Name, out var tagHelperPair))
            {
                if (tagHelperPair != null && tagHelper != tagHelperPair.Short)
                {
                    tagHelperPair.FullyQualified = tagHelper;
                }
            }
        }
 
        return [.. matching.Values];
    }
 
    private static bool SatisfiesRules(
        ImmutableArray<TagMatchingRuleDescriptor> tagMatchingRules,
        ReadOnlySpan<char> tagNameWithoutPrefix,
        ReadOnlySpan<char> parentTagNameWithoutPrefix,
        ImmutableArray<KeyValuePair<string, string>> tagAttributes,
        out bool caseInsensitiveMatch)
    {
        caseInsensitiveMatch = false;
 
        foreach (var rule in tagMatchingRules)
        {
            // We have to match parent tag and attributes regardless, so check them first and exit early if there is a fail
            if (!TagHelperMatchingConventions.SatisfiesParentTag(rule, parentTagNameWithoutPrefix) ||
               !TagHelperMatchingConventions.SatisfiesAttributes(rule, tagAttributes))
            {
                return false;
            }
 
            // Tag helpers that target catch-all will come back as satisfying the rule, but we don't want to use them for the code action
            // so we have to check that ourselves
            if (rule.TagName is null or TagHelperMatchingConventions.ElementCatchAllName)
            {
                return false;
            }
            else if (TagHelperMatchingConventions.SatisfiesTagName(rule, tagNameWithoutPrefix))
            {
                // Nothing to do, just loop around to the next rule
            }
            else if (TagHelperMatchingConventions.SatisfiesTagName(rule, tagNameWithoutPrefix, StringComparison.OrdinalIgnoreCase))
            {
                // Because the code action will be fixing the casing of the tag, we don't need all the rules to be consistent.
                caseInsensitiveMatch = true;
            }
            else
            {
                return false;
            }
        }
 
        return true;
    }
 
    private static WorkspaceEdit CreateRenameTagEdit(
        RazorCodeActionContext context, BaseMarkupStartTagSyntax startTag, string newTagName)
    {
        using var textEdits = new PooledArrayBuilder<SumType<TextEdit, AnnotatedTextEdit>>();
        var codeDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier() { DocumentUri = context.Request.TextDocument.DocumentUri };
 
        var startTagTextEdit = LspFactory.CreateTextEdit(startTag.Name.GetRange(context.CodeDocument.Source), newTagName);
 
        textEdits.Add(startTagTextEdit);
 
        var endTag = startTag.GetEndTag();
        if (endTag != null)
        {
            var endTagTextEdit = LspFactory.CreateTextEdit(endTag.Name.GetRange(context.CodeDocument.Source), newTagName);
            textEdits.Add(endTagTextEdit);
        }
 
        return new WorkspaceEdit
        {
            DocumentChanges = new TextDocumentEdit[]
            {
                new TextDocumentEdit()
                {
                    TextDocument = codeDocumentIdentifier,
                    Edits = textEdits.ToArray()
                }
            },
        };
    }
 
    private static bool IsTagUnknown(BaseMarkupStartTagSyntax startTag, RazorCodeActionContext context)
    {
        foreach (var diagnostic in context.CodeDocument.GetRequiredCSharpDocument().Diagnostics)
        {
            // Check that the diagnostic is to do with our start tag
            if (!(diagnostic.Span.AbsoluteIndex > startTag.Span.End
                || startTag.Span.Start > diagnostic.Span.AbsoluteIndex + diagnostic.Span.Length))
            {
                // Component is not recognized in environment
                if (diagnostic.Id == ComponentDiagnosticFactory.UnexpectedMarkupElement.Id)
                {
                    return true;
                }
            }
        }
 
        return false;
    }
 
    private sealed record class TagHelperPair(TagHelperDescriptor Short, bool CaseInsensitiveMatch)
    {
        public TagHelperDescriptor? FullyQualified { get; set; }
    }
}