File: MetaAnalyzers\ReleaseTrackingHelper.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;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using Analyzer.Utilities;
using Analyzer.Utilities.PooledObjects;
using Microsoft.CodeAnalysis.Text;
 
#if MICROSOFT_CODEANALYSIS_ANALYZERS
using Analyzer.Utilities.Extensions;
#endif
 
namespace Microsoft.CodeAnalysis.ReleaseTracking
{
    internal static class ReleaseTrackingHelper
    {
        internal const string ShippedFileName = "AnalyzerReleases.Shipped.md";
        internal const string UnshippedFileName = "AnalyzerReleases.Unshipped.md";
        internal const string DisabledText = "Disabled";
        internal const string UndetectedText = @"`<Undetected>`";
        internal const string ReleasePrefix = "## Release";
        internal const string TableTitleNewRules = "### New Rules";
        internal const string TableTitleRemovedRules = "### Removed Rules";
        internal const string TableTitleChangedRules = "### Changed Rules";
        internal const string TableHeaderNewOrRemovedRulesLine1 = @"Rule ID | Category | Severity | Notes";
        internal const string TableHeaderNewOrRemovedRulesLine2 = @"--------|----------|----------|-------";
        internal const string TableHeaderChangedRulesLine1 = @"Rule ID | New Category | New Severity | Old Category | Old Severity | Notes";
        internal const string TableHeaderChangedRulesLine2 = @"--------|--------------|--------------|--------------|--------------|-------";
        internal const string TableHeaderNewOrRemovedRulesLine1RegexPattern = @"^\s*Rule ID\s*\|\s*Category\s*\|\s*\Severity\s*\|\s*Notes";
        internal const string TableHeaderChangedRulesLine1RegexPattern = @"^\s*Rule ID\s*\|\s*New Category\s*\|\s*New Severity\s*\|\s*Old Category\s*\|\s*Old Severity\s*\|\s*Notes";
        internal const string TableHeaderNewOrRemovedRulesLine2RegexPattern = @"^-{3,}\|-{3,}\|-{3,}\|-{3,}";
        internal const string TableHeaderChangedRulesLine2RegexPattern = @"^-{3,}\|-{3,}\|-{3,}\|-{3,}\|-{3,}\|-{3,}";
 
        internal static Version UnshippedVersion { get; } = new Version(int.MaxValue, int.MaxValue);
 
