File: CodeFixes\Configuration\ConfigurationUpdater.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// 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.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeFixes.Suppression;
using Microsoft.CodeAnalysis.CodeStyle;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CodeFixes.Configuration;
 
/// <summary>
/// Helper class to configure diagnostic severity or code style option value based on .editorconfig file
/// </summary>
internal sealed partial class ConfigurationUpdater
{
    private enum ConfigurationKind
    {
        OptionValue,
        Severity,
        BulkConfigure
    }
 
    private const string DiagnosticOptionPrefix = "dotnet_diagnostic.";
    private const string SeveritySuffix = ".severity";
    private const string BulkConfigureAllAnalyzerDiagnosticsOptionKey = "dotnet_analyzer_diagnostic.severity";
    private const string BulkConfigureAnalyzerDiagnosticsByCategoryOptionPrefix = "dotnet_analyzer_diagnostic.category-";
    private const string AllAnalyzerDiagnosticsCategory = "";
 
    // Regular expression for .editorconfig header.
    // For example: "[*.cs]    # Optional comment"
    //              "[*.{vb,cs}]"
    //              "[*]    ; Optional comment"
    //              "[ConsoleApp/Program.cs]"
    private static readonly Regex s_headerPattern = new(@"\[(\*|[^ #;\[\]]+\.({[^ #;{}\.\[\]]+}|[^ #;{}\.\[\]]+))\]\s*([#;].*)?");
 
    // Regular expression for .editorconfig code style option entry.
    // For example:
    //  1. "dotnet_style_object_initializer = true   # Optional comment"
    //  2. "dotnet_style_object_initializer = true:suggestion   ; Optional comment"
    //  3. "dotnet_diagnostic.CA2000.severity = suggestion   # Optional comment"
    //  4. "dotnet_analyzer_diagnostic.category-Security.severity = suggestion   # Optional comment"
    //  5. "dotnet_analyzer_diagnostic.severity = suggestion   # Optional comment"
    //
    // Regex groups:
    //  1. Option key
    //  2. Option value
    //  3. Optional severity suffix in option value, i.e. ':severity' suffix
    //  4. Optional comment suffix
    private static readonly Regex s_optionEntryPattern = new($@"(.*)=([\w, ]*)(:[\w]+)?([ ]*[;#].*)?");
 
    private readonly string? _optionNameOpt;
    private readonly string? _newOptionValueOpt;
    private readonly string _newSeverity;
    private readonly ConfigurationKind _configurationKind;
    private readonly Diagnostic? _diagnostic;
    private readonly string? _categoryToBulkConfigure;
    private readonly bool _isPerLanguage;
    private readonly Project _project;
    private readonly bool _addNewEntryIfNoExistingEntryFound;
    private readonly string _language;
 
    private ConfigurationUpdater(
        string? optionNameOpt,
        string? newOptionValueOpt,
        string newSeverity,
        ConfigurationKind configurationKind,
        Diagnostic? diagnosticToConfigure,
        string? categoryToBulkConfigure,
        bool isPerLanguage,
        Project project,
        bool addNewEntryIfNoExistingEntryFound)
    {
        Debug.Assert(configurationKind != ConfigurationKind.OptionValue || !string.IsNullOrEmpty(newOptionValueOpt));
        Debug.Assert(!string.IsNullOrEmpty(newSeverity));
        Debug.Assert(diagnosticToConfigure != null ^ categoryToBulkConfigure != null);
        Debug.Assert((categoryToBulkConfigure != null) == (configurationKind == ConfigurationKind.BulkConfigure));
 
        _optionNameOpt = optionNameOpt;
        _newOptionValueOpt = newOptionValueOpt;
        _newSeverity = newSeverity;
        _configurationKind = configurationKind;
        _diagnostic = diagnosticToConfigure;
        _categoryToBulkConfigure = categoryToBulkConfigure;
        _isPerLanguage = isPerLanguage;
        _project = project;
        _addNewEntryIfNoExistingEntryFound = addNewEntryIfNoExistingEntryFound;
        _language = project.Language;
    }
 
