File: CommandLine\AnalyzerConfig.cs
Web Access
Project: src\src\Compilers\Core\Portable\Microsoft.CodeAnalysis.csproj (Microsoft.CodeAnalysis)
// 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.IO;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis
{
    /// <summary>
    /// Represents a single EditorConfig file, see https://editorconfig.org for details about the format.
    /// </summary>
    public sealed partial class AnalyzerConfig
    {
        // 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 NET
 
        [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
 
        /// <summary>
        /// Key that indicates if this config is a global config
        /// </summary>
        internal const string GlobalKey = "is_global";
 
        /// <summary>
        /// Key that indicates the precedence of this config when <see cref="IsGlobal"/> is true
        /// </summary>
        internal const string GlobalLevelKey = "global_level";
 
        /// <summary>
        /// Filename that indicates this file is a user provided global config
        /// </summary>
        internal const string UserGlobalConfigName = ".globalconfig";
 
        /// <summary>
        /// A set of keys that are reserved for special interpretation for the editorconfig specification.
        /// All values corresponding to reserved keys in a (key,value) property pair are always lowercased
        /// during parsing.
        /// </summary>
        /// <remarks>
        /// This list was retrieved from https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties
        /// at 2018-04-21 19:37:05Z. New keys may be added to this list in newer versions, but old ones will
        /// not be removed.
        /// </remarks>
        internal static ImmutableHashSet<string> ReservedKeys { get; }
            = ImmutableHashSet.CreateRange(Section.PropertiesKeyComparer, new[] {
                "root",
                "indent_style",
                "indent_size",
                "tab_width",
                "end_of_line",
                "charset",
                "trim_trailing_whitespace",
                "insert_final_newline",
            });
 
        /// <summary>
        /// A set of values that are reserved for special use for the editorconfig specification
        /// and will always be lower-cased by the parser.
        /// </summary>
        internal static ImmutableHashSet<string> ReservedValues { get; }
            = ImmutableHashSet.CreateRange(CaseInsensitiveComparison.Comparer, new[] { "unset" });
 
        internal Section GlobalSection { get; }
 
        /// <summary>
        /// The directory the editorconfig was contained in, with all directory separators
        /// replaced with '/'.
        /// </summary>
        internal string NormalizedDirectory { get; }
 
        /// <summary>
        /// The path passed to <see cref="Parse(string, string)"/> during construction.
        /// </summary>
        internal string PathToFile { get; }
 
        /// <summary>
        /// Comparer for sorting <see cref="AnalyzerConfig"/> files by <see cref="NormalizedDirectory"/> path length.
        /// </summary>
        internal static Comparer<AnalyzerConfig> DirectoryLengthComparer { get; } = Comparer<AnalyzerConfig>.Create(
            (e1, e2) => e1.NormalizedDirectory.Length.CompareTo(e2.NormalizedDirectory.Length));
 
        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 == "true";
 
        /// <summary>
        /// Gets whether this editorconfig is a global editorconfig.
        /// </summary>
        internal bool IsGlobal => _hasGlobalFileName || GlobalSection.Properties.ContainsKey(GlobalKey);
 
        /// <summary>
        /// Get the global level of this config, used to resolve conflicting keys
        /// </summary>
        /// <remarks>
        /// A user can explicitly set the global level via the <see cref="GlobalLevelKey"/>.
        /// When no global level is explicitly set, we use a heuristic:
        ///  <list type="bullet">
        ///     <item><description>
        ///     Any file matching the <see cref="UserGlobalConfigName"/> is determined to be a user supplied global config and gets a level of 100
        ///     </description></item>
        ///     <item><description>
        ///     Any other file gets a default level of 0
        ///     </description></item>
        ///  </list>
        ///  
        /// This value is unused when <see cref="IsGlobal"/> is <c>false</c>.
        /// </remarks>
        internal int GlobalLevel
        {
            get
            {
                if (GlobalSection.Properties.TryGetValue(GlobalLevelKey, out string? val) && int.TryParse(val, out int level))
                {
                    return level;
                }
                else if (_hasGlobalFileName)
                {
                    return 100;
                }
                else
                {
                    return 0;
                }
            }
        }
 
        private readonly bool _hasGlobalFileName;
 
        private AnalyzerConfig(
            Section globalSection,
            ImmutableArray<Section> namedSections,
            string pathToFile)
        {
            GlobalSection = globalSection;
            NamedSections = namedSections;
            PathToFile = pathToFile;
            _hasGlobalFileName = Path.GetFileName(pathToFile).Equals(UserGlobalConfigName, StringComparison.OrdinalIgnoreCase);
 
            // Find the containing directory and normalize the path separators
            string directory = Path.GetDirectoryName(pathToFile) ?? pathToFile;
            NormalizedDirectory = PathUtilities.NormalizeWithForwardSlash(directory);
        }
 
        /// <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>
        public static AnalyzerConfig Parse(string text, string? pathToFile)
        {
            return Parse(SourceText.From(text), pathToFile);
        }
 
        /// <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>
        public static AnalyzerConfig Parse(SourceText text, string? pathToFile)
        {
            if (pathToFile is null || !Path.IsPathRooted(pathToFile) || string.IsNullOrEmpty(Path.GetFileName(pathToFile)))
            {
                throw new ArgumentException("Must be an absolute path to an editorconfig file", nameof(pathToFile));
            }
 
            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>(
                Section.PropertiesKeyComparer);
            string activeSectionName = "";
 
            foreach (var textLine in text.Lines)
            {
                string line = textLine.ToString();
 
                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>(
                        Section.PropertiesKeyComparer);
                    continue;
                }
 
                var propMatches = GetPropertyMatcherRegex().Matches(line);
                if (propMatches.Count > 0 && propMatches[0].Groups.Count > 1)
                {
                    var key = propMatches[0].Groups[1].Value;
                    var value = propMatches[0].Groups[2].Value;
 
                    Debug.Assert(!string.IsNullOrEmpty(key));
                    Debug.Assert(key == key.Trim());
                    Debug.Assert(value == value?.Trim());
 
                    key = CaseInsensitiveComparison.ToLower(key);
                    if (ReservedKeys.Contains(key) || ReservedValues.Contains(value))
                    {
                        value = CaseInsensitiveComparison.ToLower(value);
                    }
 
                    activeSectionProperties[key] = value ?? "";
                    continue;
                }
            }
 
            // Add the last section
            addNewSection();
 
            // Normalize the path to file the same way named sections are
            pathToFile = PathUtilities.NormalizeDriveLetter(pathToFile);
 
            return new AnalyzerConfig(globalSection!, namedSectionBuilder.ToImmutable(), pathToFile);
 
            void addNewSection()
            {
                var sectionName = PathUtilities.NormalizeDriveLetter(activeSectionName);
 
                // Close out the previous section
                var previousSection = new Section(sectionName, 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
        {
            /// <summary>
            /// Used to compare <see cref="Name"/>s of sections. Specified by editorconfig to
            /// be a case-sensitive comparison.
            /// </summary>
            public static StringComparison NameComparer { get; } = StringComparison.Ordinal;
 
            /// <summary>
            /// Used to compare <see cref="Name"/>s of sections. Specified by editorconfig to
            /// be a case-sensitive comparison.
            /// </summary>
            public static IEqualityComparer<string> NameEqualityComparer { get; } = StringComparer.Ordinal;
 
            /// <summary>
            /// Used to compare keys in <see cref="Properties"/>. The editorconfig spec defines property
            /// keys as being compared case-insensitively according to Unicode lower-case rules.
            /// </summary>
            public static StringComparer PropertiesKeyComparer { get; } = CaseInsensitiveComparison.Comparer;
 
            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. Values are
            /// lower-cased if the value appears in <see cref="ReservedValues" />
            /// or if the corresponding key is in <see cref="ReservedKeys" />. Otherwise,
            /// the values are the literal values present in the source.
            /// </summary>
            public ImmutableDictionary<string, string> Properties { get; }
        }
    }
}