        internal static ReleaseTrackingData ReadReleaseTrackingData(
            string path,
            SourceText sourceText,
            Action<string, Version, string, SourceText, TextLine> onDuplicateEntryInRelease,
            Action<TextLine, InvalidEntryKind, string, SourceText> onInvalidEntry,
            bool isShippedFile)
        {
            var releaseTrackingDataByRulesBuilder = new Dictionary<string, ReleaseTrackingDataForRuleBuilder>();
            var currentVersion = UnshippedVersion;
            ReleaseTrackingHeaderKind? expectedHeaderKind = isShippedFile ? ReleaseTrackingHeaderKind.ReleaseHeader : ReleaseTrackingHeaderKind.TableHeaderTitle;
            ReleaseTrackingRuleEntryKind? currentRuleEntryKind = null;
            using var versionsBuilder = PooledHashSet<Version>.GetInstance();
 
            foreach (TextLine line in sourceText.Lines)
            {
                string lineText = line.ToString().Trim();
                if (string.IsNullOrWhiteSpace(lineText) || lineText.StartsWith(";", StringComparison.Ordinal))
                {
                    // Skip blank and comment lines.
                    continue;
                }
 
                // Parse release header if applicable.
                switch (expectedHeaderKind)
                {
                    case ReleaseTrackingHeaderKind.ReleaseHeader:
                        // Parse new release, if any.
                        if (lineText.StartsWith(ReleasePrefix, StringComparison.OrdinalIgnoreCase))
                        {
                            // Expect new table after this line.
                            expectedHeaderKind = ReleaseTrackingHeaderKind.TableHeaderTitle;
 
                            // Parse the release version.
                            string versionString = lineText[ReleasePrefix.Length..].Trim();
                            if (!Version.TryParse(versionString, out var version))
                            {
                                OnInvalidEntry(line, InvalidEntryKind.Header);
                                return ReleaseTrackingData.Default;
                            }
                            else
                            {
                                currentVersion = version;
                                versionsBuilder.Add(version);
                            }
 
                            continue;
                        }
 
                        OnInvalidEntry(line, InvalidEntryKind.Header);
                        return ReleaseTrackingData.Default;
 
                    case ReleaseTrackingHeaderKind.TableHeaderTitle:
                        if (lineText.StartsWith(TableTitleNewRules, StringComparison.OrdinalIgnoreCase))
                        {
                            expectedHeaderKind = ReleaseTrackingHeaderKind.TableHeaderNewOrRemovedRulesLine1;
                            currentRuleEntryKind = ReleaseTrackingRuleEntryKind.New;
                        }
                        else if (lineText.StartsWith(TableTitleRemovedRules, StringComparison.OrdinalIgnoreCase))
                        {
                            expectedHeaderKind = ReleaseTrackingHeaderKind.TableHeaderNewOrRemovedRulesLine1;
                            currentRuleEntryKind = ReleaseTrackingRuleEntryKind.Removed;
                        }
                        else if (lineText.StartsWith(TableTitleChangedRules, StringComparison.OrdinalIgnoreCase))
                        {
                            expectedHeaderKind = ReleaseTrackingHeaderKind.TableHeaderChangedRulesLine1;
                            currentRuleEntryKind = ReleaseTrackingRuleEntryKind.Changed;
                        }
                        else
                        {
                            OnInvalidEntry(line, InvalidEntryKind.Header);
                            return ReleaseTrackingData.Default;
                        }
 
                        continue;
 
                    case ReleaseTrackingHeaderKind.TableHeaderNewOrRemovedRulesLine1:
                        if (Regex.IsMatch(lineText, TableHeaderNewOrRemovedRulesLine1RegexPattern, RegexOptions.IgnoreCase))
                        {
                            expectedHeaderKind = ReleaseTrackingHeaderKind.TableHeaderNewOrRemovedRulesLine2;
                            continue;
                        }
 
                        OnInvalidEntry(line, InvalidEntryKind.Header);
                        return ReleaseTrackingData.Default;
 
                    case ReleaseTrackingHeaderKind.TableHeaderNewOrRemovedRulesLine2:
                        expectedHeaderKind = null;
                        if (Regex.IsMatch(lineText, TableHeaderNewOrRemovedRulesLine2RegexPattern, RegexOptions.IgnoreCase))
                        {
                            continue;
                        }
 
                        OnInvalidEntry(line, InvalidEntryKind.Header);
                        return ReleaseTrackingData.Default;
 
                    case ReleaseTrackingHeaderKind.TableHeaderChangedRulesLine1:
                        if (Regex.IsMatch(lineText, TableHeaderChangedRulesLine1RegexPattern, RegexOptions.IgnoreCase))
                        {
                            expectedHeaderKind = ReleaseTrackingHeaderKind.TableHeaderChangedRulesLine2;
                            continue;
                        }
 
                        OnInvalidEntry(line, InvalidEntryKind.Header);
                        return ReleaseTrackingData.Default;
 
                    case ReleaseTrackingHeaderKind.TableHeaderChangedRulesLine2:
                        expectedHeaderKind = null;
                        if (Regex.IsMatch(lineText, TableHeaderChangedRulesLine2RegexPattern, RegexOptions.IgnoreCase))
                        {
                            continue;
                        }
 
                        OnInvalidEntry(line, InvalidEntryKind.Header);
                        return ReleaseTrackingData.Default;
 
                    default:
                        // We might be starting a new release or table.
                        if (lineText.StartsWith("## ", StringComparison.OrdinalIgnoreCase))
                        {
                            goto case ReleaseTrackingHeaderKind.ReleaseHeader;
                        }
                        else if (lineText.StartsWith("### ", StringComparison.OrdinalIgnoreCase))
                        {
                            goto case ReleaseTrackingHeaderKind.TableHeaderTitle;
                        }
 
                        break;
                }
 
                RoslynDebug.Assert(currentRuleEntryKind != null);
 
                var parts = lineText.Split('|').Select(s => s.Trim()).ToArray();
                if (IsInvalidEntry(parts, currentRuleEntryKind.Value))
                {
                    // Report invalid entry, but continue parsing remaining entries.
                    OnInvalidEntry(line, InvalidEntryKind.Other);
                    continue;
                }
 
                //  New or Removed rule entry:
                //      "Rule ID | Category | Severity | Notes"
                //      "   0    |     1    |    2     |        3           "
                //
                //  Changed rule entry:
                //      "Rule ID | New Category | New Severity | Old Category | Old Severity | Notes"
                //      "   0    |     1        |     2        |     3        |     4        |        5           "
 
                string ruleId = parts[0];
 
                InvalidEntryKind? invalidEntryKind = TryParseFields(parts, categoryIndex: 1, severityIndex: 2,
                    out var category, out var defaultSeverity, out var enabledByDefault);
                if (invalidEntryKind.HasValue)
                {
                    OnInvalidEntry(line, invalidEntryKind.Value);
                }
 
                ReleaseTrackingLine releaseTrackingLine;
                if (currentRuleEntryKind.Value == ReleaseTrackingRuleEntryKind.Changed)
                {
                    invalidEntryKind = TryParseFields(parts, categoryIndex: 3, severityIndex: 4,
                        out var oldCategory, out var oldDefaultSeverity, out var oldEnabledByDefault);
                    if (invalidEntryKind.HasValue)
                    {
                        OnInvalidEntry(line, invalidEntryKind.Value);
                    }
 
                    // Verify at least one field is changed for the entry:
                    if (string.Equals(category, oldCategory, StringComparison.OrdinalIgnoreCase) &&
                        defaultSeverity == oldDefaultSeverity &&
                        enabledByDefault == oldEnabledByDefault)
                    {
                        OnInvalidEntry(line, InvalidEntryKind.Other);
                        return ReleaseTrackingData.Default;
                    }
 
                    releaseTrackingLine = new ChangedRuleReleaseTrackingLine(ruleId,
                        category, enabledByDefault, defaultSeverity,
                        oldCategory, oldEnabledByDefault, oldDefaultSeverity,
                        line.Span, sourceText, path, isShippedFile);
                }
                else
                {
                    releaseTrackingLine = new NewOrRemovedRuleReleaseTrackingLine(ruleId,
                        category, enabledByDefault, defaultSeverity, line.Span, sourceText,
                        path, isShippedFile, currentRuleEntryKind.Value);
                }
 
                if (!releaseTrackingDataByRulesBuilder.TryGetValue(ruleId, out var releaseTrackingDataForRuleBuilder))
                {
                    releaseTrackingDataForRuleBuilder = new ReleaseTrackingDataForRuleBuilder();
                    releaseTrackingDataByRulesBuilder.Add(ruleId, releaseTrackingDataForRuleBuilder);
                }
 
                releaseTrackingDataForRuleBuilder.AddEntry(currentVersion, releaseTrackingLine, out var hasExistingEntry);
                if (hasExistingEntry)
                {
                    onDuplicateEntryInRelease(ruleId, currentVersion, path, sourceText, line);
                }
            }
 
            var builder = ImmutableSortedDictionary.CreateBuilder<string, ReleaseTrackingDataForRule>();
            foreach (var (ruleId, value) in releaseTrackingDataByRulesBuilder)
            {
                var releaseTrackingDataForRule = new ReleaseTrackingDataForRule(ruleId, value);
                builder.Add(ruleId, releaseTrackingDataForRule);
            }
 
            return new ReleaseTrackingData(builder.ToImmutable(), versionsBuilder.ToImmutable());
 
            // Local functions
            void OnInvalidEntry(TextLine line, InvalidEntryKind invalidEntryKind)
                => onInvalidEntry(line, invalidEntryKind, path, sourceText);
 
            static bool IsInvalidEntry(string[] parts, ReleaseTrackingRuleEntryKind currentRuleEntryKind)
            {
                // Expected entry for New or Removed rules has 3 or 4 parts:
                //      "Rule ID | Category | Severity | Notes"
                //
                // Expected entry for Changed rules has 5 or 6 parts:
                //      "Rule ID | New Category | New Severity | Old Category | Old Severity | Notes"
                //
                // NOTE: Last field 'Helplink' is optional for both cases.
 
                if (parts.Length is < 3 or > 6)
                {
                    return true;
                }
 
                return currentRuleEntryKind switch
                {
                    ReleaseTrackingRuleEntryKind.New => parts.Length > 4,
                    ReleaseTrackingRuleEntryKind.Removed => parts.Length > 4,
                    ReleaseTrackingRuleEntryKind.Changed => parts.Length <= 4,
                    _ => throw new NotImplementedException()
                };
            }
 
            static InvalidEntryKind? TryParseFields(
                string[] parts, int categoryIndex, int severityIndex,
                out string category,
                out DiagnosticSeverity? defaultSeverity,
                out bool? enabledByDefault)
            {
                InvalidEntryKind? invalidEntryKind = null;
 
                category = parts[categoryIndex];
                if (category.Equals(UndetectedText, StringComparison.OrdinalIgnoreCase))
                {
                    invalidEntryKind = InvalidEntryKind.UndetectedField;
                }
 
                var severityPart = parts[severityIndex];
                if (Enum.TryParse(severityPart, ignoreCase: true, out DiagnosticSeverity parsedSeverity))
                {
                    defaultSeverity = parsedSeverity;
                    enabledByDefault = true;
                }
                else
                {
                    defaultSeverity = null;
                    if (string.Equals(severityPart, DisabledText, StringComparison.OrdinalIgnoreCase))
                    {
                        enabledByDefault = false;
                    }
                    else if (severityPart.Equals(UndetectedText, StringComparison.OrdinalIgnoreCase))
                    {
                        enabledByDefault = null;
                        invalidEntryKind = InvalidEntryKind.UndetectedField;
                    }
                    else
                    {
                        enabledByDefault = null;
                        invalidEntryKind = InvalidEntryKind.Other;
                    }
                }
 
                return invalidEntryKind;
            }
        }
    }
 
