File: src\RoslynAnalyzers\Utilities\Compiler\Options\SymbolNamesWithValueOption.cs
Web Access
Project: src\src\RoslynAnalyzers\Roslyn.Diagnostics.Analyzers\Core\Roslyn.Diagnostics.Analyzers.csproj (Roslyn.Diagnostics.Analyzers)
// 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.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Analyzer.Utilities.Extensions;
using Analyzer.Utilities.PooledObjects;
using Microsoft.CodeAnalysis;
 
namespace Analyzer.Utilities
{
#if !TEST_UTILITIES
    public sealed class SymbolNamesWithValueOption<TValue>
#else
    internal sealed class SymbolNamesWithValueOption<TValue>
#endif
     : IEquatable<SymbolNamesWithValueOption<TValue>?>
    {
        internal const SymbolKind AllKinds = SymbolKind.ErrorType;
        internal const char WildcardChar = '*';
 
        public static readonly SymbolNamesWithValueOption<TValue> Empty = new();
 
        private readonly ImmutableDictionary<string, TValue> _names;
        private readonly ImmutableDictionary<ISymbol, TValue> _symbols;
 
        /// <summary>
        /// Dictionary holding per symbol kind the wildcard entry with its suffix.
        /// The implementation only supports the following SymbolKind: Namespace, Type, Event, Field, Method, Property and ErrorType (as a way to hold the non-fully qualified types).
        /// </summary>
        /// <example>
        /// ErrorType ->
        ///     Symbol* -> "some value"
        /// Namespace ->
        ///     Analyzer.Utilities -> ""
        /// Type ->
        ///     Analyzer.Utilities.SymbolNamesWithValueOption -> ""
        /// Event ->
        ///     Analyzer.Utilities.SymbolNamesWithValueOption.MyEvent -> ""
        /// Field ->
        ///     Analyzer.Utilities.SymbolNamesWithValueOption.myField -> ""
        /// Method ->
        ///     Analyzer.Utilities.SymbolNamesWithValueOption.MyMethod() -> ""
        /// Property ->
        ///     Analyzer.Utilities.SymbolNamesWithValueOption.MyProperty -> ""
        /// </example>
        private readonly ImmutableDictionary<SymbolKind, ImmutableDictionary<string, TValue>> _wildcardNamesBySymbolKind;
 
        /// <summary>
        /// Cache for the wildcard matching algorithm. The current implementation can be slow so we want to make sure that once a match is performed we save its result.
        /// </summary>
        private readonly ConcurrentDictionary<ISymbol, KeyValuePair<string?, TValue?>> _wildcardMatchResult = new();
 
        private readonly ConcurrentDictionary<ISymbol, string> _symbolToDeclarationId = new();
 
        private SymbolNamesWithValueOption(ImmutableDictionary<string, TValue> names, ImmutableDictionary<ISymbol, TValue> symbols,
            ImmutableDictionary<SymbolKind, ImmutableDictionary<string, TValue>> wildcardNamesBySymbolKind)
        {
            Debug.Assert(!names.IsEmpty || !symbols.IsEmpty || !wildcardNamesBySymbolKind.IsEmpty);
 
            _names = names;
            _symbols = symbols;
            _wildcardNamesBySymbolKind = wildcardNamesBySymbolKind;
        }
 
        private SymbolNamesWithValueOption()
        {
            _names = ImmutableDictionary<string, TValue>.Empty;
            _symbols = ImmutableDictionary<ISymbol, TValue>.Empty;
            _wildcardNamesBySymbolKind = ImmutableDictionary<SymbolKind, ImmutableDictionary<string, TValue>>.Empty;
        }
 
#pragma warning disable CA1000 // Do not declare static members on generic types
        public static SymbolNamesWithValueOption<TValue> Create(ImmutableArray<string> symbolNames, Compilation compilation, string? optionalPrefix,
#pragma warning restore CA1000 // Do not declare static members on generic types
            Func<string, NameParts> getSymbolNamePartsFunc)
        {
            if (symbolNames.IsEmpty)
            {
                return Empty;
            }
 
            var namesBuilder = PooledDictionary<string, TValue>.GetInstance();
            var symbolsBuilder = PooledDictionary<ISymbol, TValue>.GetInstance();
            var wildcardNamesBuilder = PooledDictionary<SymbolKind, PooledDictionary<string, TValue>>.GetInstance();
 
            foreach (var symbolName in symbolNames)
            {
                var parts = getSymbolNamePartsFunc(symbolName);
 
                var numberOfWildcards = parts.SymbolName.Count(c => c == WildcardChar);
 
                // More than one wildcard, bail-out.
                if (numberOfWildcards > 1)
                {
                    continue;
                }
 
                // Wildcard is not last or is the only char, bail-out
                if (numberOfWildcards == 1 &&
                    (parts.SymbolName[^1] != WildcardChar ||
                    parts.SymbolName.Length == 1))
                {
                    continue;
                }
 
                if (numberOfWildcards == 1)
                {
                    ProcessWildcardName(parts, wildcardNamesBuilder);
                }
#pragma warning disable CA1847 // Use 'string.Contains(char)' instead of 'string.Contains(string)' when searching for a single character
                else if (parts.SymbolName.Equals(".ctor", StringComparison.Ordinal) ||
                    parts.SymbolName.Equals(".cctor", StringComparison.Ordinal) ||
                    !parts.SymbolName.Contains(".", StringComparison.Ordinal) && !parts.SymbolName.Contains(":", StringComparison.Ordinal))
                {
                    ProcessName(parts, namesBuilder);
                }
                else
                {
                    ProcessSymbolName(parts, compilation, optionalPrefix, symbolsBuilder);
                }
#pragma warning restore CA1847 // Use 'string.Contains(char)' instead of 'string.Contains(string)' when searching for a single character
            }
 
            if (namesBuilder.Count == 0 && symbolsBuilder.Count == 0 && wildcardNamesBuilder.Count == 0)
            {
                return Empty;
            }
 
            return new SymbolNamesWithValueOption<TValue>(namesBuilder.ToImmutableDictionaryAndFree(),
                symbolsBuilder.ToImmutableDictionaryAndFree(),
                wildcardNamesBuilder.ToImmutableDictionaryAndFree(x => x.Key, x => x.Value.ToImmutableDictionaryAndFree(), wildcardNamesBuilder.Comparer));
 
            // Local functions
            static void ProcessWildcardName(NameParts parts, PooledDictionary<SymbolKind, PooledDictionary<string, TValue>> wildcardNamesBuilder)
            {
                Debug.Assert(parts.SymbolName[^1] == WildcardChar);
                Debug.Assert(parts.SymbolName.Length >= 2);
 
                if (parts.SymbolName[1] != ':')
                {
                    if (!wildcardNamesBuilder.TryGetValue(AllKinds, out var associatedValues))
                    {
                        associatedValues = PooledDictionary<string, TValue>.GetInstance();
                        wildcardNamesBuilder.Add(AllKinds, associatedValues);
                    }
 
                    associatedValues.Add(parts.SymbolName[0..^1], parts.AssociatedValue);
                    return;
                }
 
                var symbolKind = parts.SymbolName[0] switch
                {
                    'E' => (SymbolKind?)SymbolKind.Event,
                    'F' => SymbolKind.Field,
                    'M' => SymbolKind.Method,
                    'N' => SymbolKind.Namespace,
                    'P' => SymbolKind.Property,
                    'T' => SymbolKind.NamedType,
                    _ => null,
                };
 
                if (symbolKind != null)
                {
                    if (!wildcardNamesBuilder.TryGetValue(symbolKind.Value, out var associatedValues))
                    {
                        associatedValues = PooledDictionary<string, TValue>.GetInstance();
                        wildcardNamesBuilder.Add(symbolKind.Value, associatedValues);
                    }
 
                    associatedValues.Add(parts.SymbolName[2..^1], parts.AssociatedValue);
                }
            }
 
            static void ProcessName(NameParts parts, PooledDictionary<string, TValue> namesBuilder)
            {
                if (!namesBuilder.ContainsKey(parts.SymbolName))
                {
                    namesBuilder.Add(parts.SymbolName, parts.AssociatedValue);
                }
            }
 
            static void ProcessSymbolName(NameParts parts, Compilation compilation, string? optionalPrefix, PooledDictionary<ISymbol, TValue> symbolsBuilder)
            {
                var nameWithPrefix = (string.IsNullOrEmpty(optionalPrefix) || parts.SymbolName.StartsWith(optionalPrefix, StringComparison.Ordinal))
                    ? parts.SymbolName
                    : optionalPrefix + parts.SymbolName;
 
                // Documentation comment ID for constructors uses '#ctor', but '#' is a comment start token for editorconfig.
                // We instead search for a '..ctor' in editorconfig and replace it with a '.#ctor' here.
                // Similarly, handle static constructors ".cctor"
                nameWithPrefix = nameWithPrefix.Replace("..ctor", ".#ctor", StringComparison.Ordinal);
                nameWithPrefix = nameWithPrefix.Replace("..cctor", ".#cctor", StringComparison.Ordinal);
 
                foreach (var symbol in DocumentationCommentId.GetSymbolsForDeclarationId(nameWithPrefix, compilation))
                {
                    if (symbol == null)
                    {
                        continue;
                    }
 
                    if (symbol is INamespaceSymbol namespaceSymbol &&
                        namespaceSymbol.ConstituentNamespaces.Length > 1)
                    {
                        foreach (var constituentNamespace in namespaceSymbol.ConstituentNamespaces)
                        {
                            if (!symbolsBuilder.ContainsKey(constituentNamespace))
                            {
                                symbolsBuilder.Add(constituentNamespace, parts.AssociatedValue);
                            }
                        }
                    }
 
                    if (!symbolsBuilder.ContainsKey(symbol))
                    {
                        symbolsBuilder.Add(symbol, parts.AssociatedValue);
                    }
                }
            }
        }
 
        public bool IsEmpty => ReferenceEquals(this, Empty);
 
        public bool Contains(ISymbol symbol)
            => _symbols.ContainsKey(symbol) || _names.ContainsKey(symbol.Name) || TryGetFirstWildcardMatch(symbol, out _, out _);
 
        /// <summary>
        /// Gets the value associated with the specified symbol in the option specification.
        /// </summary>
        public bool TryGetValue(ISymbol symbol, [MaybeNullWhen(false)] out TValue value)
        {
            if (_symbols.TryGetValue(symbol, out value) || _names.TryGetValue(symbol.Name, out value))
            {
                return true;
            }
 
            if (TryGetFirstWildcardMatch(symbol, out _, out value))
            {
                return true;
            }
 
            value = default;
            return false;
        }
 
        public override bool Equals(object? obj) => Equals(obj as SymbolNamesWithValueOption<TValue>);
 
        public bool Equals(SymbolNamesWithValueOption<TValue>? other)
            => other != null && _names.IsEqualTo(other._names) && _symbols.IsEqualTo(other._symbols) && _wildcardNamesBySymbolKind.IsEqualTo(other._wildcardNamesBySymbolKind);
 
        public override int GetHashCode()
        {
            var hashCode = new RoslynHashCode();
            HashUtilities.Combine(_names, ref hashCode);
            HashUtilities.Combine(_symbols, ref hashCode);
            HashUtilities.Combine(_wildcardNamesBySymbolKind, ref hashCode);
            return hashCode.ToHashCode();
        }
 
        private bool TryGetFirstWildcardMatch(ISymbol symbol, [NotNullWhen(true)] out string? firstMatchName, [MaybeNullWhen(false)] out TValue firstMatchValue)
        {
            switch (symbol.Kind)
            {
                case SymbolKind.Event:
                case SymbolKind.Field:
                case SymbolKind.Method:
                case SymbolKind.NamedType:
                case SymbolKind.Namespace:
                case SymbolKind.Property:
                    break;
 
                case SymbolKind.Assembly:
                case SymbolKind.ErrorType:
                case SymbolKind.NetModule:
                    firstMatchName = null;
                    firstMatchValue = default;
                    return false;
 
                default:
                    throw new ArgumentException($"Unsupported symbol kind '{symbol.Kind}' for symbol '{symbol}'");
            }
 
            // No wildcard entry, let's bail-out
            if (_wildcardNamesBySymbolKind.IsEmpty)
            {
                firstMatchName = null;
                firstMatchValue = default;
                return false;
            }
 
            // The matching was already processed, use cached result
            if (_wildcardMatchResult.TryGetValue(symbol, out var firstMatch))
            {
                (firstMatchName, firstMatchValue) = firstMatch;
#pragma warning disable CS8762 // Parameter 'firstMatchValue' must have a non-null value when exiting with 'true'
                return firstMatchName is not null;
#pragma warning restore CS8762 // Parameter 'firstMatchValue' must have a non-null value when exiting with 'true'
            }
 
            var symbolDeclarationId = _symbolToDeclarationId.GetOrAdd(symbol, GetDeclarationId);
 
            // We start by trying to match with the most precise definition (prefix)...
            if (_wildcardNamesBySymbolKind.TryGetValue(symbol.Kind, out var names) &&
                names.FirstOrDefault(kvp => symbolDeclarationId.StartsWith(kvp.Key, StringComparison.Ordinal)) is var prefixedFirstMatchOrDefault &&
                !string.IsNullOrWhiteSpace(prefixedFirstMatchOrDefault.Key))
            {
                (firstMatchName, firstMatchValue) = prefixedFirstMatchOrDefault;
                _wildcardMatchResult.AddOrUpdate(symbol, prefixedFirstMatchOrDefault.AsNullable(), (s, match) => prefixedFirstMatchOrDefault.AsNullable());
                return true;
            }
 
            // If not found, then we try to match with the symbol full declaration ID...
            if (_wildcardNamesBySymbolKind.TryGetValue(AllKinds, out var value) &&
                value.FirstOrDefault(kvp => symbolDeclarationId.StartsWith(kvp.Key, StringComparison.Ordinal)) is var unprefixedFirstMatchOrDefault &&
                !string.IsNullOrWhiteSpace(unprefixedFirstMatchOrDefault.Key))
            {
                (firstMatchName, firstMatchValue) = unprefixedFirstMatchOrDefault;
                _wildcardMatchResult.AddOrUpdate(symbol, unprefixedFirstMatchOrDefault.AsNullable(), (s, match) => unprefixedFirstMatchOrDefault.AsNullable());
                return true;
            }
 
            // If not found, then we try to match with the symbol name...
            if (_wildcardNamesBySymbolKind.TryGetValue(AllKinds, out var allKindsValue) &&
                allKindsValue.FirstOrDefault(kvp => symbol.Name.StartsWith(kvp.Key, StringComparison.Ordinal)) is var partialFirstMatchOrDefault &&
                !string.IsNullOrWhiteSpace(partialFirstMatchOrDefault.Key))
            {
                (firstMatchName, firstMatchValue) = partialFirstMatchOrDefault;
                _wildcardMatchResult.AddOrUpdate(symbol, partialFirstMatchOrDefault.AsNullable(), (s, match) => partialFirstMatchOrDefault.AsNullable());
                return true;
            }
 
            // Nothing was found
            firstMatchName = null;
            firstMatchValue = default;
            _wildcardMatchResult.AddOrUpdate(symbol, new KeyValuePair<string?, TValue?>(null, default), (s, match) => new KeyValuePair<string?, TValue?>(null, default));
            return false;
 
            static string GetDeclarationId(ISymbol symbol)
            {
                var declarationIdWithoutPrefix = DocumentationCommentId.CreateDeclarationId(symbol)[2..];
 
                // Documentation comment ID for constructors uses '#ctor', but '#' is a comment start token for editorconfig.
                declarationIdWithoutPrefix = declarationIdWithoutPrefix
                    .Replace(".#ctor", "..ctor", StringComparison.Ordinal)
                    .Replace(".#cctor", "..cctor", StringComparison.Ordinal);
 
                return declarationIdWithoutPrefix;
            }
        }
 
        internal TestAccessor GetTestAccessor()
        {
            return new TestAccessor(this);
        }
 
        [SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "Does not apply to test accessors")]
        internal readonly struct TestAccessor
        {
            private readonly SymbolNamesWithValueOption<TValue> _symbolNamesWithValueOption;
 
            internal TestAccessor(SymbolNamesWithValueOption<TValue> symbolNamesWithValueOption)
            {
                _symbolNamesWithValueOption = symbolNamesWithValueOption;
            }
 
            internal ref readonly ImmutableDictionary<string, TValue> Names => ref _symbolNamesWithValueOption._names;
 
            internal ref readonly ImmutableDictionary<ISymbol, TValue> Symbols => ref _symbolNamesWithValueOption._symbols;
 
            internal ref readonly ImmutableDictionary<SymbolKind, ImmutableDictionary<string, TValue>> WildcardNamesBySymbolKind => ref _symbolNamesWithValueOption._wildcardNamesBySymbolKind;
 
            internal ref readonly ConcurrentDictionary<ISymbol, KeyValuePair<string?, TValue?>> WildcardMatchResult => ref _symbolNamesWithValueOption._wildcardMatchResult;
 
            internal ref readonly ConcurrentDictionary<ISymbol, string> SymbolToDeclarationId => ref _symbolNamesWithValueOption._symbolToDeclarationId;
        }
 
        /// <summary>
        /// Represents the two parts of a symbol name option when the symbol name is tighted to some specific value.
        /// This allows to link a value to a symbol while following the symbol's documentation ID format.
        /// </summary>
        /// <example>
        /// On the rule CA1710, we allow user specific suffix to be registered for symbol names using the following format:
        /// MyClass->Suffix or T:MyNamespace.MyClass->Suffix or N:MyNamespace->Suffix.
        /// </example>
#pragma warning disable CA1034 // Nested types should not be visible
        public sealed class NameParts
#pragma warning restore CA1034 // Nested types should not be visible
        {
            public NameParts(string symbolName, TValue associatedValue)
            {
                SymbolName = symbolName.Trim();
                AssociatedValue = associatedValue;
            }
 
            public string SymbolName { get; }
            public TValue AssociatedValue { get; }
        }
    }
}