// 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; } } } } |