File: BuildCheck\Infrastructure\EditorConfig\EditorConfigFile.cs
Web Access
Project: ..\..\..\src\Build\Microsoft.Build.csproj (Microsoft.Build)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
// Note:
// Code and logic is copied from the https://github.com/dotnet/roslyn/blob/06d3f153ed6af6f2b78028a1e1e6ecc55c8ff101/src/Compilers/Core/Portable/CommandLine/AnalyzerConfig.cs
// with slight changes like:
//  1. Remove dependency from Source text.
//  2. Remove support of globalconfig
//  3. Remove the FilePath and receive only the text
 
using System;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.RegularExpressions;
 
namespace Microsoft.Build.Experimental.BuildCheck.Infrastructure.EditorConfig;
 
internal partial class EditorConfigFile
{
    // Matches EditorConfig section header such as "[*.{js,py}]", see https://editorconfig.org for details
    private const string s_sectionMatcherPattern = @"^\s*\[(([^#;]|\\#|\\;)+)\]\s*([#;].*)?$";
 
    // Matches EditorConfig property such as "indent_style = space", see https://editorconfig.org for details
    private const string s_propertyMatcherPattern = @"^\s*([\w\.\-_]+)\s*[=:]\s*(.*?)\s*([#;].*)?$";
 
#if NETCOREAPP

    [GeneratedRegex(s_sectionMatcherPattern)]
    private static partial Regex GetSectionMatcherRegex();
 
    [GeneratedRegex(s_propertyMatcherPattern)]
    private static partial Regex GetPropertyMatcherRegex();
 
#else
    private static readonly Regex s_sectionMatcher = new Regex(s_sectionMatcherPattern, RegexOptions.Compiled);
 
    private static readonly Regex s_propertyMatcher = new Regex(s_propertyMatcherPattern, RegexOptions.Compiled);
 
    private static Regex GetSectionMatcherRegex() => s_sectionMatcher;
 
    private static Regex GetPropertyMatcherRegex() => s_propertyMatcher;
 
#endif
 
    internal Section GlobalSection { get; }
 
    internal ImmutableArray<Section> NamedSections { get; }
 
    /// <summary>
    /// Gets whether this editorconfig is a topmost editorconfig.
    /// </summary>
    internal bool IsRoot => GlobalSection.Properties.TryGetValue("root", out string? val) && val?.ToLower() == "true";
 
    private EditorConfigFile(
        Section globalSection,
        ImmutableArray<Section> namedSections)
    {
        GlobalSection = globalSection;
        NamedSections = namedSections;
    }
 
    /// <summary>
    /// Parses an editor config file text located at the given path. No parsing
    /// errors are reported. If any line contains a parse error, it is dropped.
    /// </summary>
    internal static EditorConfigFile Parse(string text)
    {
        Section? globalSection = null;
        var namedSectionBuilder = ImmutableArray.CreateBuilder<Section>();
 
        // N.B. The editorconfig documentation is quite loose on property interpretation.
        // Specifically, it says:
        //      Currently all properties and values are case-insensitive.
        //      They are lowercased when parsed.
        // To accommodate this, we use a lower case Unicode mapping when adding to the
        // dictionary, but we also use a case-insensitive key comparer when doing lookups
        var activeSectionProperties = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
        string activeSectionName = "";
        var lines = string.IsNullOrEmpty(text) ? [] : text.Split(["\r\n", "\n"], StringSplitOptions.None);
 
        foreach (var line in lines)
        {
            if (string.IsNullOrWhiteSpace(line))
            {
                continue;
            }
 
            if (IsComment(line))
            {
                continue;
            }
 
            var sectionMatches = GetSectionMatcherRegex().Matches(line);
            if (sectionMatches.Count > 0 && sectionMatches[0].Groups.Count > 0)
            {
                addNewSection();
 
                var sectionName = sectionMatches[0].Groups[1].Value;
                Debug.Assert(!string.IsNullOrEmpty(sectionName));
 
                activeSectionName = sectionName;
                activeSectionProperties = ImmutableDictionary.CreateBuilder<string, string>();
                continue;
            }
 
            var propMatches = GetPropertyMatcherRegex().Matches(line);
            if (propMatches.Count > 0 && propMatches[0].Groups.Count > 1)
            {
                var key = propMatches[0].Groups[1].Value.ToLower();
                var value = propMatches[0].Groups[2].Value;
 
                Debug.Assert(!string.IsNullOrEmpty(key));
                Debug.Assert(key == key.Trim());
                Debug.Assert(value == value?.Trim());
 
                activeSectionProperties[key] = value ?? "";
                continue;
            }
        }
 
        // Add the last section
        addNewSection();
 
        return new EditorConfigFile(globalSection!, namedSectionBuilder.ToImmutable());
 
        void addNewSection()
        {
            // Close out the previous section
            var previousSection = new Section(activeSectionName, activeSectionProperties.ToImmutable());
            if (activeSectionName == "")
            {
                // This is the global section
                globalSection = previousSection;
            }
            else
            {
                namedSectionBuilder.Add(previousSection);
            }
        }
    }
 
    private static bool IsComment(string line)
    {
        foreach (char c in line)
        {
            if (!char.IsWhiteSpace(c))
            {
                return c == '#' || c == ';';
            }
        }
 
        return false;
    }
 
    /// <summary>
    /// Represents a named section of the editorconfig file, which consists of a name followed by a set
    /// of key-value pairs.
    /// </summary>
    internal sealed class Section
    {
        public Section(string name, ImmutableDictionary<string, string> properties)
        {
            Name = name;
            Properties = properties;
        }
 
        /// <summary>
        /// For regular files, the name as present directly in the section specification of the editorconfig file. For sections in
        /// global configs, this is the unescaped full file path.
        /// </summary>
        public string Name { get; }
 
        /// <summary>
        /// Keys and values for this section. All keys are lower-cased according to the
        /// EditorConfig specification and keys are compared case-insensitively.
        /// </summary>
        public ImmutableDictionary<string, string> Properties { get; }
    }
}