    internal enum InvalidEntryKind
    {
        Header,
        UndetectedField,
        Other
    }
 
#pragma warning disable CA1815 // Override equals and operator equals on value types
    internal sealed class ReleaseTrackingData
#pragma warning restore CA1815 // Override equals and operator equals on value types
    {
        public static readonly ReleaseTrackingData Default = new();
        public ImmutableSortedDictionary<string, ReleaseTrackingDataForRule> ReleaseTrackingDataByRuleIdMap { get; }
        public ImmutableHashSet<Version> Versions { get; }
 
        private ReleaseTrackingData()
            : this(ImmutableSortedDictionary<string, ReleaseTrackingDataForRule>.Empty, ImmutableHashSet<Version>.Empty)
        {
        }
 
        internal ReleaseTrackingData(ImmutableSortedDictionary<string, ReleaseTrackingDataForRule> releaseTrackingDataByRuleIdMap, ImmutableHashSet<Version> versions)
        {
            ReleaseTrackingDataByRuleIdMap = releaseTrackingDataByRuleIdMap;
            Versions = versions;
        }
 
        public bool TryGetLatestReleaseTrackingLine(
            string ruleId,
            [NotNullWhen(returnValue: true)] out Version? version,
            [NotNullWhen(returnValue: true)] out ReleaseTrackingLine? releaseTrackingLine)
        => TryGetLatestReleaseTrackingLineCore(ruleId, maxVersion: null, out version, out releaseTrackingLine);
 
        public bool TryGetLatestReleaseTrackingLine(
            string ruleId,
            Version maxVersion,
            [NotNullWhen(returnValue: true)] out Version? version,
            [NotNullWhen(returnValue: true)] out ReleaseTrackingLine? releaseTrackingLine)
        => TryGetLatestReleaseTrackingLineCore(ruleId, maxVersion, out version, out releaseTrackingLine);
 
        private bool TryGetLatestReleaseTrackingLineCore(
            string ruleId,
            Version? maxVersion,
            [NotNullWhen(returnValue: true)] out Version? version,
            [NotNullWhen(returnValue: true)] out ReleaseTrackingLine? releaseTrackingLine)
        {
            version = null;
            releaseTrackingLine = null;
            if (!ReleaseTrackingDataByRuleIdMap.TryGetValue(ruleId, out var releaseTrackingDataForRule) ||
                releaseTrackingDataForRule.ReleasesByVersionMap.IsEmpty)
            {
                return false;
            }
 
            foreach (var (releaseVersion, releaseLine) in releaseTrackingDataForRule.ReleasesByVersionMap)
            {
                if (maxVersion == null || releaseVersion <= maxVersion)
                {
                    version = releaseVersion;
                    releaseTrackingLine = releaseLine;
                    return true;
                }
            }
 
            return false;
        }
    }
 
#pragma warning disable CA1815 // Override equals and operator equals on value types
    internal readonly struct ReleaseTrackingDataForRule
#pragma warning restore CA1815 // Override equals and operator equals on value types
    {
        public string RuleId { get; }
        public ImmutableSortedDictionary<Version, ReleaseTrackingLine> ReleasesByVersionMap { get; }
 
        internal ReleaseTrackingDataForRule(string ruleId, ReleaseTrackingDataForRuleBuilder builder)
        {
            RuleId = ruleId;
            ReleasesByVersionMap = builder.ToImmutable();
        }
    }
 
