File: Diagnostics\RazorTranslateDiagnosticsService.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.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.Razor.Diagnostics;
 
using RazorDiagnosticFactory = AspNetCore.Razor.Language.RazorDiagnosticFactory;
using SyntaxNode = AspNetCore.Razor.Language.Syntax.SyntaxNode;
 
/// <summary>
/// Contains several methods for mapping and filtering Razor and C# diagnostics. It allows for
/// translating code diagnostics from one representation into another, such as from C# to Razor.
/// </summary>
internal class RazorTranslateDiagnosticsService(IDocumentMappingService documentMappingService, ILoggerFactory loggerFactory)
{
    private readonly IDocumentMappingService _documentMappingService = documentMappingService;
    private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<RazorTranslateDiagnosticsService>();
 
    /// <summary>
    ///  Translates code diagnostics from one representation into another.
    /// </summary>
    /// <param name="diagnosticKind">
    ///  The <see cref="RazorLanguageKind"/> of the <see cref="Diagnostic"/> objects
    ///  included in <paramref name="diagnostics"/>.
    /// </param>
    /// <param name="diagnostics">
    ///  An array of <see cref="Diagnostic"/> objects to translate.
    /// </param>
    /// <param name="documentSnapshot">
    ///  The <see cref="IDocumentSnapshot"/> for the code document associated with the diagnostics.
    /// </param>
    /// <param name="cancellationToken">A token that can be checked to cancel work.</param>
    /// <returns>An array of translated diagnostics</returns>
    internal async Task<LspDiagnostic[]> TranslateAsync(
        RazorLanguageKind diagnosticKind,
        LspDiagnostic[] diagnostics,
        IDocumentSnapshot documentSnapshot,
        CancellationToken cancellationToken)
    {
        var codeDocument = await documentSnapshot.GetGeneratedOutputAsync(cancellationToken).ConfigureAwait(false);
 
        var filteredDiagnostics = diagnosticKind == RazorLanguageKind.CSharp
            ? FilterCSharpDiagnostics(diagnostics, codeDocument)
            : FilterHTMLDiagnostics(diagnostics, codeDocument);
        if (filteredDiagnostics.Length == 0)
        {
            _logger.LogDebug($"No diagnostics remaining after filtering.");
            return [];
        }
 
        _logger.LogDebug($"{filteredDiagnostics.Length}/{diagnostics.Length} diagnostics remain after filtering {diagnosticKind}.");
 
        var mappedDiagnostics = MapDiagnostics(
            diagnosticKind,
            filteredDiagnostics,
            documentSnapshot,
            codeDocument);
 
        return mappedDiagnostics;
    }
 
    private static LspDiagnostic[] FilterHTMLDiagnostics(
        LspDiagnostic[] unmappedDiagnostics,
        RazorCodeDocument codeDocument)
    {
        var syntaxTree = codeDocument.GetRequiredTagHelperRewrittenSyntaxTree();
        var sourceText = codeDocument.Source.Text;
 
        using var _ = DictionaryPool<TextSpan, bool>.GetPooledObject(out var processedAttributes);
 
        var filteredDiagnostics = unmappedDiagnostics
            .Where(d =>
                !InRazorComment(d, sourceText, syntaxTree) &&
                !InCSharpLiteral(d, sourceText, syntaxTree) &&
                !InAttributeContainingCSharp(d, sourceText, syntaxTree, processedAttributes) &&
                !AppliesToTagHelperTagName(d, sourceText, syntaxTree) &&
                !ShouldFilterHtmlDiagnosticBasedOnErrorCode(d, sourceText, syntaxTree))
            .ToArray();
 
        return filteredDiagnostics;
    }
 
    internal LspDiagnostic[] MapDiagnostics(
        RazorLanguageKind languageKind,
        LspDiagnostic[] diagnostics,
        IDocumentSnapshot documentSnapshot,
        RazorCodeDocument codeDocument)
    {
        var projects = RazorDiagnosticHelper.GetProjectInformation(documentSnapshot);
        using var mappedDiagnostics = new PooledArrayBuilder<LspDiagnostic>();
 
        foreach (var diagnostic in diagnostics)
        {
            // C# requests don't map directly to where they are in the document.
            if (languageKind == RazorLanguageKind.CSharp)
            {
                if (!TryGetOriginalDiagnosticRange(diagnostic, codeDocument, out var originalRange))
                {
                    continue;
                }
 
                diagnostic.Range = originalRange;
            }
 
            if (diagnostic is VSDiagnostic vsDiagnostic)
            {
                // We're the ones reporting the diagnostic, and it shows up as coming from our filename (not the generated one), so
                // the project info should be consistent too
                vsDiagnostic.Projects = projects;
            }
 
            mappedDiagnostics.Add(diagnostic);
        }
 
        return mappedDiagnostics.ToArray();
    }
 
