Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 52 additions & 7 deletions docs/Rules/MA0048.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,6 +69,41 @@ class Foo<TKey, TResult> // compliant
class Foo<TKey, TResult> // 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]`
Expand All @@ -92,10 +129,18 @@ 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

# Ignore certain symbols. default: none
# 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`.
164 changes: 162 additions & 2 deletions src/Meziantou.Analyzer/Rules/FileNameMustMatchTypeNameAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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));
}
}

Expand All @@ -140,6 +156,150 @@ private static ReadOnlySpan<char> GetFileName(ReadOnlySpan<char> 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<char> 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<string>? GetTopLevelTypeNames(SymbolAnalysisContext context, SyntaxTree sourceTree)
{
var root = sourceTree.GetRoot(context.CancellationToken);
List<string>? 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<string>();
typeNames.Add(typeName);
}

return typeNames;
}

// -1: no/one type, 0: no common prefix, >0: prefix length
private static int GetLongestCommonPrefixLength(List<string>? 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

/// <summary>
/// Implemented wildcard pattern match
/// </summary>
Expand Down
Loading
Loading