    internal sealed class ReleaseTrackingDataForRuleBuilder
    {
        private readonly ImmutableSortedDictionary<Version, ReleaseTrackingLine>.Builder _builder
            = ImmutableSortedDictionary.CreateBuilder<Version, ReleaseTrackingLine>(ReverseComparer.Instance);
 
        public void AddEntry(Version version, ReleaseTrackingLine releaseTrackingLine, out bool hasExistingEntry)
        {
            hasExistingEntry = _builder.ContainsKey(version);
            _builder[version] = releaseTrackingLine;
        }
 
        public ImmutableSortedDictionary<Version, ReleaseTrackingLine> ToImmutable()
            => _builder.ToImmutable();
 
        private sealed class ReverseComparer : IComparer<Version>
        {
            public static readonly IComparer<Version> Instance = new ReverseComparer();
            private ReverseComparer() { }
 
            public int Compare([AllowNull] Version x, [AllowNull] Version y)
            {
                return (x?.CompareTo(y)).GetValueOrDefault() * -1;
            }
        }
    }
 
    internal abstract class ReleaseTrackingLine
    {
        public string RuleId { get; }
        public string Category { get; }
        public bool? EnabledByDefault { get; }
        public DiagnosticSeverity? DefaultSeverity { get; }
 
        public TextSpan Span { get; }
        public SourceText SourceText { get; }
        public string Path { get; }
        public bool IsShipped { get; }
        public bool IsRemovedRule => Kind == ReleaseTrackingRuleEntryKind.Removed;
        public ReleaseTrackingRuleEntryKind Kind { get; }
 