    /// <summary>
    /// Updates or adds an .editorconfig <see cref="AnalyzerConfigDocument"/> to the given <paramref name="project"/>
    /// so that the severity of the given <paramref name="diagnostic"/> is configured to be the given
    /// <paramref name="severity"/>.
    /// </summary>
    public static Task<Solution> ConfigureSeverityAsync(
        ReportDiagnostic severity,
        Diagnostic diagnostic,
        Project project,
        CancellationToken cancellationToken)
    {
        if (severity == ReportDiagnostic.Default)
        {
            severity = diagnostic.DefaultSeverity.ToReportDiagnostic();
        }
 
        return ConfigureSeverityAsync(severity.ToEditorConfigString(), diagnostic, project, cancellationToken);
    }
 
    /// <summary>
    /// Updates or adds an .editorconfig <see cref="AnalyzerConfigDocument"/> to the given <paramref name="project"/>
    /// so that the severity of the given <paramref name="diagnostic"/> is configured to be the given
    /// <paramref name="editorConfigSeverity"/>.
    /// </summary>
    public static Task<Solution> ConfigureSeverityAsync(
        string editorConfigSeverity,
        Diagnostic diagnostic,
        Project project,
        CancellationToken cancellationToken)
    {
        // For option based code style diagnostic, try to find the .editorconfig key-value pair for the
        // option setting.
        var codeStyleOptionValues = GetCodeStyleOptionValuesForDiagnostic(diagnostic, project);
 
        ConfigurationUpdater updater;
        if (!codeStyleOptionValues.IsEmpty)
        {
            return ConfigureCodeStyleOptionsAsync(
                codeStyleOptionValues.Select(t => (t.optionName, t.currentOptionValue, t.isPerLanguage)),
                editorConfigSeverity, diagnostic, project, configurationKind: ConfigurationKind.Severity, cancellationToken);
        }
        else
        {
            updater = new ConfigurationUpdater(optionNameOpt: null, newOptionValueOpt: null, editorConfigSeverity,
                configurationKind: ConfigurationKind.Severity, diagnostic, categoryToBulkConfigure: null,
                isPerLanguage: false, project, addNewEntryIfNoExistingEntryFound: true);
            return updater.ConfigureAsync(cancellationToken);
        }
    }
 
    /// <summary>
    /// Updates or adds an .editorconfig <see cref="AnalyzerConfigDocument"/> to the given <paramref name="project"/>
    /// so that the default severity of the diagnostics with the given <paramref name="category"/> is configured to be the given
    /// <paramref name="editorConfigSeverity"/>.
    /// </summary>
    public static Task<Solution> BulkConfigureSeverityAsync(
        string editorConfigSeverity,
        string category,
        Project project,
        CancellationToken cancellationToken)
    {
        Contract.ThrowIfFalse(!string.IsNullOrEmpty(category));
        return BulkConfigureSeverityCoreAsync(editorConfigSeverity, category, project, cancellationToken);
    }
 
    /// <summary>
    /// Updates or adds an .editorconfig <see cref="AnalyzerConfigDocument"/> to the given <paramref name="project"/>
    /// so that the default severity of all diagnostics is configured to be the given
    /// <paramref name="editorConfigSeverity"/>.
    /// </summary>
    public static Task<Solution> BulkConfigureSeverityAsync(
        string editorConfigSeverity,
        Project project,
        CancellationToken cancellationToken)
    {
        return BulkConfigureSeverityCoreAsync(editorConfigSeverity, category: AllAnalyzerDiagnosticsCategory, project, cancellationToken);
    }
 
    private static Task<Solution> BulkConfigureSeverityCoreAsync(
        string editorConfigSeverity,
        string category,
        Project project,
        CancellationToken cancellationToken)
    {
        Contract.ThrowIfNull(category);
        var updater = new ConfigurationUpdater(optionNameOpt: null, newOptionValueOpt: null, editorConfigSeverity,
            configurationKind: ConfigurationKind.BulkConfigure, diagnosticToConfigure: null, category,
            isPerLanguage: false, project, addNewEntryIfNoExistingEntryFound: true);
        return updater.ConfigureAsync(cancellationToken);
    }
 
    /// <summary>
    /// Updates or adds an .editorconfig <see cref="AnalyzerConfigDocument"/> to the given <paramref name="project"/>
    /// so that the given <paramref name="optionName"/> is configured to have the given <paramref name="optionValue"/>.
    /// </summary>
    public static Task<Solution> ConfigureCodeStyleOptionAsync(
        string optionName,
        string optionValue,
        Diagnostic diagnostic,
        bool isPerLanguage,
        Project project,
        CancellationToken cancellationToken)
    => ConfigureCodeStyleOptionsAsync(
            [(optionName, optionValue, isPerLanguage)],
            diagnostic.Severity.ToEditorConfigString(),
            diagnostic, project, configurationKind: ConfigurationKind.OptionValue, cancellationToken);
 