    private static bool InRazorComment(
        LspDiagnostic d,
        SourceText sourceText,
        RazorSyntaxTree syntaxTree)
    {
        // If the diagnostic is within a Razor comment block, we don't want to show it.
        // Razor comments are not part of the Html document, so diagnostics within them stem from misinterpretation
        // of the "~" and comments that are generated by the compiler.
        return d.Range is not null &&
            syntaxTree.Root.FindNode(sourceText.GetTextSpan(d.Range), getInnermostNodeForTie: true) is RazorCommentBlockSyntax;
    }
 
    private static bool InCSharpLiteral(
        LspDiagnostic d,
        SourceText sourceText,
        RazorSyntaxTree syntaxTree)
    {
        if (d.Range is null)
        {
            return false;
        }
 
        var owner = syntaxTree.Root.FindNode(sourceText.GetTextSpan(d.Range), getInnermostNodeForTie: true);
        if (IsCsharpKind(owner))
        {
            return true;
        }
 
        if (owner is CSharpImplicitExpressionSyntax implicitExpressionSyntax &&
            implicitExpressionSyntax.Body is CSharpImplicitExpressionBodySyntax bodySyntax &&
            bodySyntax.CSharpCode is CSharpCodeBlockSyntax codeBlock)
        {
            return codeBlock.Children.Count == 1
                && IsCsharpKind(codeBlock.Children[0]);
        }
 
        return false;
 
        static bool IsCsharpKind([NotNullWhen(true)] SyntaxNode? node)
            => node?.Kind is SyntaxKind.CSharpExpressionLiteral
                or SyntaxKind.CSharpStatementLiteral
                or SyntaxKind.CSharpEphemeralTextLiteral;
    }
 
    private static bool AppliesToTagHelperTagName(LspDiagnostic diagnostic, SourceText sourceText, RazorSyntaxTree syntaxTree)
    {
        // Goal of this method is to filter diagnostics that touch TagHelper tag names. Reason being is TagHelpers can output anything. Meaning
        // If you have a TagHelper like:
        //
        // <Input>
        // </Input>
        //
        // HTML would see this as an error because the input element can't have a body; however, a TagHelper could respect this in a totally valid
        // way.
 
        if (diagnostic.Range is null)
        {
            return false;
        }
 
        var owner = syntaxTree.FindInnermostNode(sourceText, diagnostic.Range.End);
 
        var startOrEndTag = owner?.FirstAncestorOrSelf<RazorSyntaxNode>(static n => n is MarkupTagHelperStartTagSyntax || n is MarkupTagHelperEndTagSyntax);
        if (startOrEndTag is null)
        {
            return false;
        }
 
        var tagName = startOrEndTag is MarkupTagHelperStartTagSyntax startTag ? startTag.Name : ((MarkupTagHelperEndTagSyntax)startOrEndTag).Name;
        var tagNameRange = tagName.GetRange(syntaxTree.Source);
 
        if (!tagNameRange.IntersectsOrTouches(diagnostic.Range))
        {
            // The diagnostic doesn't touch the tag name
            return false;
        }
 
        // Diagnostic is touching the start or end tag name range
        return true;
    }
 
