diff --git a/docs/Rules/MA0048.md b/docs/Rules/MA0048.md index ebcae9948..0dd29e982 100644 --- a/docs/Rules/MA0048.md +++ b/docs/Rules/MA0048.md @@ -8,13 +8,15 @@ The name of the class must match the name of the file. This rule has two main re - When you navigate code without an IDE, such as on GitHub, GitLab, or most web interfaces, you can quickly find the file that you are interested in. The diagnostic message includes the type kind and name to provide clear context: -- `File name must match type name (class MyClass)` -- `File name must match type name (enum MyEnum)` -- `File name must match type name (interface IMyInterface)` -- `File name must match type name (struct MyStruct)` -- `File name must match type name (record MyRecord)` -- `File name must match type name (record struct MyRecordStruct)` -- `File name must match type name (delegate MyDelegate)` +- `File name must match type name (class MyClass), expected file name: 'MyClass'` +- `File name must match type name (enum MyEnum), expected file name: 'MyEnum'` +- `File name must match type name (interface IMyInterface), expected file name: 'IMyInterface'` +- `File name must match type name (struct MyStruct), expected file name: 'MyStruct'` +- `File name must match type name (record MyRecord), expected file name: 'MyRecord'` +- `File name must match type name (record struct MyRecordStruct), expected file name: 'MyRecordStruct'` +- `File name must match type name (delegate MyDelegate), expected file name: 'MyDelegate'` +- `File name must match type name (class MyHandler), expected file name: a prefix of 'MyHandler'` (when `MA0048.mode = Prefix`) +- `File name must match type name (class MyRequest), expected file name: 'My'` (when `MA0048.mode = LongestCommonPrefix` and the longest common prefix is `My`) ````csharp // filename: Bar.cs @@ -67,6 +69,41 @@ class Foo // compliant class Foo // non compliant { } + +// filename: Perk.cs +class PerkQuery // non compliant (requires MA0048.mode = Prefix) +{ +} + +// filename: Perk.cs +// .editorconfig: MA0048.mode = Prefix +class PerkQuery // compliant +{ +} + +// filename: Sample.cs +// .editorconfig: MA0048.mode = LongestCommonPrefix +class SampleProjectHandler // non compliant +{ +} +class SampleProjectQuery // non compliant +{ +} +class SampleProjectResponse // non compliant (longest common prefix is "SampleProject") +{ +} + +// filename: SampleProject.cs +// .editorconfig: MA0048.mode = LongestCommonPrefix +class SampleProjectHandler // compliant +{ +} +class SampleProjectQuery // compliant +{ +} +class SampleProjectResponse // compliant +{ +} ```` If a file contains multiple types, you can disable the rule locally by using `#pragma warning disable MA0048` or `[SuppressMessage]` @@ -92,6 +129,10 @@ MA0048.exclude_file_local_types = true # Only validate the first type in a file. default: false MA0048.only_validate_first_type = false +# File name matching mode. default: Exact +# Allowed values: Exact, Prefix, LongestCommonPrefix +MA0048.mode = Exact + # Allow "OfT" suffix for generic types with any arity (not just arity 1). default: false MA0048.allow_oft_for_all_generic_types = false @@ -99,3 +140,7 @@ MA0048.allow_oft_for_all_generic_types = false # Pipe-separated list of wildcard patterns dotnet_diagnostic.MA0048.excluded_symbol_names = Foo*|T:MyNamespace.Bar ```` + +For backward compatibility: +- `MA0048.allow_type_name_prefix = true` maps to `MA0048.mode = Prefix`. +- `MA0048.allow_type_name_prefix = true` and `MA0048.use_longest_type_name_prefix = true` map to `MA0048.mode = LongestCommonPrefix`. diff --git a/src/Meziantou.Analyzer/Rules/FileNameMustMatchTypeNameAnalyzer.cs b/src/Meziantou.Analyzer/Rules/FileNameMustMatchTypeNameAnalyzer.cs index f78ca27f4..d340eded3 100644 --- a/src/Meziantou.Analyzer/Rules/FileNameMustMatchTypeNameAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/FileNameMustMatchTypeNameAnalyzer.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.CSharp.Syntax; using Meziantou.Analyzer.Configurations; @@ -11,10 +13,17 @@ namespace Meziantou.Analyzer.Rules; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class FileNameMustMatchTypeNameAnalyzer : DiagnosticAnalyzer { + private enum TypeNameMatchMode + { + Exact, + Prefix, + LongestCommonPrefix, + } + private static readonly DiagnosticDescriptor Rule = new( RuleIdentifiers.FileNameMustMatchTypeName, title: "File name must match type name", - messageFormat: "File name must match type name ({0} {1})", + messageFormat: "File name must match type name ({0} {1}), expected file name: {2}", RuleCategories.Design, DiagnosticSeverity.Warning, isEnabledByDefault: true, @@ -46,6 +55,8 @@ private static void AnalyzeSymbol(SymbolAnalysisContext context) if (symbol.ContainingType is not null) continue; + var typeNameMatchMode = GetTypeNameMatchMode(context, location.SourceTree); + #if ROSLYN_4_4_OR_GREATER if (symbol.IsFileLocal && context.Options.GetConfigurationValue(location.SourceTree, Rule.Id + ".exclude_file_local_types", defaultValue: true)) continue; @@ -103,6 +114,11 @@ private static void AnalyzeSymbol(SymbolAnalysisContext context) if (fileName.Equals(symbolName.AsSpan(), StringComparison.OrdinalIgnoreCase)) continue; + if (!fileName.IsEmpty && symbolName.AsSpan().StartsWith(fileName, StringComparison.OrdinalIgnoreCase) && + (typeNameMatchMode is TypeNameMatchMode.Prefix || + (typeNameMatchMode is TypeNameMatchMode.LongestCommonPrefix && IsLongestTypeNamePrefix(context, location.SourceTree, fileName)))) + continue; + if (symbol.Arity > 0) { // Type`1 @@ -121,7 +137,7 @@ private static void AnalyzeSymbol(SymbolAnalysisContext context) continue; } - context.ReportDiagnostic(Rule, location, GetTypeKindDisplayString(symbol), symbolName); + context.ReportDiagnostic(Rule, location, GetTypeKindDisplayString(symbol), symbolName, GetExpectedFileName(context, symbol, location.SourceTree, typeNameMatchMode)); } } @@ -140,6 +156,150 @@ private static ReadOnlySpan GetFileName(ReadOnlySpan filePath) return filePath[..index]; } + private static TypeNameMatchMode GetTypeNameMatchMode(SymbolAnalysisContext context, SyntaxTree sourceTree) + { + var mode = context.Options.GetConfigurationValue(sourceTree, Rule.Id + ".mode", defaultValue: string.Empty); + if (mode.Equals(nameof(TypeNameMatchMode.Exact), StringComparison.OrdinalIgnoreCase)) + return TypeNameMatchMode.Exact; + + if (mode.Equals(nameof(TypeNameMatchMode.Prefix), StringComparison.OrdinalIgnoreCase)) + return TypeNameMatchMode.Prefix; + + if (mode.Equals(nameof(TypeNameMatchMode.LongestCommonPrefix), StringComparison.OrdinalIgnoreCase)) + return TypeNameMatchMode.LongestCommonPrefix; + + // Backward compatibility + if (!context.Options.GetConfigurationValue(sourceTree, Rule.Id + ".allow_type_name_prefix", defaultValue: false)) + return TypeNameMatchMode.Exact; + + return context.Options.GetConfigurationValue(sourceTree, Rule.Id + ".use_longest_type_name_prefix", defaultValue: false) + ? TypeNameMatchMode.LongestCommonPrefix + : TypeNameMatchMode.Prefix; + } + + private static string GetExpectedFileName(SymbolAnalysisContext context, INamedTypeSymbol symbol, SyntaxTree sourceTree, TypeNameMatchMode typeNameMatchMode) + { + return typeNameMatchMode switch + { + TypeNameMatchMode.Exact => "'" + symbol.Name + "'", + TypeNameMatchMode.Prefix => "a prefix of '" + symbol.Name + "'", + TypeNameMatchMode.LongestCommonPrefix => GetExpectedLongestCommonPrefixFileName(context, sourceTree, symbol.Name), + _ => throw new ArgumentOutOfRangeException(nameof(typeNameMatchMode)), + }; + } + + private static string GetExpectedLongestCommonPrefixFileName(SymbolAnalysisContext context, SyntaxTree sourceTree, string fallbackTypeName) + { + var typeNames = GetTopLevelTypeNames(context, sourceTree); + var longestCommonPrefixLength = GetLongestCommonPrefixLength(typeNames); + if (longestCommonPrefixLength <= 0) + return "'" + fallbackTypeName + "'"; + + return "'" + typeNames![0].AsSpan(0, longestCommonPrefixLength).ToString() + "'"; + } + + private static bool IsLongestTypeNamePrefix(SymbolAnalysisContext context, SyntaxTree sourceTree, ReadOnlySpan fileName) + { + var typeNames = GetTopLevelTypeNames(context, sourceTree); + var longestCommonPrefixLength = GetLongestCommonPrefixLength(typeNames); + if (longestCommonPrefixLength < 0) + return true; + + if (longestCommonPrefixLength == 0) + return false; + + return longestCommonPrefixLength == fileName.Length && + typeNames![0].AsSpan(0, longestCommonPrefixLength).Equals(fileName, StringComparison.OrdinalIgnoreCase); + } + + private static List? GetTopLevelTypeNames(SymbolAnalysisContext context, SyntaxTree sourceTree) + { + var root = sourceTree.GetRoot(context.CancellationToken); + List? typeNames = null; + +#if ROSLYN_4_4_OR_GREATER + var excludeFileLocalTypes = context.Options.GetConfigurationValue(sourceTree, Rule.Id + ".exclude_file_local_types", defaultValue: true); +#endif + + foreach (var node in root.DescendantNodesAndSelf(descendIntoChildren: static node => !IsTypeDeclaration(node))) + { + if (!TryGetTypeDeclarationName(node, out var typeName)) + continue; + +#if ROSLYN_4_4_OR_GREATER + if (excludeFileLocalTypes && IsFileLocalType(node)) + continue; +#endif + + typeNames ??= new List(); + typeNames.Add(typeName); + } + + return typeNames; + } + + // -1: no/one type, 0: no common prefix, >0: prefix length + private static int GetLongestCommonPrefixLength(List? typeNames) + { + if (typeNames is null || typeNames.Count <= 1) + return -1; + + var commonPrefixLength = typeNames[0].Length; + for (var i = 1; i < typeNames.Count; i++) + { + commonPrefixLength = GetCommonPrefixLength(typeNames[0], typeNames[i], commonPrefixLength); + if (commonPrefixLength == 0) + return 0; + } + + return commonPrefixLength; + } + + private static int GetCommonPrefixLength(string left, string right, int maxLength) + { + var length = Math.Min(maxLength, right.Length); + var index = 0; + while (index < length && char.ToUpperInvariant(left[index]) == char.ToUpperInvariant(right[index])) + { + index++; + } + + return index; + } + + private static bool IsTypeDeclaration(SyntaxNode node) + { + return node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax; + } + + private static bool TryGetTypeDeclarationName(SyntaxNode node, [NotNullWhen(true)] out string? typeName) + { + switch (node) + { + case BaseTypeDeclarationSyntax typeDeclaration: + typeName = typeDeclaration.Identifier.ValueText; + return true; + case DelegateDeclarationSyntax delegateDeclaration: + typeName = delegateDeclaration.Identifier.ValueText; + return true; + default: + typeName = null; + return false; + } + } + +#if ROSLYN_4_4_OR_GREATER + private static bool IsFileLocalType(SyntaxNode node) + { + return node switch + { + BaseTypeDeclarationSyntax typeDeclaration => typeDeclaration.Modifiers.Any(SyntaxKind.FileKeyword), + DelegateDeclarationSyntax delegateDeclaration => delegateDeclaration.Modifiers.Any(SyntaxKind.FileKeyword), + _ => false, + }; + } +#endif + /// /// Implemented wildcard pattern match /// diff --git a/tests/Meziantou.Analyzer.Test/Rules/FileNameMustMatchTypeNameAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/FileNameMustMatchTypeNameAnalyzerTests.cs index df0b21110..117d1ee2a 100755 --- a/tests/Meziantou.Analyzer.Test/Rules/FileNameMustMatchTypeNameAnalyzerTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/FileNameMustMatchTypeNameAnalyzerTests.cs @@ -20,7 +20,7 @@ class [|Sample|] { } """) - .ShouldReportDiagnosticWithMessage("File name must match type name (class Sample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (class Sample), expected file name: 'Sample'") .ValidateAsync(); } @@ -198,6 +198,83 @@ class [|Test0|] .ValidateAsync(); } + [Fact] + public async Task DoesNotMatchFileNamePrefix_WithoutConfiguration() + { + await CreateProjectBuilder() + .WithSourceCode(fileName: "Perk.cs", """ + class [|PerkQuery|] {} + """) + .ValidateAsync(); + } + + [Fact] + public async Task MatchFileNamePrefix_WithModeConfiguration() + { + await CreateProjectBuilder() + .WithSourceCode(fileName: "Perk.cs", """ + class PerkQuery {} + class PerkResponse {} + class PerkHandler {} + class [|DummyHandler|] {} + """) + .AddAnalyzerConfiguration("MA0048.mode", "Prefix") + .ShouldReportDiagnosticWithMessage("File name must match type name (class DummyHandler), expected file name: a prefix of 'DummyHandler'") + .ValidateAsync(); + } + + [Fact] + public async Task MatchFileNamePrefix_WithLegacyConfiguration() + { + await CreateProjectBuilder() + .WithSourceCode(fileName: "Perk.cs", """ + class PerkQuery {} + """) + .AddAnalyzerConfiguration("MA0048.allow_type_name_prefix", "true") + .ValidateAsync(); + } + + [Fact] + public async Task MatchFileNamePrefix_LongestCommonPrefixMode_WithoutLongestCommonPrefixInFileName() + { + await CreateProjectBuilder() + .WithSourceCode(fileName: "Sample.cs", """ + class [|SampleProjectHandler|] {} + class [|SampleProjectQuery|] {} + class [|SampleProjectResponse|] {} + """) + .AddAnalyzerConfiguration("MA0048.mode", "LongestCommonPrefix") + .ShouldReportDiagnosticWithMessage("File name must match type name (class SampleProjectHandler), expected file name: 'SampleProject'") + .ValidateAsync(); + } + + [Fact] + public async Task MatchFileNamePrefix_LongestCommonPrefixMode_WithLongestCommonPrefixInFileName() + { + await CreateProjectBuilder() + .WithSourceCode(fileName: "SampleProject.cs", """ + class SampleProjectHandler {} + class SampleProjectQuery {} + class SampleProjectResponse {} + """) + .AddAnalyzerConfiguration("MA0048.mode", "LongestCommonPrefix") + .ValidateAsync(); + } + + [Fact] + public async Task MatchFileNamePrefix_LongestCommonPrefix_WithLegacyConfiguration() + { + await CreateProjectBuilder() + .WithSourceCode(fileName: "SampleProject.cs", """ + class SampleProjectHandler {} + class SampleProjectQuery {} + class SampleProjectResponse {} + """) + .AddAnalyzerConfiguration("MA0048.allow_type_name_prefix", "true") + .AddAnalyzerConfiguration("MA0048.use_longest_type_name_prefix", "true") + .ValidateAsync(); + } + [Fact] public async Task MatchOnlyFirstType_class1() { @@ -430,7 +507,7 @@ file class [|Sample|] } """) .AddAnalyzerConfiguration("MA0048.exclude_file_local_types", "false") - .ShouldReportDiagnosticWithMessage("File name must match type name (class Sample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (class Sample), expected file name: 'Sample'") .ValidateAsync(); } @@ -443,7 +520,7 @@ class [|Sample|] { } """) - .ShouldReportDiagnosticWithMessage("File name must match type name (class Sample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (class Sample), expected file name: 'Sample'") .ValidateAsync(); } @@ -456,7 +533,7 @@ struct [|Sample|] { } """) - .ShouldReportDiagnosticWithMessage("File name must match type name (struct Sample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (struct Sample), expected file name: 'Sample'") .ValidateAsync(); } @@ -469,7 +546,7 @@ interface [|ISample|] { } """) - .ShouldReportDiagnosticWithMessage("File name must match type name (interface ISample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (interface ISample), expected file name: 'ISample'") .ValidateAsync(); } @@ -483,7 +560,7 @@ enum [|Sample|] Value1 } """) - .ShouldReportDiagnosticWithMessage("File name must match type name (enum Sample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (enum Sample), expected file name: 'Sample'") .ValidateAsync(); } @@ -494,7 +571,7 @@ await CreateProjectBuilder() .WithSourceCode(fileName: "Test.cs", """ record [|Sample|]; """) - .ShouldReportDiagnosticWithMessage("File name must match type name (record Sample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (record Sample), expected file name: 'Sample'") .ValidateAsync(); } @@ -507,7 +584,7 @@ await CreateProjectBuilder() record struct [|Sample|]; """) .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp11) - .ShouldReportDiagnosticWithMessage("File name must match type name (record struct Sample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (record struct Sample), expected file name: 'Sample'") .ValidateAsync(); } #endif @@ -519,7 +596,7 @@ await CreateProjectBuilder() .WithSourceCode(fileName: "Test.cs", """ delegate void [|Sample|](); """) - .ShouldReportDiagnosticWithMessage("File name must match type name (delegate Sample)") + .ShouldReportDiagnosticWithMessage("File name must match type name (delegate Sample), expected file name: 'Sample'") .ValidateAsync(); } #endif