    private static async Task<Solution> ConfigureCodeStyleOptionsAsync(
        IEnumerable<(string optionName, string optionValue, bool isPerLanguage)> codeStyleOptionValues,
        string editorConfigSeverity,
        Diagnostic diagnostic,
        Project project,
        ConfigurationKind configurationKind,
        CancellationToken cancellationToken)
    {
        Debug.Assert(!codeStyleOptionValues.IsEmpty());
 
        // For severity configuration for IDE code style diagnostics, we want to ensure the following:
        //  1. For code style option based entries, i.e. "%option_name% = %option_value%:%severity%,
        //     we only update existing entries, but do not add a new entry.
        //  2. For "dotnet_diagnostic.<%DiagnosticId%>.severity = %severity%" entries, we update existing entries, and if none found
        //     we add a single new severity configuration entry for all code style options that share the same diagnostic ID.
        //  This behavior is required to ensure that we always add the up-to-date dotnet_diagnostic based severity entry
        //  so the IDE code style diagnostics can be enforced in build, as the compiler only understands dotnet_diagnostic entries.
        //  See https://github.com/dotnet/roslyn/issues/44201 for more details.
 
        // First handle "%option_name% = %option_value%:%severity% entries.
        // For option value configuration, we always want to add new entry if no existing value is found.
        // For severity configuration, we only want to update existing value if found.
        var currentProject = project;
        var areAllOptionsPerLanguage = true;
        var addNewEntryIfNoExistingEntryFound = configurationKind != ConfigurationKind.Severity;
        foreach (var (optionName, optionValue, isPerLanguage) in codeStyleOptionValues)
        {
            Debug.Assert(!string.IsNullOrEmpty(optionName));
            Debug.Assert(optionValue != null);
 
            var updater = new ConfigurationUpdater(optionName, optionValue, editorConfigSeverity, configurationKind,
                diagnostic, categoryToBulkConfigure: null, isPerLanguage, currentProject,
                addNewEntryIfNoExistingEntryFound);
            var solution = await updater.ConfigureAsync(cancellationToken).ConfigureAwait(false);
            currentProject = solution.GetProject(project.Id)!;
            areAllOptionsPerLanguage = areAllOptionsPerLanguage && isPerLanguage;
        }
 
        // For severity configuration, handle "dotnet_diagnostic.<%DiagnosticId%>.severity = %severity%" entry.
        // We want to update existing entry + add new entry if no existing value is found.
        if (configurationKind == ConfigurationKind.Severity)
        {
            var updater = new ConfigurationUpdater(optionNameOpt: null, newOptionValueOpt: null, editorConfigSeverity,
                configurationKind: ConfigurationKind.Severity, diagnostic, categoryToBulkConfigure: null,
                isPerLanguage: areAllOptionsPerLanguage, currentProject, addNewEntryIfNoExistingEntryFound: true);
            var solution = await updater.ConfigureAsync(cancellationToken).ConfigureAwait(false);
            currentProject = solution.GetProject(project.Id)!;
        }
 
        return currentProject.Solution;
    }
 
    private async Task<Solution> ConfigureAsync(CancellationToken cancellationToken)
    {
        // Find existing .editorconfig or generate a new one if none exists.
        var editorConfigDocument = FindOrGenerateEditorConfig();
        if (editorConfigDocument == null)
        {
            return _project.Solution;
        }
 
        var solution = editorConfigDocument.Project.Solution;
        var originalText = await editorConfigDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
 
        // Compute the updated text for analyzer config document.
        var newText = GetNewAnalyzerConfigDocumentText(originalText, editorConfigDocument);
 
        if (newText == null || newText.Equals(originalText))
        {
            return solution;
        }
 
        return solution.WithAnalyzerConfigDocumentText(editorConfigDocument.Id, newText);
    }
 
