File: FindSymbols\FindReferences\Finders\AbstractReferenceFinder_GlobalSuppressions.cs
Web Access
Project: ..\..\..\src\Workspaces\Core\Portable\Microsoft.CodeAnalysis.Workspaces.csproj (Microsoft.CodeAnalysis.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.FindSymbols.Finders
{
    internal abstract partial class AbstractReferenceFinder : IReferenceFinder
    {
        private static bool ShouldFindReferencesInGlobalSuppressions(ISymbol symbol, [NotNullWhen(returnValue: true)] out string? documentationCommentId)
        {
            if (!SupportsGlobalSuppression(symbol))
            {
                documentationCommentId = null;
                return false;
            }
 
            documentationCommentId = DocumentationCommentId.CreateDeclarationId(symbol);
            return documentationCommentId != null;
 
            // Global suppressions are currently supported for types, members and
            // namespaces, except global namespace.
            static bool SupportsGlobalSuppression(ISymbol symbol)
                => symbol.Kind switch
                {
                    SymbolKind.Namespace => !((INamespaceSymbol)symbol).IsGlobalNamespace,
                    SymbolKind.NamedType => true,
                    SymbolKind.Method => true,
                    SymbolKind.Field => true,
                    SymbolKind.Property => true,
                    SymbolKind.Event => true,
                    _ => false,
                };
        }
 
        /// <summary>
        /// Find references to a symbol inside global suppressions.
        /// For example, consider a field 'Field' defined inside a type 'C'.
        /// This field's documentation comment ID is 'F:C.Field'
        /// A reference to this field inside a global suppression would be as following:
        ///     [assembly: SuppressMessage("RuleCategory", "RuleId', Scope = "member", Target = "~F:C.Field")]
        /// </summary>
        [PerformanceSensitive("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1224834", OftenCompletesSynchronously = true)]
        protected static async ValueTask<ImmutableArray<FinderLocation>> FindReferencesInDocumentInsideGlobalSuppressionsAsync(
            ISymbol symbol,
            FindReferencesDocumentState state,
            CancellationToken cancellationToken)
        {
            if (!ShouldFindReferencesInGlobalSuppressions(symbol, out var docCommentId))
                return ImmutableArray<FinderLocation>.Empty;
 
            // Check if we have any relevant global attributes in this document.
            var info = await SyntaxTreeIndex.GetRequiredIndexAsync(state.Document, cancellationToken).ConfigureAwait(false);
            if (!info.ContainsGlobalSuppressMessageAttribute)
                return ImmutableArray<FinderLocation>.Empty;
 
            var semanticModel = state.SemanticModel;
            var suppressMessageAttribute = semanticModel.Compilation.SuppressMessageAttributeType();
            if (suppressMessageAttribute == null)
                return ImmutableArray<FinderLocation>.Empty;
 
            // Check if we have any instances of the symbol documentation comment ID string literals within global attributes.
            // These string literals represent references to the symbol.
            if (!TryGetExpectedDocumentationCommentId(docCommentId, out var expectedDocCommentId))
                return ImmutableArray<FinderLocation>.Empty;
 
            var syntaxFacts = state.SyntaxFacts;
 
            // We map the positions of documentation ID literals in tree to string literal tokens,
            // perform semantic checks to ensure these are valid references to the symbol
            // and if so, add these locations to the computed references.
            var root = await semanticModel.SyntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false);
            using var _ = ArrayBuilder<FinderLocation>.GetInstance(out var locations);
            foreach (var token in root.DescendantTokens())
            {
                if (IsCandidate(state, token, expectedDocCommentId.Span, suppressMessageAttribute, cancellationToken, out var offsetOfReferenceInToken))
                {
                    var referenceLocation = CreateReferenceLocation(offsetOfReferenceInToken, token, root, state.Document, syntaxFacts);
                    locations.Add(new FinderLocation(token.GetRequiredParent(), referenceLocation));
                }
            }
 
            return locations.ToImmutable();
 
            // Local functions
            static bool IsCandidate(
                FindReferencesDocumentState state, SyntaxToken token, ReadOnlySpan<char> expectedDocCommentId,
                INamedTypeSymbol suppressMessageAttribute, CancellationToken cancellationToken, out int offsetOfReferenceInToken)
            {
                offsetOfReferenceInToken = -1;
 
                // Check if this token is a named attribute argument to "Target" property of "SuppressMessageAttribute".
                if (!IsValidTargetOfGlobalSuppressionAttribute(
                        token, suppressMessageAttribute, state.SemanticModel, state.SyntaxFacts, cancellationToken))
                {
                    return false;
                }
 
                // Target string must contain a valid symbol DocumentationCommentId.
                if (!ValidateAndSplitDocumentationCommentId(token.ValueText, out var prefix, out var docCommentId))
                {
                    return false;
                }
 
                // We have couple of success cases:
                // 1. The FAR symbol is the same one as the target of the suppression. In this case,
                //    target string for the suppression exactly matches the expectedDocCommentId.
                // 2. The FAR symbol is one of the containing symbols of the target symbol of suppression.
                //    In this case, the target string for the suppression starts with the expectedDocCommentId. 
                //
                // For example, consider the below suppression applied to field 'Field' of type 'C'
                //      [assembly: SuppressMessage("RuleCategory", "RuleId', Scope = "member", Target = "~F:C.Field")]
                // When doing a FAR query on 'Field', we would return true from case 1.
                // When doing a FAR query on 'C', we would return true from case 2.
 
                if (!docCommentId.Span.StartsWith(expectedDocCommentId))
                {
                    return false;
                }
 
                // We found a match, now compute the offset of the reference within the string literal token.
                offsetOfReferenceInToken = prefix.Length;
 
                if (expectedDocCommentId.Length < docCommentId.Length)
                {
                    // Expected doc comment ID belongs to a containing symbol of the symbol referenced in suppression's target doc comment ID.
                    // Verify the next character in suppression doc comment ID is the '.' separator for its member.
                    if (docCommentId.Span[expectedDocCommentId.Length] != '.')
                        return false;
 
                    offsetOfReferenceInToken += expectedDocCommentId.LastIndexOf('.') + 1;
                }
                else
                {
                    // Expected doc comment ID matches the suppression's target doc comment ID.
                    SplitIdAndArguments(docCommentId, out var idPartBeforeArguments, out var _);
                    offsetOfReferenceInToken += idPartBeforeArguments.Span.LastIndexOf('.') + 1;
                }
 
                return true;
            }
 
            static bool IsValidTargetOfGlobalSuppressionAttribute(
                SyntaxToken token,
                INamedTypeSymbol suppressMessageAttribute,
                SemanticModel semanticModel,
                ISyntaxFacts syntaxFacts,
                CancellationToken cancellationToken)
            {
                // We need to check if the given token is a non-null, non-empty string literal token
                // passed as a named argument to 'Target' property of a global SuppressMessageAttribute.
                //
                // For example, consider the below global suppression.
                //      [assembly: SuppressMessage("RuleCategory", "RuleId', Scope = "member", Target = "F:C.Field")]
                //
                // We return true when processing "F:C.Field".
 
                if (!syntaxFacts.IsStringLiteral(token))
                {
                    return false;
                }
 
                var text = token.ValueText;
                if (string.IsNullOrEmpty(text))
                {
                    return false;
                }
 
                // We need to go from string literal token "F:C.Field" to the suppression attribute node.
                //  AttributeSyntax
                //   -> AttributeArgumentList
                //     -> AttributeArgument
                //       -> StringLiteralExpression
                //         -> StringLiteralToken
                var attributeArgument = token.Parent?.Parent;
                if (syntaxFacts.GetNameForAttributeArgument(attributeArgument) != "Target")
                {
                    return false;
                }
 
                var attributeNode = attributeArgument!.Parent?.Parent;
                if (attributeNode == null || !syntaxFacts.IsGlobalAttribute(attributeNode))
                {
                    return false;
                }
 
                // Check the attribute type matches 'SuppressMessageAttribute'.
                var attributeSymbol = semanticModel.GetSymbolInfo(attributeNode, cancellationToken).Symbol?.ContainingType;
                return suppressMessageAttribute.Equals(attributeSymbol);
            }
 
            static ReferenceLocation CreateReferenceLocation(
                int offsetOfReferenceInToken,
                SyntaxToken token,
                SyntaxNode root,
                Document document,
                ISyntaxFacts syntaxFacts)
            {
                // We found a valid reference to the symbol in documentation comment ID string literal.
                // Compute the reference span within this string literal for the identifier.
                // For example, consider the suppression below for field 'Field' defined in type 'C':
                //      [assembly: SuppressMessage("RuleCategory", "RuleId', Scope = "member", Target = "F:C.Field")]
                // We compute the span for 'Field' within the target string literal.
                // NOTE: '#' is also a valid char in documentation comment ID. For example, '#ctor' and '#cctor'.
 
                var positionOfReferenceInTree = token.SpanStart + offsetOfReferenceInToken + 1;
                var valueText = token.ValueText;
                var length = 0;
                while (offsetOfReferenceInToken < valueText.Length)
                {
                    var ch = valueText[offsetOfReferenceInToken++];
                    if (ch == '#' || syntaxFacts.IsIdentifierPartCharacter(ch))
                        length++;
                    else
                        break;
                }
 
                // We create a reference location of the identifier span within this string literal
                // that represents the symbol reference.
                // We also add the location for the containing documentation comment ID string literal.
                // For the suppression example above, location points to the span of 'Field' inside "F:C.Field"
                // and containing string location points to the span of the entire string literal "F:C.Field".
                var location = Location.Create(root.SyntaxTree, new TextSpan(positionOfReferenceInTree, length));
                var containingStringLocation = token.GetLocation();
                return new ReferenceLocation(document, location, containingStringLocation);
            }
        }
 
        private static bool TryGetExpectedDocumentationCommentId(
            string id,
            out ReadOnlyMemory<char> docCommentId)
        {
            return ValidateAndSplitDocumentationCommentId(id, out _, out docCommentId);
        }
 
        /// <summary>
        /// Validate and split a documentation comment ID into a prefix and complete symbol ID. For the
        /// <paramref name="docCommentId"/> <c>~M:C.X(System.String)</c>, the <paramref name="prefix"/> would be
        /// <c>~M:</c> and <paramref name="id"/> would be <c>C.X(System.String)</c>.
        /// </summary>
        [PerformanceSensitive("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1224834", Constraint = "Avoid Regex splitting due to high allocation costs.")]
        private static bool ValidateAndSplitDocumentationCommentId(
            [NotNullWhen(true)] string? docCommentId,
            out ReadOnlyMemory<char> prefix,
            out ReadOnlyMemory<char> id)
        {
            prefix = ReadOnlyMemory<char>.Empty;
            id = ReadOnlyMemory<char>.Empty;
            if (docCommentId is null)
            {
                return false;
            }
 
            // Parse the prefix
            if (docCommentId.StartsWith("~"))
            {
                if (docCommentId.Length < 3)
                    return false;
 
                prefix = docCommentId.AsMemory()[0..3];
            }
            else
            {
                if (docCommentId.Length < 2)
                    return false;
 
                prefix = docCommentId.AsMemory()[0..2];
            }
 
            if (prefix.Span[^2] is < 'A' or > 'Z')
            {
                return false;
            }
 
            if (prefix.Span[^1] is not ':')
            {
                return false;
            }
 
            // The rest of the ID is returned without splitting
            id = docCommentId.AsMemory()[prefix.Length..];
            return true;
        }
 
        /// <summary>
        /// Split a full documentation symbol ID into the core symbol ID and optional parameter list. For the
        /// <paramref name="id"/> <c>C.X(System.String)</c>, the <paramref name="idPartBeforeArguments"/> would be
        /// <c>C.X</c> and <paramref name="arguments"/> would be <c>(System.String)</c>.
        /// </summary>
        private static void SplitIdAndArguments(
            ReadOnlyMemory<char> id,
            out ReadOnlyMemory<char> idPartBeforeArguments,
            out ReadOnlyMemory<char> arguments)
        {
            ReadOnlySpan<char> argumentSeparators = stackalloc[] { '(', '[' };
            var indexOfArguments = id.Span.IndexOfAny(argumentSeparators);
            if (indexOfArguments < 0)
            {
                idPartBeforeArguments = id;
                arguments = ReadOnlyMemory<char>.Empty;
            }
            else
            {
                idPartBeforeArguments = id[0..indexOfArguments];
                arguments = id[indexOfArguments..];
            }
        }
 
        /// <summary>
        /// Validate and split symbol documentation comment ID.
        /// For example, "~M:C.X(System.String)" represents the documentation comment ID of a method named 'X'
        /// that takes a single string-typed parameter and is contained in a type named 'C'.
        ///
        /// We divide the ID into 3 groups:
        /// 1. Prefix:
        ///     - Starts with an optional '~'
        ///     - Followed by a single capital letter indicating the symbol kind (for example, 'M' indicates method symbol)
        ///     - Followed by ':'
        /// 2. Core symbol ID, which is its fully qualified name before the optional parameter list and return type (i.e. before the '(' or '[' tokens)
        /// 3. Optional parameter list and/or return type that begins with a '(' or '[' tokens.
        ///
        /// For the above example, "~M:" is the prefix, "C.X" is the core symbol ID and "(System.String)" is the parameter list.
        /// </summary>
        private static bool ValidateAndSplitDocumentationCommentId(
            [NotNullWhen(true)] string? docCommentId,
            out ReadOnlyMemory<char> prefix,
            out ReadOnlyMemory<char> idPartBeforeArguments,
            out ReadOnlyMemory<char> arguments)
        {
            idPartBeforeArguments = ReadOnlyMemory<char>.Empty;
            arguments = ReadOnlyMemory<char>.Empty;
            if (!ValidateAndSplitDocumentationCommentId(docCommentId, out prefix, out var id))
            {
                return false;
            }
 
            // Parse the id part and arguments
            SplitIdAndArguments(id, out idPartBeforeArguments, out arguments);
            return true;
        }
    }
}