    private static bool ShouldFilterHtmlDiagnosticBasedOnErrorCode(LspDiagnostic diagnostic, SourceText sourceText, RazorSyntaxTree syntaxTree)
    {
        if (!diagnostic.Code.HasValue)
        {
            return false;
        }
 
        diagnostic.Code.Value.TryGetSecond(out var str);
 
        return str switch
        {
            CSSErrorCodes.UnrecognizedBlockType => IsEscapedAtSign(diagnostic, sourceText),
            CSSErrorCodes.MissingOpeningBrace or
            CSSErrorCodes.MissingClassNameAfterDot or
            CSSErrorCodes.MissingSelectorAfterCombinator or
            CSSErrorCodes.MissingPropertyName or
            CSSErrorCodes.MissingPropertyValue or
            CSSErrorCodes.MissingSelectorBeforeCombinatorCode => IsAtCSharpTransitionInStyleBlock(diagnostic, sourceText, syntaxTree),
            HtmlErrorCodes.UnexpectedEndTagErrorCode => IsHtmlWithBangAndMatchingTags(diagnostic, sourceText, syntaxTree),
            HtmlErrorCodes.InvalidNestingErrorCode => IsAnyFilteredInvalidNestingError(diagnostic, sourceText, syntaxTree),
            HtmlErrorCodes.MissingEndTagErrorCode => syntaxTree.Options.FileKind.IsComponent(), // Redundant with RZ9980 in Components
            HtmlErrorCodes.TooFewElementsErrorCode => IsAnyFilteredTooFewElementsError(diagnostic, sourceText, syntaxTree),
            _ => false,
        };
 
        static bool IsEscapedAtSign(LspDiagnostic diagnostic, SourceText sourceText)
        {
            // Filters out "Unrecognized block type" errors in CSS, which occur with something like this:
            //
            // <style>
            //     @@font - face
            //     {
            //         // contents
            //     }
            // </style>
            //
            // The "@@" tells Razor that the user wants an "@" in the final Html, but the design time document
            // for the Html has to line up with the source Razor file, so that doesn't happen in the IDE. When
            // CSS gets the two "@"s, it raises the "Unrecognized block type" error.
 
            if (!sourceText.TryGetAbsoluteIndex(diagnostic.Range.Start, out var absoluteIndex))
            {
                return false;
            }
 
            // It's much easier to just check the source text directly, rather than try to understand all of the
            // possible shapes of the syntax tree here. We assume that since the diagnostics we're filtering out
            // came from the CSS server, it's a CSS block.
            return absoluteIndex > 0 &&
                sourceText[absoluteIndex] == '@' &&
                sourceText[absoluteIndex - 1] == '@';
        }
 
        static bool IsAtCSharpTransitionInStyleBlock(LspDiagnostic diagnostic, SourceText sourceText, RazorSyntaxTree syntaxTree)
        {
            if (!sourceText.TryGetAbsoluteIndex(diagnostic.Range.Start, out var absoluteIndex))
            {
                return false;
            }
 
            // Skip past non-newline whitespace to find the first interesting node
            while (sourceText[absoluteIndex] is ' ' or '\t')
            {
                absoluteIndex++;
                if (absoluteIndex == sourceText.Length)
                {
                    return false;
                }
            }
 
            var owner = syntaxTree.Root.FindInnermostNode(absoluteIndex);
 
            // If we're not at an @ to transition to C#, then we don't want to filter this diagnostic
            if (owner is not CSharpTransitionSyntax)
            {
                return false;
            }
 
            return owner.FirstAncestorOrSelf<BaseMarkupElementSyntax>(static n => n.StartTag?.Name.Content == "style") is not null;
        }
 
        // Ideally this would be solved instead by not emitting the "!" at the HTML backing file,
        // but we don't currently have a system to accomplish that
        static bool IsAnyFilteredTooFewElementsError(LspDiagnostic diagnostic, SourceText sourceText, RazorSyntaxTree syntaxTree)
        {
            var owner = syntaxTree.FindInnermostNode(sourceText, diagnostic.Range.Start);
            if (owner is null)
            {
                return false;
            }
 
            var element = owner.FirstAncestorOrSelf<MarkupElementSyntax>();
            if (element is null)
            {
                return false;
            }
 
            if (element.StartTag?.Name.Content != "html")
            {
                return false;
            }
 
            var bodyElement = element
                .ChildNodes()
                .OfType<MarkupElementSyntax>()
                .SingleOrDefault(static element => element.StartTag?.Name.Content == "body");
 
            return bodyElement is not null &&
                   bodyElement.StartTag?.Bang is not null;
        }
 
        // Ideally this would be solved instead by not emitting the "!" at the HTML backing file,
        // but we don't currently have a system to accomplish that
        static bool IsHtmlWithBangAndMatchingTags(LspDiagnostic diagnostic, SourceText sourceText, RazorSyntaxTree syntaxTree)
        {
            var owner = syntaxTree.FindInnermostNode(sourceText, diagnostic.Range.Start);
            if (owner is null)
            {
                return false;
            }
 
            var element = owner.FirstAncestorOrSelf<MarkupElementSyntax>();
            var startNode = element?.StartTag;
            var endNode = element?.EndTag;
 
            if (startNode is null || endNode is null)
            {
                // We only care about tags with a start and an end because we want to exclude diagnostics from their children
                return false;
            }
 
            var haveBang = startNode.Bang.IsValid() && endNode.Bang.IsValid();
            var namesEquivalent = startNode.Name.Content == endNode.Name.Content;
 
            return haveBang && namesEquivalent;
        }
 
        static bool IsAnyFilteredInvalidNestingError(LspDiagnostic diagnostic, SourceText sourceText, RazorSyntaxTree syntaxTree)
            => IsInvalidNestingWarningWithinComponent(diagnostic, sourceText, syntaxTree) ||
               IsInvalidNestingFromBody(diagnostic, sourceText, syntaxTree);
 
        static bool IsInvalidNestingWarningWithinComponent(LspDiagnostic diagnostic, SourceText sourceText, RazorSyntaxTree syntaxTree)
        {
            var owner = syntaxTree.FindInnermostNode(sourceText, diagnostic.Range.Start);
            if (owner is null)
            {
                return false;
            }
 
            var taghelperNode = owner.FirstAncestorOrSelf<MarkupTagHelperElementSyntax>();
 
            return taghelperNode is not null;
        }
 
        // Ideally this would be solved instead by not emitting the "!" at the HTML backing file,
        // but we don't currently have a system to accomplish that
        static bool IsInvalidNestingFromBody(LspDiagnostic diagnostic, SourceText sourceText, RazorSyntaxTree syntaxTree)
        {
            var owner = syntaxTree.FindInnermostNode(sourceText, diagnostic.Range.Start);
            if (owner is null)
            {
                return false;
            }
 
            var body = owner.FirstAncestorOrSelf<MarkupElementSyntax>(static n => n.StartTag?.Name.Content.Equals("body", StringComparison.Ordinal) == true);
 
            if (ReferenceEquals(body, owner))
            {
                return false;
            }
 
            if (diagnostic.Message is null)
            {
                return false;
            }
 
            return diagnostic.Message.EndsWith("cannot be nested inside element 'html'.") && body?.StartTag?.Bang is not null;
        }
    }
 
