File: Microsoft.CodeQuality.Analyzers\Maintainability\CodeMetricsAnalyzer.cs
Web Access
Project: src\sdk\src\Microsoft.CodeAnalysis.NetAnalyzers\src\Microsoft.CodeAnalysis.NetAnalyzers\Microsoft.CodeAnalysis.NetAnalyzers.csproj (Microsoft.CodeAnalysis.NetAnalyzers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading;
using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeMetrics;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.CodeQuality.Analyzers.Maintainability.CodeMetrics
{
    using static MicrosoftCodeQualityAnalyzersResources;

    /// <summary>
    /// CA1501: <inheritdoc cref="AvoidExcessiveInheritanceTitle"/>
    /// CA1502: <inheritdoc cref="AvoidExcessiveComplexityTitle"/>
    /// CA1505: <inheritdoc cref="AvoidUnmantainableCodeTitle"/>
    /// CA1506: <inheritdoc cref="AvoidExcessiveClassCouplingTitle"/>
    /// CA1509: <inheritdoc cref="InvalidEntryInCodeMetricsConfigFileTitle"/>
    /// </summary>
    [DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
    public sealed class CodeMetricsAnalyzer : DiagnosticAnalyzer
    {
        internal const string CA1501RuleId = "CA1501";
        internal const string CA1502RuleId = "CA1502";
        internal const string CA1505RuleId = "CA1505";
        internal const string CA1506RuleId = "CA1506";

        /// <summary>
        /// Configuration file to configure custom threshold values for supported code metrics.
        /// For example, the below entry changes the maximum allowed inheritance depth from the default value of 5 to 10:
        ///
        ///     # FORMAT:
        ///     # 'RuleId'(Optional 'SymbolKind'): 'Threshold'
        ///
        ///     CA1501: 10
        /// See CA1509 unit tests for more examples.
        /// </summary>
        private const string CodeMetricsConfigurationFile = "CodeMetricsConfig.txt";

        // New rule for invalid entries in CodeMetricsConfigurationFile.
        internal const string CA1509RuleId = "CA1509";

        internal static readonly DiagnosticDescriptor CA1501Rule = DiagnosticDescriptorHelper.Create(
            CA1501RuleId,
            CreateLocalizableResourceString(nameof(AvoidExcessiveInheritanceTitle)),
            CreateLocalizableResourceString(nameof(AvoidExcessiveInheritanceMessage)),
            DiagnosticCategory.Maintainability,
            RuleLevel.CandidateForRemoval,
            description: CreateLocalizableResourceString(nameof(AvoidExcessiveInheritanceDescription)),
            isPortedFxCopRule: true,
            isDataflowRule: false,
            isEnabledByDefaultInAggressiveMode: false,
            isReportedAtCompilationEnd: true);

        internal static readonly DiagnosticDescriptor CA1502Rule = DiagnosticDescriptorHelper.Create(
            CA1502RuleId,
            CreateLocalizableResourceString(nameof(AvoidExcessiveComplexityTitle)),
            CreateLocalizableResourceString(nameof(AvoidExcessiveComplexityMessage)),
            DiagnosticCategory.Maintainability,
            RuleLevel.CandidateForRemoval,
            description: CreateLocalizableResourceString(nameof(AvoidExcessiveComplexityDescription)),
            isPortedFxCopRule: true,
            isDataflowRule: false,
            isEnabledByDefaultInAggressiveMode: false,
            isReportedAtCompilationEnd: true);

        internal static readonly DiagnosticDescriptor CA1505Rule = DiagnosticDescriptorHelper.Create(
            CA1505RuleId,
            CreateLocalizableResourceString(nameof(AvoidUnmantainableCodeTitle)),
            CreateLocalizableResourceString(nameof(AvoidUnmantainableCodeMessage)),
            DiagnosticCategory.Maintainability,
            RuleLevel.CandidateForRemoval,
            description: CreateLocalizableResourceString(nameof(AvoidUnmantainableCodeDescription)),
            isPortedFxCopRule: true,
            isDataflowRule: false,
            isEnabledByDefaultInAggressiveMode: false,
            isReportedAtCompilationEnd: true);

        internal static readonly DiagnosticDescriptor CA1506Rule = DiagnosticDescriptorHelper.Create(
            CA1506RuleId,
            CreateLocalizableResourceString(nameof(AvoidExcessiveClassCouplingTitle)),
            CreateLocalizableResourceString(nameof(AvoidExcessiveClassCouplingMessage)),
            DiagnosticCategory.Maintainability,
            RuleLevel.CandidateForRemoval,
            description: CreateLocalizableResourceString(nameof(AvoidExcessiveClassCouplingDescription)),
            isPortedFxCopRule: true,
            isDataflowRule: false,
            isEnabledByDefaultInAggressiveMode: false,
            isReportedAtCompilationEnd: true);

        internal static readonly DiagnosticDescriptor InvalidEntryInCodeMetricsConfigFileRule = DiagnosticDescriptorHelper.Create(
            CA1509RuleId,
            CreateLocalizableResourceString(nameof(InvalidEntryInCodeMetricsConfigFileTitle)),
            CreateLocalizableResourceString(nameof(InvalidEntryInCodeMetricsConfigFileMessage)),
            DiagnosticCategory.Maintainability,
            RuleLevel.CandidateForRemoval,
            description: CreateLocalizableResourceString(nameof(InvalidEntryInCodeMetricsConfigFileDescription)),
            isPortedFxCopRule: false,
            isDataflowRule: false,
            isEnabledByDefaultInAggressiveMode: false,
            isReportedAtCompilationEnd: true);

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(CA1501Rule, CA1502Rule, CA1505Rule, CA1506Rule, InvalidEntryInCodeMetricsConfigFileRule);

        public override void Initialize(AnalysisContext context)
        {
            context.EnableConcurrentExecution();
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);

            context.RegisterCompilationAction(compilationContext =>
            {
                if (compilationContext.Compilation.SyntaxTrees.FirstOrDefault() is not SyntaxTree tree)
                {
                    return;
                }

                // Try read the additional file containing the code metrics configuration.
                if (!TryGetRuleIdToThresholdMap(
                        compilationContext.Options.AdditionalFiles,
                        compilationContext.CancellationToken,
                        out AdditionalText? additionalTextOpt,
                        out ImmutableDictionary<string, IReadOnlyList<(SymbolKind?, uint)>>? ruleIdToThresholdMap,
                        out List<Diagnostic>? invalidFileDiagnostics) &&
                    invalidFileDiagnostics != null)
                {
                    // Report any invalid additional file diagnostics.
                    foreach (var diagnostic in invalidFileDiagnostics)
                    {
                        compilationContext.ReportDiagnostic(diagnostic);
                    }

                    return;
                }

                var metricsAnalysisContext = new CodeMetricsAnalysisContext(compilationContext.Compilation, compilationContext.CancellationToken,
                    namedType => IsConfiguredToSkipFromInheritanceCount(namedType, compilationContext, tree));
                var codeAnalysisMetricData = CodeAnalysisMetricData.ComputeSynchronously(metricsAnalysisContext);

                // Analyze code metrics tree and report diagnostics.
                analyzeMetricsData(codeAnalysisMetricData);

                void analyzeMetricsData(CodeAnalysisMetricData codeAnalysisMetricData)
                {
                    var symbol = codeAnalysisMetricData.Symbol;

                    // CA1501: Avoid excessive inheritance
                    if (symbol.Kind == SymbolKind.NamedType && codeAnalysisMetricData.DepthOfInheritance.HasValue)
                    {
                        uint? inheritanceThreshold = getThreshold(CA1501RuleId, symbol.Kind);
                        if (inheritanceThreshold.HasValue && codeAnalysisMetricData.DepthOfInheritance.Value > inheritanceThreshold.Value)
                        {
                            // '{0}' has an object hierarchy '{1}' levels deep within the defining module. If possible, eliminate base classes within the hierarchy to decrease its hierarchy level below '{2}': '{3}'
                            var arg1 = symbol.Name;
                            var arg2 = codeAnalysisMetricData.DepthOfInheritance;
                            var arg3 = inheritanceThreshold + 1;
                            var arg4 = string.Join(", ", ((INamedTypeSymbol)symbol).GetBaseTypes(t => !IsConfiguredToSkipFromInheritanceCount(t, compilationContext, tree)).Select(t => t.Name));
                            var diagnostic = symbol.CreateDiagnostic(CA1501Rule, arg1, arg2, arg3, arg4);
                            compilationContext.ReportDiagnostic(diagnostic);
                        }
                    }

                    // CA1502: Avoid excessive complexity
                    uint? complexityThreshold = getThreshold(CA1502RuleId, symbol.Kind);
                    if (complexityThreshold.HasValue && codeAnalysisMetricData.CyclomaticComplexity > complexityThreshold.Value)
                    {
                        // '{0}' has a cyclomatic complexity of '{1}'. Rewrite or refactor the code to decrease its complexity below '{2}'.
                        var arg1 = symbol.Name;
                        var arg2 = codeAnalysisMetricData.CyclomaticComplexity;
                        var arg3 = complexityThreshold.Value + 1;
                        var diagnostic = symbol.CreateDiagnostic(CA1502Rule, arg1, arg2, arg3);
                        compilationContext.ReportDiagnostic(diagnostic);
                    }

                    // CA1505: Avoid unmaintainable code
                    uint? maintainabilityIndexThreshold = getThreshold(CA1505RuleId, symbol.Kind);
                    if (maintainabilityIndexThreshold.HasValue && maintainabilityIndexThreshold.Value > codeAnalysisMetricData.MaintainabilityIndex)
                    {
                        // '{0}' has a maintainability index of '{1}'. Rewrite or refactor the code to increase its maintainability index (MI) above '{2}'.
                        var arg1 = symbol.Name;
                        var arg2 = codeAnalysisMetricData.MaintainabilityIndex;
                        var arg3 = maintainabilityIndexThreshold.Value - 1;
                        var diagnostic = symbol.CreateDiagnostic(CA1505Rule, arg1, arg2, arg3);
                        compilationContext.ReportDiagnostic(diagnostic);
                    }

                    // CA1506: Avoid excessive class coupling
                    uint? classCouplingThreshold = getThreshold(CA1506RuleId, symbol.Kind);
                    if (classCouplingThreshold.HasValue && codeAnalysisMetricData.CoupledNamedTypes.Count > classCouplingThreshold.Value)
                    {
                        // '{0}' is coupled with '{1}' different types from '{2}' different namespaces. Rewrite or refactor the code to decrease its class coupling below '{3}'.
                        var arg1 = symbol.Name;
                        var arg2 = codeAnalysisMetricData.CoupledNamedTypes.Count;
                        var arg3 = GetDistinctContainingNamespacesCount(codeAnalysisMetricData.CoupledNamedTypes);
                        var arg4 = classCouplingThreshold.Value + 1;
                        var diagnostic = symbol.CreateDiagnostic(CA1506Rule, arg1, arg2, arg3, arg4);
                        compilationContext.ReportDiagnostic(diagnostic);
                    }

                    foreach (var child in codeAnalysisMetricData.Children)
                    {
                        analyzeMetricsData(child);
                    }
                }

                uint? getThreshold(string ruleId, SymbolKind symbolKind)
                {
                    // Check if we have custom threshold value for the given ruleId and symbolKind.
                    if (ruleIdToThresholdMap != null &&
                        ruleIdToThresholdMap.TryGetValue(ruleId, out IReadOnlyList<(SymbolKind? symbolKindOpt, uint threshold)>? values))
                    {
                        foreach ((SymbolKind? symbolKindOpt, uint threshold) in values)
                        {
                            if (symbolKindOpt.HasValue && symbolKindOpt.Value == symbolKind)
                            {
                                return threshold;
                            }
                        }

                        if (values.Count == 1 &&
                            values[0].symbolKindOpt == null &&
                            isApplicableByDefault(ruleId, symbolKind))
                        {
                            return values[0].threshold;
                        }
                    }

                    return getDefaultThreshold(ruleId, symbolKind);
                }

                static bool isApplicableByDefault(string ruleId, SymbolKind symbolKind)
                {
                    return ruleId switch
                    {
                        CA1501RuleId => symbolKind == SymbolKind.NamedType,
                        CA1502RuleId => symbolKind == SymbolKind.Method,
                        CA1505RuleId => symbolKind switch
                        {
                            SymbolKind.NamedType or SymbolKind.Method or SymbolKind.Field or SymbolKind.Property or SymbolKind.Event => true,
                            _ => false,
                        },
                        CA1506RuleId => symbolKind switch
                        {
                            SymbolKind.NamedType or SymbolKind.Method or SymbolKind.Field or SymbolKind.Property or SymbolKind.Event => true,
                            _ => false,
                        },
                        _ => throw new NotImplementedException(),
                    };
                }

                static uint? getDefaultThreshold(string ruleId, SymbolKind symbolKind)
                {
                    if (!isApplicableByDefault(ruleId, symbolKind))
                    {
                        return null;
                    }

                    // Compat: we match the default threshold values for old FxCop implementation.
                    return ruleId switch
                    {
                        CA1501RuleId => 5,

                        CA1502RuleId => 25,

                        CA1505RuleId => 10,

                        CA1506RuleId => symbolKind == SymbolKind.NamedType ? 95 : (uint)40,

                        _ => throw new NotImplementedException(),
                    };
                }
            });
        }

        private static bool TryGetRuleIdToThresholdMap(
            ImmutableArray<AdditionalText> additionalFiles,
            CancellationToken cancellationToken,
            [NotNullWhen(returnValue: true)] out AdditionalText? additionalText,
            [NotNullWhen(returnValue: true)] out ImmutableDictionary<string, IReadOnlyList<(SymbolKind?, uint)>>? ruleIdToThresholdMap,
            out List<Diagnostic>? invalidFileDiagnostics)
        {
            invalidFileDiagnostics = null;
            ruleIdToThresholdMap = null;

            // Parse the additional file for code metrics configuration.
            // Return false if there is no such additional file or it contains at least one invalid entry.
            additionalText = TryGetCodeMetricsConfigurationFile(additionalFiles, cancellationToken);
            return additionalText != null &&
                TryParseCodeMetricsConfigurationFile(additionalText, cancellationToken, out ruleIdToThresholdMap, out invalidFileDiagnostics);
        }

        private static AdditionalText? TryGetCodeMetricsConfigurationFile(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, CodeMetricsConfigurationFile))
                {
                    return textFile;
                }
            }

            return null;
        }

        private static bool TryParseCodeMetricsConfigurationFile(
            AdditionalText additionalText,
            CancellationToken cancellationToken,
            [NotNullWhen(returnValue: true)] out ImmutableDictionary<string, IReadOnlyList<(SymbolKind?, uint)>>? ruleIdToThresholdMap,
            out List<Diagnostic>? invalidFileDiagnostics)
        {
            // Parse the additional file with Metric rule ID (which may contain an optional parenthesized SymbolKind suffix) and custom threshold.
            //     # FORMAT:
            //     # 'RuleId'(Optional 'SymbolKind'): 'Threshold'

            ruleIdToThresholdMap = null;
            invalidFileDiagnostics = null;

            var builder = ImmutableDictionary.CreateBuilder<string, IReadOnlyList<(SymbolKind?, uint)>>(StringComparer.OrdinalIgnoreCase);
            var lines = additionalText.GetTextOrEmpty(cancellationToken).Lines;
            foreach (var line in lines)
            {
                var contents = line.ToString().Trim();
                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 key = parts[0];
                if (parts.Length != 2 ||                            // We require exactly one ':' separator in the line.
                    key.Any(char.IsWhiteSpace) ||                   // We do not allow white spaces in rule name.
                    !uint.TryParse(parts[1], out uint threshold))    // Value must be a non-negative integral threshold.
                {
                    isInvalidLine = true;
                }
                else
                {
                    SymbolKind? symbolKind = null;
                    string[] keyParts = key.Split('(');
                    switch (keyParts[0])
                    {
                        case CA1501RuleId:
                        case CA1502RuleId:
                        case CA1505RuleId:
                        case CA1506RuleId:
                            break;

                        default:
                            isInvalidLine = true;
                            break;
                    }

                    if (!isInvalidLine && keyParts.Length > 1)
                    {
                        if (keyParts.Length > 2 ||
                            keyParts[1].Length == 0 ||
                            keyParts[1].Last() != ')')
                        {
                            isInvalidLine = true;
                        }
                        else
                        {
                            // Remove the trailing ')'
                            var symbolKindStr = keyParts[1][0..^1];
                            switch (symbolKindStr)
                            {
                                case "Assembly":
                                    symbolKind = SymbolKind.Assembly;
                                    break;
                                case "Namespace":
                                    symbolKind = SymbolKind.Namespace;
                                    break;
                                case "Type":
                                    symbolKind = SymbolKind.NamedType;
                                    break;
                                case "Method":
                                    symbolKind = SymbolKind.Method;
                                    break;
                                case "Field":
                                    symbolKind = SymbolKind.Field;
                                    break;
                                case "Event":
                                    symbolKind = SymbolKind.Event;
                                    break;
                                case "Property":
                                    symbolKind = SymbolKind.Property;
                                    break;

                                default:
                                    isInvalidLine = true;
                                    break;
                            }
                        }
                    }

                    if (!isInvalidLine)
                    {
                        if (!builder.TryGetValue(keyParts[0], out var values))
                        {
                            values = new List<(SymbolKind?, uint)>();
                            builder.Add(keyParts[0], values);
                        }

                        ((List<(SymbolKind?, uint)>)values).Add((symbolKind, threshold));
                    }
                }

                if (isInvalidLine)
                {
                    // Invalid entry '{0}' in code metrics rule 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(InvalidEntryInCodeMetricsConfigFileRule, location, arg1, arg2);
                    invalidFileDiagnostics.Add(diagnostic);
                }
            }

            ruleIdToThresholdMap = builder.ToImmutable();
            return invalidFileDiagnostics == null;
        }

        private static int GetDistinctContainingNamespacesCount(IEnumerable<INamedTypeSymbol> namedTypes)
        {
            var distinctNamespaces = new HashSet<INamespaceSymbol>();
            foreach (var namedType in namedTypes)
            {
                if (namedType.ContainingNamespace != null)
                {
                    distinctNamespaces.Add(namedType.ContainingNamespace);
                }
            }

            return distinctNamespaces.Count;
        }

        private static bool IsConfiguredToSkipFromInheritanceCount(ISymbol symbol,
            CompilationAnalysisContext context, SyntaxTree tree)
        {
            // Compute code metrics.
            // For the calculation of the inheritance tree, we are allowing specific exclusions:
            //   - all types from System namespaces
            //   - all types/namespaces provided by the user
            // so that the calculation isn't unfair.
            // For example inheriting from WPF/WinForms UserControl makes your class over the default threshold,
            // yet there isn't anything you can do about it.
            var inheritanceExcludedTypes = context.Options.GetInheritanceExcludedSymbolNamesOption(CA1501Rule,
                tree, context.Compilation, defaultForcedValue: "N:System");

            if (inheritanceExcludedTypes.IsEmpty)
            {
                return false;
            }

            while (symbol != null)
            {
                if (inheritanceExcludedTypes.Contains(symbol))
                {
                    return true;
                }

                symbol = symbol.ContainingSymbol;
            }

            return false;
        }
    }
}