    private AnalyzerConfigDocument? FindOrGenerateEditorConfig()
    {
        var analyzerConfigPath = _diagnostic != null
            ? _project.TryGetAnalyzerConfigPathForDiagnosticConfiguration(_diagnostic)
            : _project.TryGetAnalyzerConfigPathForProjectConfiguration();
        if (analyzerConfigPath == null)
        {
            return null;
        }
 
        if (_project.Solution?.FilePath == null)
        {
            // Project has no solution or solution without a file path.
            // Add analyzer config to just the current project.
            return GetOrCreateAnalyzerConfigDocument(_project, analyzerConfigPath);
        }
 
        // Otherwise, add analyzer config document to all applicable projects for the current project's solution.
        AnalyzerConfigDocument? analyzerConfigDocument = null;
        var analyzerConfigDirectory = PathUtilities.GetDirectoryName(analyzerConfigPath) ?? throw ExceptionUtilities.Unreachable();
        var currentSolution = _project.Solution;
        foreach (var projectId in _project.Solution.ProjectIds)
        {
            var project = currentSolution.GetProject(projectId);
            if (project?.FilePath?.StartsWith(analyzerConfigDirectory) == true)
            {
                var addedAnalyzerConfigDocument = GetOrCreateAnalyzerConfigDocument(project, analyzerConfigPath);
                if (addedAnalyzerConfigDocument != null)
                {
                    analyzerConfigDocument ??= addedAnalyzerConfigDocument;
                    currentSolution = addedAnalyzerConfigDocument.Project.Solution;
                }
            }
        }
 
        return analyzerConfigDocument;
    }
 
    private static AnalyzerConfigDocument? GetOrCreateAnalyzerConfigDocument(Project project, string analyzerConfigPath)
    {
        var existingAnalyzerConfigDocument = project.TryGetExistingAnalyzerConfigDocumentAtPath(analyzerConfigPath);
        if (existingAnalyzerConfigDocument != null)
        {
            return existingAnalyzerConfigDocument;
        }
 
        var id = DocumentId.CreateNewId(project.Id);
        var documentInfo = DocumentInfo.Create(
            id,
            name: ".editorconfig",
            filePath: analyzerConfigPath);
 
        var newSolution = project.Solution.AddAnalyzerConfigDocuments([documentInfo]);
        return newSolution.GetProject(project.Id)?.GetAnalyzerConfigDocument(id);
    }
 
    private static ImmutableArray<(string optionName, string currentOptionValue, bool isPerLanguage)> GetCodeStyleOptionValuesForDiagnostic(
        Diagnostic diagnostic,
        Project project)
    {
        // For option based code style diagnostic, try to find the .editorconfig key-value pair for the
        // option setting.
        // For example, IDE diagnostics which are configurable with following code style option based .editorconfig entry:
        //      "%option_name% = %option_value%:%severity%
        // we return '(option_name, new_option_value, new_severity)'
        var codeStyleOptions = GetCodeStyleOptionsForDiagnostic(diagnostic, project);
        if (!codeStyleOptions.IsEmpty)
        {
            var builder = new FixedSizeArrayBuilder<(string optionName, string currentOptionValue, bool isPerLanguage)>(codeStyleOptions.Length);
 
            foreach (var option in codeStyleOptions)
            {
                var optionValue = option.Definition.Serializer.Serialize(option.DefaultValue);
                builder.Add((option.Definition.ConfigName, optionValue, option.IsPerLanguage));
            }
 
            return builder.MoveToImmutable();
        }
 
        return [];
    }
 
    internal static bool TryGetEditorConfigStringParts(string editorConfigString, out (string optionName, string optionValue) parts)
    {
        if (!string.IsNullOrEmpty(editorConfigString))
        {
            var match = s_optionEntryPattern.Match(editorConfigString);
            if (match.Success)
            {
                parts = (optionName: match.Groups[1].Value.Trim(),
                         optionValue: match.Groups[2].Value.Trim());
                return true;
            }
        }
 
        parts = default;
        return false;
    }
 
    internal static ImmutableArray<IOption2> GetCodeStyleOptionsForDiagnostic(Diagnostic diagnostic, Project project)
    {
        if (IDEDiagnosticIdToOptionMappingHelper.TryGetMappedOptions(diagnostic.Id, project.Language, out var options))
        {
            return [.. from option in options
                       where option.DefaultValue is ICodeStyleOption2
                       orderby option.Definition.ConfigName
                       select option];
        }
 
        return [];
    }
 