    private static bool InAttributeContainingCSharp(
        LspDiagnostic diagnostic,
        SourceText sourceText,
        RazorSyntaxTree syntaxTree,
        Dictionary<TextSpan, bool> processedAttributes)
    {
        // Examine the _end_ of the diagnostic to see if we're at the
        // start of an (im/ex)plicit expression. Looking at the start
        // of the diagnostic isn't sufficient.
        if (diagnostic.Range is null)
        {
            return false;
        }
 
        if (!sourceText.TryGetAbsoluteIndex(diagnostic.Range.End, out var absoluteIndex))
        {
            return false;
        }
 
        var owner = syntaxTree.Root.FindInnermostNode(absoluteIndex);
        if (owner is null)
        {
            return false;
        }
 
        // If the owner is the close quote of an attribute value, then we need to move to the previous position, as
        // the closing quote is actually owned by the whole attribute node. This will put us in the actual attribute
        // value node for sure, and as long as the diagnostic range isn't zero-width, shouldn't affect semantics.
        if (absoluteIndex > 0 &&
            diagnostic.Range.Start != diagnostic.Range.End &&
            owner is MarkupTextLiteralSyntax { LiteralTokens: [{ Content: "\"" or "'" }], Parent: MarkupTagHelperAttributeSyntax or MarkupAttributeBlockSyntax })
        {
            owner = syntaxTree.Root.FindInnermostNode(absoluteIndex - 1);
            if (owner is null)
            {
                return false;
            }
        }
 
        var markupAttributeValue = owner.FirstAncestorOrSelf<RazorSyntaxNode>(static n =>
            (n.Parent is MarkupAttributeBlockSyntax block && n == block.Value) ||
            n is MarkupTagHelperAttributeValueSyntax or MarkupMiscAttributeContentSyntax);
 
        if (markupAttributeValue is not null)
        {
            if (!processedAttributes.TryGetValue(markupAttributeValue.Span, out var shouldFilterDiagnostic))
            {
                // If a component attribute is spread across multiple lines, it's not valid Html so the Html server can't be expected to reason
                // about the contents correctly
                shouldFilterDiagnostic = markupAttributeValue is MarkupTagHelperAttributeValueSyntax &&
                    markupAttributeValue.GetLinePositionSpan(syntaxTree.Source).SpansMultipleLines();
 
                // Similarly, if the attribute value contains non-markup, the Html could report false positives
                shouldFilterDiagnostic |= CheckIfAttributeContainsNonMarkupNodes(markupAttributeValue);
                processedAttributes.Add(markupAttributeValue.Span, shouldFilterDiagnostic);
            }
 
            return shouldFilterDiagnostic;
        }
 
        return false;
 
        static bool CheckIfAttributeContainsNonMarkupNodes(RazorSyntaxNode attributeNode)
        {
            return attributeNode.DescendantNodes().Any(IsNotMarkupOrCommentNode);
        }
 
        static bool IsNotMarkupOrCommentNode(SyntaxNode node)
        {
            return !(node is
                MarkupBlockSyntax or
                MarkupSyntaxNode or
                GenericBlockSyntax or
                RazorCommentBlockSyntax);
        }
    }
 