        internal static ReleaseTrackingLine Create(
            string ruleId, string category, bool? enabledByDefault,
            DiagnosticSeverity? defaultSeverity,
            TextSpan span, SourceText sourceText,
            string path, bool isShipped, ReleaseTrackingRuleEntryKind kind)
        {
            return new NewOrRemovedRuleReleaseTrackingLine(ruleId, category,
                enabledByDefault, defaultSeverity, span, sourceText, path, isShipped, kind);
        }
 
        protected ReleaseTrackingLine(string ruleId, string category, bool? enabledByDefault,
            DiagnosticSeverity? defaultSeverity,
            TextSpan span, SourceText sourceText,
            string path, bool isShipped, ReleaseTrackingRuleEntryKind kind)
        {
            RuleId = ruleId;
            Category = category;
            EnabledByDefault = enabledByDefault;
            DefaultSeverity = defaultSeverity;
            Span = span;
            SourceText = sourceText;
            Path = path;
            IsShipped = isShipped;
            Kind = kind;
        }
    }
 
    internal sealed class NewOrRemovedRuleReleaseTrackingLine : ReleaseTrackingLine
    {
        internal NewOrRemovedRuleReleaseTrackingLine(
            string ruleId, string category, bool? enabledByDefault,
            DiagnosticSeverity? defaultSeverity,
            TextSpan span, SourceText sourceText,
            string path, bool isShipped, ReleaseTrackingRuleEntryKind kind)
            : base(ruleId, category, enabledByDefault, defaultSeverity, span, sourceText, path, isShipped, kind)
        {
            Debug.Assert(kind is ReleaseTrackingRuleEntryKind.New or ReleaseTrackingRuleEntryKind.Removed);
        }
    }
 
    internal sealed class ChangedRuleReleaseTrackingLine : ReleaseTrackingLine
    {
        public string OldCategory { get; }
        public bool? OldEnabledByDefault { get; }
        public DiagnosticSeverity? OldDefaultSeverity { get; }
 
        internal ChangedRuleReleaseTrackingLine(
            string ruleId, string category, bool? enabledByDefault,
            DiagnosticSeverity? defaultSeverity,
            string oldCategory, bool? oldEnabledByDefault,
            DiagnosticSeverity? oldDefaultSeverity,
            TextSpan span, SourceText sourceText,
            string path, bool isShipped)
            : base(ruleId, category, enabledByDefault, defaultSeverity, span, sourceText, path, isShipped, ReleaseTrackingRuleEntryKind.Changed)
        {
            OldCategory = oldCategory;
            OldEnabledByDefault = oldEnabledByDefault;
            OldDefaultSeverity = oldDefaultSeverity;
        }
    }
 
    internal enum ReleaseTrackingHeaderKind
    {
        ReleaseHeader,
        TableHeaderTitle,
        TableHeaderNewOrRemovedRulesLine1,
        TableHeaderNewOrRemovedRulesLine2,
        TableHeaderChangedRulesLine1,
        TableHeaderChangedRulesLine2,
    }
 
    internal enum ReleaseTrackingRuleEntryKind
    {
        New,
        Removed,
        Changed,
    }
}