    private SourceText? GetNewAnalyzerConfigDocumentText(SourceText originalText, AnalyzerConfigDocument editorConfigDocument)
    {
        // Check if an entry to configure the rule severity already exists in the .editorconfig file.
        // If it does, we update the existing entry with the new severity.
        var (newText, lastValidHeaderSpanEnd, lastValidSpecificHeaderSpanEnd) = CheckIfRuleExistsAndReplaceInFile(originalText, editorConfigDocument);
        if (newText != null)
        {
            return newText;
        }
 
        if (!_addNewEntryIfNoExistingEntryFound)
        {
            return originalText;
        }
 
        // We did not find any existing entry in the in the .editorconfig file to configure rule severity.
        // So we add a new configuration entry to the .editorconfig file.
        return AddMissingRule(originalText, lastValidHeaderSpanEnd, lastValidSpecificHeaderSpanEnd);
    }
 
    private (SourceText? newText, TextLine? lastValidHeaderSpanEnd, TextLine? lastValidSpecificHeaderSpanEnd) CheckIfRuleExistsAndReplaceInFile(
        SourceText result,
        AnalyzerConfigDocument editorConfigDocument)
    {
        // If there's an error finding the editorconfig directory, bail out.
        var editorConfigDirectory = PathUtilities.GetDirectoryName(editorConfigDocument.FilePath);
        if (editorConfigDirectory == null)
        {
            return (null, null, null);
        }
 
        var relativePath = string.Empty;
        var diagnosticFilePath = string.Empty;
 
        // If diagnostic SourceTree is null, it means either Location.None or Bulk configuration at root editorconfig, and thus no relative path.
        var diagnosticSourceTree = _diagnostic?.Location.SourceTree;
        if (diagnosticSourceTree != null)
        {
            // Finds the relative path between editorconfig directory and diagnostic filepath.
            diagnosticFilePath = diagnosticSourceTree.FilePath.ToLowerInvariant();
            relativePath = PathUtilities.GetRelativePath(editorConfigDirectory.ToLowerInvariant(), diagnosticFilePath);
            relativePath = PathUtilities.NormalizeWithForwardSlash(relativePath);
        }
 
        TextLine? mostRecentHeader = null;
        TextLine? lastValidHeader = null;
        TextLine? lastValidHeaderSpanEnd = null;
 
        TextLine? lastValidSpecificHeader = null;
        TextLine? lastValidSpecificHeaderSpanEnd = null;
 
        var textChange = new TextChange();
        var isGlobalConfig = false;
 
        foreach (var curLine in result.Lines)
        {
            var curLineText = curLine.ToString();
 
            if (s_optionEntryPattern.IsMatch(curLineText))
            {
                var groups = s_optionEntryPattern.Match(curLineText).Groups;
 
                // Regex groups:
                //  1. Option key
                //  2. Option value
                //  3. Optional severity suffix, i.e. ':severity' suffix
                //  4. Optional comment suffix
                var untrimmedKey = groups[1].Value.ToString();
                var key = untrimmedKey.Trim();
                var value = groups[2].Value.ToString();
                var severitySuffixInValue = groups[3].Value.ToString();
                var commentValue = groups[4].Value.ToString();
 
                // Check for global config header: "is_global = true"
                if (mostRecentHeader == null &&
                    lastValidHeader == null &&
                    key.Equals("is_global", StringComparison.OrdinalIgnoreCase) &&
                    value.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) &&
                    severitySuffixInValue.Length == 0)
                {
                    isGlobalConfig = true;
                    mostRecentHeader = curLine;
                    lastValidHeader = curLine;
                    lastValidHeaderSpanEnd = curLine;
                    continue;
                }
 
                // Verify the most recent header is a valid header
                if (mostRecentHeader != null &&
                    lastValidHeader != null &&
                    mostRecentHeader.Equals(lastValidHeader))
                {
                    // We found the rule in the file -- replace it with updated option value/severity.
                    if (key.Equals(_optionNameOpt))
                    {
                        // We found an option configuration entry of form:
                        //      "%option_name% = %option_value%
                        //          OR
                        //      "%option_name% = %option_value%:%severity%
                        var newOptionValue = _configurationKind == ConfigurationKind.OptionValue
                            ? $"{value.GetLeadingWhitespace()}{_newOptionValueOpt}{value.GetTrailingWhitespace()}"
                            : value;
                        var newSeverityValue = _configurationKind == ConfigurationKind.Severity && severitySuffixInValue.Length > 0 ? $":{_newSeverity}" : severitySuffixInValue;
 
                        textChange = new TextChange(curLine.Span, $"{untrimmedKey}={newOptionValue}{newSeverityValue}{commentValue}");
                    }
                    else
                    {
                        // We want to detect severity based entry only when we are configuring severity and have no option name specified.
                        if (_configurationKind != ConfigurationKind.OptionValue &&
                            _optionNameOpt == null &&
                            severitySuffixInValue.Length == 0 &&
                            key.EndsWith(SeveritySuffix))
                        {
                            // We found a rule configuration entry of severity based form:
                            //      "dotnet_diagnostic.<%DiagnosticId%>.severity = %severity%
                            //              OR
                            //      "dotnet_analyzer_diagnostic.severity = %severity%
                            //              OR
                            //      "dotnet_analyzer_diagnostic.category-<%DiagnosticCategory%>.severity = %severity%
 
                            var foundMatch = false;
                            switch (_configurationKind)
                            {
                                case ConfigurationKind.Severity:
                                    RoslynDebug.Assert(_diagnostic != null);
                                    if (key.StartsWith(DiagnosticOptionPrefix, StringComparison.Ordinal))
                                    {
                                        var diagIdLength = key.Length - (DiagnosticOptionPrefix.Length + SeveritySuffix.Length);
                                        if (diagIdLength > 0)
                                        {
                                            var diagId = key.Substring(DiagnosticOptionPrefix.Length, diagIdLength);
                                            foundMatch = string.Equals(diagId, _diagnostic.Id, StringComparison.OrdinalIgnoreCase);
                                        }
                                    }
 
                                    break;
 
                                case ConfigurationKind.BulkConfigure:
                                    RoslynDebug.Assert(_categoryToBulkConfigure != null);
                                    if (_categoryToBulkConfigure == AllAnalyzerDiagnosticsCategory)
                                    {
                                        foundMatch = key == BulkConfigureAllAnalyzerDiagnosticsOptionKey;
                                    }
                                    else
                                    {
                                        if (key.StartsWith(BulkConfigureAnalyzerDiagnosticsByCategoryOptionPrefix, StringComparison.Ordinal))
                                        {
                                            var categoryLength = key.Length - (BulkConfigureAnalyzerDiagnosticsByCategoryOptionPrefix.Length + SeveritySuffix.Length);
                                            var category = key.Substring(BulkConfigureAnalyzerDiagnosticsByCategoryOptionPrefix.Length, categoryLength);
                                            foundMatch = string.Equals(category, _categoryToBulkConfigure, StringComparison.OrdinalIgnoreCase);
                                        }
                                    }
 
                                    break;
                            }
 
                            if (foundMatch)
                            {
                                var newSeverityValue = $"{value.GetLeadingWhitespace()}{_newSeverity}{value.GetTrailingWhitespace()}";
                                textChange = new TextChange(curLine.Span, $"{untrimmedKey}={newSeverityValue}{commentValue}");
                            }
                        }
                    }
                }
            }
            else if (!isGlobalConfig && s_headerPattern.IsMatch(curLineText.Trim()))
            {
                // We found a header entry such as '[*.cs]', '[*.vb]', etc.
                // Verify that header is valid.
                mostRecentHeader = curLine;
                var groups = s_headerPattern.Match(curLineText.Trim()).Groups;
                var mostRecentHeaderText = groups[1].Value.ToString().ToLowerInvariant();
 
                if (mostRecentHeaderText.Equals("*"))
                {
                    lastValidHeader = mostRecentHeader;
                }
                else
                {
                    // We splice on the last occurrence of '.' to account for filenames containing periods.
                    var nameExtensionSplitIndex = mostRecentHeaderText.LastIndexOf('.');
                    var fileName = mostRecentHeaderText[..nameExtensionSplitIndex];
                    var splicedFileExtensions = mostRecentHeaderText[(nameExtensionSplitIndex + 1)..].Split(',', ' ', '{', '}');
 
                    // Replacing characters in the header with the regex equivalent.
                    fileName = fileName.Replace(".", @"\.");
                    fileName = fileName.Replace("*", ".*");
                    fileName = fileName.Replace("/", @"\/");
 
                    // Creating the header regex string, ex. [*.{cs,vb}] => ((\.cs)|(\.vb))
                    var headerRegexStr = fileName + @"((\." + splicedFileExtensions[0] + ")";
                    for (var i = 1; i < splicedFileExtensions.Length; i++)
                    {
                        headerRegexStr += @"|(\." + splicedFileExtensions[i] + ")";
                    }
 
                    headerRegexStr += ")";
 
                    var headerRegex = new Regex(headerRegexStr);
 
                    // We check that the relative path of the .editorconfig file to the diagnostic file
                    // matches the header regex pattern.
                    if (headerRegex.IsMatch(relativePath))
                    {
                        var match = headerRegex.Match(relativePath).Value;
                        var matchWithoutExtension = match[..match.LastIndexOf('.')];
 
                        // Edge case: The below statement checks that we correctly handle cases such as a header of [m.cs] and
                        // a file name of Program.cs.
                        if (matchWithoutExtension.Contains(PathUtilities.GetFileName(diagnosticFilePath, false)))
                        {
                            // If the diagnostic's isPerLanguage = true, the rule is valid for both C# and VB.
                            // For the purpose of adding missing rules later, we want to keep track of whether there is a
                            // valid header that contains both [*.cs] and [*.vb]. 
                            // If isPerLanguage = false or a compiler diagnostic, the rule is only valid for one of the languages.
                            // Thus, we want to keep track of whether there is an existing header that only contains [*.cs] or only
                            // [*.vb], depending on the language.
                            // We also keep track of the last valid header for the language.
                            var isLanguageAgnosticEntry = (_diagnostic == null || !SuppressionHelpers.IsCompilerDiagnostic(_diagnostic)) && _isPerLanguage;
                            if (isLanguageAgnosticEntry)
                            {
                                if ((_language.Equals(LanguageNames.CSharp) || _language.Equals(LanguageNames.VisualBasic)) &&
                                    splicedFileExtensions.Contains("cs") && splicedFileExtensions.Contains("vb"))
                                {
                                    lastValidSpecificHeader = mostRecentHeader;
                                }
                            }
                            else if (splicedFileExtensions.Length == 1)
                            {
                                if (_language.Equals(LanguageNames.CSharp) && splicedFileExtensions.Contains("cs"))
                                {
                                    lastValidSpecificHeader = mostRecentHeader;
                                }
                                else if (_language.Equals(LanguageNames.VisualBasic) && splicedFileExtensions.Contains("vb"))
                                {
                                    lastValidSpecificHeader = mostRecentHeader;
                                }
                            }
 
                            lastValidHeader = mostRecentHeader;
                        }
                    }
                    // Location.None special case.
                    else if (relativePath.IsEmpty() && new Regex(fileName).IsMatch(relativePath))
                    {
                        if ((_language.Equals(LanguageNames.CSharp) && splicedFileExtensions.Contains("cs")) ||
                                (_language.Equals(LanguageNames.VisualBasic) && splicedFileExtensions.Contains("vb")))
                        {
                            lastValidHeader = mostRecentHeader;
                        }
                    }
                }
            }
 
            // We want to keep track of how far this (valid) section spans.
            if (mostRecentHeader != null &&
                lastValidHeader != null &&
                mostRecentHeader.Equals(lastValidHeader))
            {
                lastValidHeaderSpanEnd = curLine;
                if (lastValidSpecificHeader != null && mostRecentHeader.Equals(lastValidSpecificHeader))
                {
                    lastValidSpecificHeaderSpanEnd = curLine;
                }
            }
        }
 