    private LspDiagnostic[] FilterCSharpDiagnostics(LspDiagnostic[] diagnostics, RazorCodeDocument codeDocument)
    {
        using var filteredDiagnostics = new PooledArrayBuilder<LspDiagnostic>();
 
        foreach (var diagnostic in diagnostics)
        {
            if (diagnostic.Code is not { } code ||
                !code.TryGetSecond(out var str) ||
                str is null)
            {
                filteredDiagnostics.Add(diagnostic);
                continue;
            }
 
            if (str switch
            {
                "CS1525" => ShouldIgnoreCS1525(diagnostic, codeDocument),
                Constants.DiagnosticIds.IDE0005_gen => IsUsingDirectiveUsed(diagnostic, codeDocument),
                // This diagnostics is produced by Roslyn to help its Remove Usings code fixer, so is irrelevant to us
                Constants.DiagnosticIds.RemoveUnnecessaryImportsFixable => true,
                _ => false
            })
            {
                continue;
            }
 
            filteredDiagnostics.Add(diagnostic);
        }
 
        return filteredDiagnostics.ToArrayAndClear();
 
        bool ShouldIgnoreCS1525(LspDiagnostic diagnostic, RazorCodeDocument codeDocument)
        {
            if (CheckIfDocumentHasRazorDiagnostic(codeDocument, RazorDiagnosticFactory.TagHelper_EmptyBoundAttribute.Id) &&
                TryGetOriginalDiagnosticRange(diagnostic, codeDocument, out var originalRange) &&
                originalRange.IsUndefined())
            {
                // Empty attribute values will take the following form in the generated C# document:
                // __o = Microsoft.AspNetCore.Components.EventCallback.Factory.Create<Microsoft.AspNetCore.Components.Web.ProgressEventArgs>(this, );
                // The trailing `)` with no value preceding it, will lead to a C# error which doesn't make sense within the razor file.
                // The empty attribute value is not directly mappable to Razor, hence we check if the diagnostic has an undefined range.
                // Note; Error RZ2008 informs the user that the empty attribute value is not allowed.
                // https://github.com/dotnet/aspnetcore/issues/30480
                return true;
            }
 
            return false;
        }
    }
 
    private bool IsUsingDirectiveUsed(LspDiagnostic diagnostic, RazorCodeDocument codeDocument)
    {
        // In imports files, all usings are considered used
        if (codeDocument.IsImportsFile())
        {
            return true;
        }
 
        // In legacy files, using directives don't affect tag helper discovery so they're all "unused" to us.
        if (codeDocument.FileKind.IsLegacy())
        {
            return false;
        }
 
        // Roslyn reports any usings that aren't used by user code for us. Some of these usings might be
        // used for component tags though, which are always fully qualified by the Razor compiler, so we
        // have to check if the using was actually used by component binding, if so, we need to keep the
        // diagnostic. Conveniently, this means we don't need to worry about actually reporting our own
        // unused diagnostics, so it's worth it.
        var syntaxTree = codeDocument.GetRequiredTagHelperRewrittenSyntaxTree();
        if (TryGetOriginalDiagnosticRange(diagnostic, codeDocument, out var originalRange) &&
            syntaxTree.FindInnermostNode(codeDocument.Source.Text, originalRange.Start) is { Parent.Parent: RazorUsingDirectiveSyntax usingDirectiveSyntax })
        {
            return codeDocument.IsDirectiveUsed(usingDirectiveSyntax);
        }
 
        return true;
    }
 
    private static bool CheckIfDocumentHasRazorDiagnostic(RazorCodeDocument codeDocument, string razorDiagnosticCode)
    {
        return codeDocument.GetRequiredTagHelperRewrittenSyntaxTree().Diagnostics.Any(razorDiagnosticCode, static (d, code) => d.Id == code);
    }
 
    private bool TryGetOriginalDiagnosticRange(LspDiagnostic diagnostic, RazorCodeDocument codeDocument, [NotNullWhen(true)] out LspRange? originalRange)
    {
        if (!_documentMappingService.TryMapToRazorDocumentRange(
            codeDocument.GetRequiredCSharpDocument(),
            diagnostic.Range,
            MappingBehavior.Inferred,
            out originalRange))
        {
            // Couldn't remap the range correctly.
            // If this is error it's worth at least logging so we know if there's an issue
            // for mapping when a user reports not seeing an error they thought they should
            if (diagnostic.Severity == LspDiagnosticSeverity.Error)
            {
                _logger.LogWarning($"Dropping diagnostic {diagnostic.Code}:{diagnostic.Message} at csharp range {diagnostic.Range}");
            }
 
            return false;
        }
 
        return true;
    }
}