File: MetaAnalyzers\DiagnosticDescriptorCreationAnalyzer_IdRangeAndCategoryValidation.cs
Web Access
Project: src\src\RoslynAnalyzers\Microsoft.CodeAnalysis.Analyzers\Core\Microsoft.CodeAnalysis.Analyzers.csproj (Microsoft.CodeAnalysis.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.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.Analyzers.MetaAnalyzers
{
    using static CodeAnalysisDiagnosticsResources;
 
    /// <summary>
    /// RS1018 <inheritdoc cref="DiagnosticIdMustBeInSpecifiedFormatTitle"/>
    /// RS1020 <inheritdoc cref="UseCategoriesFromSpecifiedRangeTitle"/>
    /// RS1021 <inheritdoc cref="AnalyzerCategoryAndIdRangeFileInvalidTitle"/>
    /// </summary>
    public sealed partial class DiagnosticDescriptorCreationAnalyzer
    {
        private const string DiagnosticCategoryAndIdRangeFile = "DiagnosticCategoryAndIdRanges.txt";
        private static readonly (string? prefix, int start, int end) s_defaultAllowedIdsInfo = (null, -1, -1);
 
        public static readonly DiagnosticDescriptor DiagnosticIdMustBeInSpecifiedFormatRule = new(
            DiagnosticIds.DiagnosticIdMustBeInSpecifiedFormatRuleId,
            CreateLocalizableResourceString(nameof(DiagnosticIdMustBeInSpecifiedFormatTitle)),
            CreateLocalizableResourceString(nameof(DiagnosticIdMustBeInSpecifiedFormatMessage)),
            DiagnosticCategory.MicrosoftCodeAnalysisDesign,
            DiagnosticSeverity.Warning,
            isEnabledByDefault: true,
            description: CreateLocalizableResourceString(nameof(DiagnosticIdMustBeInSpecifiedFormatDescription)),
            customTags: WellKnownDiagnosticTagsExtensions.Telemetry);
 
        public static readonly DiagnosticDescriptor UseCategoriesFromSpecifiedRangeRule = new(
            DiagnosticIds.UseCategoriesFromSpecifiedRangeRuleId,
            CreateLocalizableResourceString(nameof(UseCategoriesFromSpecifiedRangeTitle)),
            CreateLocalizableResourceString(nameof(UseCategoriesFromSpecifiedRangeMessage)),
            DiagnosticCategory.MicrosoftCodeAnalysisDesign,
            DiagnosticSeverity.Warning,
            isEnabledByDefault: false,
            description: CreateLocalizableResourceString(nameof(UseCategoriesFromSpecifiedRangeDescription)),
            customTags: WellKnownDiagnosticTagsExtensions.Telemetry);
 
        public static readonly DiagnosticDescriptor AnalyzerCategoryAndIdRangeFileInvalidRule = new(
            DiagnosticIds.AnalyzerCategoryAndIdRangeFileInvalidRuleId,
            CreateLocalizableResourceString(nameof(AnalyzerCategoryAndIdRangeFileInvalidTitle)),
            CreateLocalizableResourceString(nameof(AnalyzerCategoryAndIdRangeFileInvalidMessage)),
            DiagnosticCategory.MicrosoftCodeAnalysisDesign,
            DiagnosticSeverity.Warning,
            isEnabledByDefault: true,
            description: CreateLocalizableResourceString(nameof(AnalyzerCategoryAndIdRangeFileInvalidDescription)),
            customTags: WellKnownDiagnosticTagsExtensions.Telemetry);
 
        private static void AnalyzeAllowedIdsInfoList(
            string ruleId,
            IArgumentOperation argument,
            AdditionalText? additionalText,
            string? category,
            ImmutableArray<(string? prefix, int start, int end)> allowedIdsInfoList,
            Action<Diagnostic> addDiagnostic)
        {
            RoslynDebug.Assert(!allowedIdsInfoList.IsDefaultOrEmpty);
            RoslynDebug.Assert(category != null);
            RoslynDebug.Assert(additionalText != null);
 
            var foundMatch = false;
            static bool ShouldValidateRange((string? prefix, int start, int end) range)
                => range.start >= 0 && range.end >= 0;
 
            // Check if ID matches any one of the required ranges.
            foreach (var allowedIds in allowedIdsInfoList)
            {
                RoslynDebug.Assert(allowedIds.prefix != null);
 
                if (ruleId.StartsWith(allowedIds.prefix, StringComparison.Ordinal))
                {
                    if (ShouldValidateRange(allowedIds))
                    {
                        var suffix = ruleId[allowedIds.prefix.Length..];
                        if (int.TryParse(suffix, out int ruleIdInt) &&
                            ruleIdInt >= allowedIds.start &&
                            ruleIdInt <= allowedIds.end)
                        {
                            foundMatch = true;
                            break;
                        }
                    }
                    else
                    {
                        foundMatch = true;
                        break;
                    }
                }
            }
 
            if (!foundMatch)
            {
                // Diagnostic Id '{0}' belonging to category '{1}' is not in the required range and/or format '{2}' specified in the file '{3}'.
                string arg1 = ruleId;
                string arg2 = category;
                var arg3 = new StringBuilder();
                foreach (var range in allowedIdsInfoList)
                {
                    if (arg3.Length != 0)
                    {
                        arg3.Append(", ");
                    }
 
                    if (ShouldValidateRange(range))
                    {
                        arg3.AppendFormat(CultureInfo.InvariantCulture, "{0}{1}-{0}{2}", range.prefix, range.start, range.end);
                    }
                    else
                    {
                        arg3.AppendFormat(CultureInfo.InvariantCulture, "{0}XXXX", range.prefix);
                    }
                }
 
                string arg4 = Path.GetFileName(additionalText.Path);
                var diagnostic = argument.Value.CreateDiagnostic(DiagnosticIdMustBeInSpecifiedFormatRule, arg1, arg2, arg3.ToString(), arg4);
                addDiagnostic(diagnostic);
            }
        }
 
        private static bool TryAnalyzeCategory(
            OperationAnalysisContext operationAnalysisContext,
            ImmutableArray<IArgumentOperation> creationArguments,
            bool checkCategoryAndAllowedIds,
            AdditionalText? additionalText,
            ImmutableDictionary<string, ImmutableArray<(string? prefix, int start, int end)>>? categoryAndAllowedIdsInfoMap,
            [NotNullWhen(returnValue: true)] out string? category,
            out ImmutableArray<(string? prefix, int start, int end)> allowedIdsInfoList)
        {
            category = null;
            allowedIdsInfoList = default;
            foreach (var argument in creationArguments)
            {
                if (argument.Parameter?.Name.Equals(CategoryParameterName, StringComparison.Ordinal) == true)
                {
                    // Check if the category argument is a constant or refers to a string field.
                    if (argument.Value.ConstantValue.HasValue)
                    {
                        if (argument.Value.Type != null &&
                            argument.Value.Type.SpecialType == SpecialType.System_String &&
                            argument.Value.ConstantValue.Value is string value)
                        {
                            category = value;
                        }
                    }
                    else if (argument.Value is IFieldReferenceOperation fieldReference &&
                        fieldReference.Field.Type.SpecialType == SpecialType.System_String)
                    {
                        category = fieldReference.ConstantValue.HasValue && fieldReference.ConstantValue.Value is string value ? value : fieldReference.Field.Name;
                    }
 
                    if (!checkCategoryAndAllowedIds)
                    {
                        return category != null;
                    }
 
                    // Check if the category is one of the allowed values.
                    RoslynDebug.Assert(categoryAndAllowedIdsInfoMap != null);
                    RoslynDebug.Assert(additionalText != null);
 
                    if (category != null &&
                        categoryAndAllowedIdsInfoMap.TryGetValue(category, out allowedIdsInfoList))
                    {
                        return true;
                    }
 
                    // Category '{0}' is not from the allowed categories specified in the file '{1}'.
                    string arg1 = category ?? "<unknown>";
                    string arg2 = Path.GetFileName(additionalText.Path);
                    var diagnostic = argument.Value.CreateDiagnostic(UseCategoriesFromSpecifiedRangeRule, arg1, arg2);
                    operationAnalysisContext.ReportDiagnostic(diagnostic);
                    return false;
                }
            }
 
            return false;
        }
 
        private static bool TryGetCategoryAndAllowedIdsMap(
            ImmutableArray<AdditionalText> additionalFiles,
            CancellationToken cancellationToken,
            [NotNullWhen(returnValue: true)] out AdditionalText? additionalText,
            [NotNullWhen(returnValue: true)] out ImmutableDictionary<string, ImmutableArray<(string? prefix, int start, int end)>>? categoryAndAllowedIdsMap,
            out List<Diagnostic>? invalidFileDiagnostics)
        {
            invalidFileDiagnostics = null;
            categoryAndAllowedIdsMap = null;
 
            // Parse the additional file with allowed diagnostic categories and corresponding ID range.
            // Bail out if there is no such additional file or it contains at least one invalid entry.
            additionalText = TryGetCategoryAndAllowedIdsInfoFile(additionalFiles, cancellationToken);
            return additionalText != null &&
                TryParseCategoryAndAllowedIdsInfoFile(additionalText, cancellationToken, out categoryAndAllowedIdsMap, out invalidFileDiagnostics);
        }
 
        private static AdditionalText? TryGetCategoryAndAllowedIdsInfoFile(ImmutableArray<AdditionalText> additionalFiles, CancellationToken cancellationToken)
        {
            StringComparer comparer = StringComparer.Ordinal;
            foreach (AdditionalText textFile in additionalFiles)
            {
                cancellationToken.ThrowIfCancellationRequested();
 
                string fileName = Path.GetFileName(textFile.Path);
                if (comparer.Equals(fileName, DiagnosticCategoryAndIdRangeFile))
                {
                    return textFile;
                }
            }
 
            return null;
        }
 
        private static bool TryParseCategoryAndAllowedIdsInfoFile(
            AdditionalText additionalText,
            CancellationToken cancellationToken,
            [NotNullWhen(returnValue: true)] out ImmutableDictionary<string, ImmutableArray<(string? prefix, int start, int end)>>? categoryAndAllowedIdsInfoMap,
            out List<Diagnostic>? invalidFileDiagnostics)
        {
            // Parse the additional file with allowed diagnostic categories and corresponding ID range.
            // FORMAT:
            // 'Category': Comma separate list of 'StartId-EndId' or 'Id' or 'Prefix'
 
            categoryAndAllowedIdsInfoMap = null;
            invalidFileDiagnostics = null;
 
            var builder = ImmutableDictionary.CreateBuilder<string, ImmutableArray<(string? prefix, int start, int end)>>();
            var lines = additionalText.GetTextOrEmpty(cancellationToken).Lines;
            foreach (var line in lines)
            {
                var contents = line.ToString();
                if (contents.Length == 0 || contents.StartsWith("#", StringComparison.Ordinal))
                {
                    // Ignore empty lines and comments.
                    continue;
                }
 
                var parts = contents.Split(':');
                for (int i = 0; i < parts.Length; i++)
                {
                    parts[i] = parts[i].Trim();
                }
 
                var isInvalidLine = false;
                string category = parts[0];
                if (parts.Length > 2 ||                 // We allow only 0 or 1 ':' separator in the line.
                    category.Any(char.IsWhiteSpace) ||  // We do not allow white spaces in category name.
                    builder.ContainsKey(category))      // We do not allow multiple lines with same category.
                {
                    isInvalidLine = true;
                }
                else
                {
                    if (parts.Length == 1)
                    {
                        // No ':' symbol, so the entry just specifies the category.
                        builder.Add(category, default);
                        continue;
                    }
 
                    // Entry with the following possible formats:
                    // 'Category': Comma separate list of 'StartId-EndId' or 'Id' or 'Prefix'
                    var ranges = parts[1].Split(',');
 
                    var infoList = ImmutableArray.CreateBuilder<(string? prefix, int start, int end)>(ranges.Length);
                    for (int i = 0; i < ranges.Length; i++)
                    {
                        (string? prefix, int start, int end) allowedIdsInfo = s_defaultAllowedIdsInfo;
                        string range = ranges[i].Trim();
                        if (!range.Contains('-'))
                        {
                            if (TryParseIdRangeEntry(range, out string prefix, out int start))
                            {
                                // Specific Id validation.
                                allowedIdsInfo.prefix = prefix;
                                allowedIdsInfo.start = start;
                                allowedIdsInfo.end = start;
                            }
                            else if (range.All(char.IsLetter))
                            {
                                // Only prefix validation.
                                allowedIdsInfo.prefix = range;
                            }
                            else
                            {
                                isInvalidLine = true;
                                break;
                            }
                        }
                        else
                        {
                            // Prefix and start-end range validation.
                            var rangeParts = range.Split('-');
                            if (TryParseIdRangeEntry(rangeParts[0], out string prefix1, out int start) &&
                                TryParseIdRangeEntry(rangeParts[1], out string prefix2, out int end) &&
                                prefix1.Equals(prefix2, StringComparison.Ordinal))
                            {
                                allowedIdsInfo.prefix = prefix1;
                                allowedIdsInfo.start = start;
                                allowedIdsInfo.end = end;
                            }
                            else
                            {
                                isInvalidLine = true;
                                break;
                            }
                        }
 
                        infoList.Add(allowedIdsInfo);
                    }
 
                    if (!isInvalidLine)
                    {
                        builder.Add(category, infoList.ToImmutable());
                    }
                }
 
                if (isInvalidLine)
                {
                    // Invalid entry '{0}' in analyzer category and diagnostic ID range specification file '{1}'.
                    string arg1 = contents;
                    string arg2 = Path.GetFileName(additionalText.Path);
                    LinePositionSpan linePositionSpan = lines.GetLinePositionSpan(line.Span);
                    Location location = Location.Create(additionalText.Path, line.Span, linePositionSpan);
                    invalidFileDiagnostics ??= new List<Diagnostic>();
                    var diagnostic = Diagnostic.Create(AnalyzerCategoryAndIdRangeFileInvalidRule, location, arg1, arg2);
                    invalidFileDiagnostics.Add(diagnostic);
                }
            }
 
            categoryAndAllowedIdsInfoMap = builder.ToImmutable();
            return invalidFileDiagnostics == null;
        }
 
        private static bool TryParseIdRangeEntry(string entry, out string prefix, out int suffix)
        {
            // Parse an entry for diagnostic ID.
            // We require diagnostic ID to have an alphabetical prefix followed by a numerical suffix.
            var prefixBuilder = new StringBuilder();
            suffix = -1;
            var suffixStr = new StringBuilder();
            bool seenDigit = false;
            foreach (char ch in entry)
            {
                bool isDigit = char.IsDigit(ch);
                if (seenDigit && !isDigit)
                {
                    prefix = prefixBuilder.ToString();
                    return false;
                }
 
                if (isDigit)
                {
                    suffixStr.Append(ch);
                    seenDigit = true;
                }
                else if (!char.IsLetter(ch))
                {
                    prefix = prefixBuilder.ToString();
                    return false;
                }
                else
                {
                    prefixBuilder.Append(ch);
                }
            }
 
            prefix = prefixBuilder.ToString();
            return prefix.Length > 0 && int.TryParse(suffixStr.ToString(), out suffix);
        }
    }
}