        // We return only the last text change in case of duplicate entries for the same rule.
        if (textChange != default)
        {
            return (result.WithChanges(textChange), lastValidHeaderSpanEnd, lastValidSpecificHeaderSpanEnd);
        }
 
        // Rule not found.
        return (null, lastValidHeaderSpanEnd, lastValidSpecificHeaderSpanEnd);
    }
 
    private SourceText? AddMissingRule(
        SourceText result,
        TextLine? lastValidHeaderSpanEnd,
        TextLine? lastValidSpecificHeaderSpanEnd)
    {
        // Create a new rule configuration entry for the given diagnostic ID or bulk configuration category.
        // If optionNameOpt and optionValueOpt are non-null, it indicates an option based diagnostic ID
        // which can be configured by a new entry such as: "%option_name% = %option_value%:%severity%
        // Otherwise, if diagnostic is non-null, it indicates a non-option diagnostic ID,
        // which can be configured by a new entry such as: "dotnet_diagnostic.<%DiagnosticId%>.severity = %severity%
        // Otherwise, it indicates a bulk configuration entry for default severity of a specific diagnostic category or all analyzer diagnostics,
        // which can be configured by a new entry such as:
        //  1. All analyzer diagnostics: "dotnet_analyzer_diagnostic.severity = %severity%
        //  2. Category configuration: "dotnet_analyzer_diagnostic.category-<%DiagnosticCategory%>.severity = %severity%
 
        var newEntry = !string.IsNullOrEmpty(_optionNameOpt) && !string.IsNullOrEmpty(_newOptionValueOpt)
            ? $"{_optionNameOpt} = {_newOptionValueOpt}"
            : _diagnostic != null
                ? $"{DiagnosticOptionPrefix}{_diagnostic.Id}{SeveritySuffix} = {_newSeverity}"
                : _categoryToBulkConfigure == AllAnalyzerDiagnosticsCategory
                    ? $"{BulkConfigureAllAnalyzerDiagnosticsOptionKey} = {_newSeverity}"
                    : $"{BulkConfigureAnalyzerDiagnosticsByCategoryOptionPrefix}{_categoryToBulkConfigure}{SeveritySuffix} = {_newSeverity}";
 
        // Insert a new line and comment text above the new entry
        var commentPrefix = _diagnostic != null
            ? $"{_diagnostic.Id}: {_diagnostic.Descriptor.Title}"
            : _categoryToBulkConfigure == AllAnalyzerDiagnosticsCategory
                ? "Default severity for all analyzer diagnostics"
                : $"Default severity for analyzer diagnostics with category '{_categoryToBulkConfigure}'";
 
        newEntry = $"\r\n# {commentPrefix}\r\n{newEntry}\r\n";
 
        // Check if have a correct existing header for the new entry.
        //      - If the diagnostic's isPerLanguage = true, it means the rule is valid for both C# and VB.
        //        Thus, if there is a valid existing header containing both [*.cs] and [*.vb], then we prioritize it.
        //      - If isPerLanguage = false, it means the rule is only valid for one of the languages. Thus, we
        //        prioritize headers that contain only the file extension for the given language.
        //      - If neither of the above hold true, we choose the last existing valid header. 
        //      - If no valid existing headers, we generate a new header.
        if (lastValidSpecificHeaderSpanEnd.HasValue)
        {
            if (lastValidSpecificHeaderSpanEnd.Value.ToString().Trim().Length != 0)
            {
                newEntry = "\r\n" + newEntry;
            }
 
            var textChange = new TextChange(new TextSpan(lastValidSpecificHeaderSpanEnd.Value.Span.End, 0), newEntry);
            return result.WithChanges(textChange);
        }
        else if (lastValidHeaderSpanEnd.HasValue)
        {
            if (lastValidHeaderSpanEnd.Value.ToString().Trim().Length != 0)
            {
                newEntry = "\r\n" + newEntry;
            }
 
            var textChange = new TextChange(new TextSpan(lastValidHeaderSpanEnd.Value.Span.End, 0), newEntry);
            return result.WithChanges(textChange);
        }
 
        // We need to generate a new header such as '[*.cs]' or '[*.vb]':
        //      - For compiler diagnostic entries and code style entries which have per-language option = false, generate only [*.cs] or [*.vb].
        //      - For the remainder, generate [*.{cs,vb}]
        if (_language is LanguageNames.CSharp or LanguageNames.VisualBasic)
        {
            // Insert a newline if not already present
            var lines = result.Lines;
            var lastLine = lines.Count > 0 ? lines[^1] : default;
            var prefix = string.Empty;
            if (lastLine.ToString().Trim().Length != 0)
            {
                prefix = "\r\n";
            }
 
            // Insert newline if file is not empty
            if (lines.Count > 1 && lastLine.ToString().Trim().Length == 0)
            {
                prefix += "\r\n";
            }
 
            var compilerDiagOrNotPerLang = (_diagnostic != null && SuppressionHelpers.IsCompilerDiagnostic(_diagnostic)) || !_isPerLanguage;
            if (_language.Equals(LanguageNames.CSharp) && compilerDiagOrNotPerLang)
            {
                prefix += "[*.cs]\r\n";
            }
            else if (_language.Equals(LanguageNames.VisualBasic) && compilerDiagOrNotPerLang)
            {
                prefix += "[*.vb]\r\n";
            }
            else
            {
                prefix += "[*.{cs,vb}]\r\n";
            }
 
            var textChange = new TextChange(new TextSpan(result.Length, 0), prefix + newEntry);
            return result.WithChanges(textChange);
        }
 
        return null;
    }
}