diff --git a/README.md b/README.md index 68afabfa..4200d685 100755 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0171](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0171.md)|Usage|Use pattern matching instead of inequality operators for discrete value|ℹ️|❌|✔️| |[MA0172](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0172.md)|Usage|Both sides of the logical operation are identical|⚠️|❌|❌| |[MA0173](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0173.md)|Design|Use LazyInitializer.EnsureInitialize|ℹ️|✔️|❌| +|[MA0174](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0174.md)|Style|Record should use explicit 'class' keyword|ℹ️|❌|❌| +|[MA0175](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0175.md)|Style|Record should not use explicit 'class' keyword|ℹ️|❌|❌| diff --git a/docs/README.md b/docs/README.md index df614d90..9732a745 100755 --- a/docs/README.md +++ b/docs/README.md @@ -173,6 +173,8 @@ |[MA0171](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0171.md)|Usage|Use pattern matching instead of inequality operators for discrete value|ℹ️|❌|✔️| |[MA0172](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0172.md)|Usage|Both sides of the logical operation are identical|⚠️|❌|❌| |[MA0173](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0173.md)|Design|Use LazyInitializer.EnsureInitialize|ℹ️|✔️|❌| +|[MA0174](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0174.md)|Style|Record should use explicit 'class' keyword|ℹ️|❌|❌| +|[MA0175](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0175.md)|Style|Record should not use explicit 'class' keyword|ℹ️|❌|❌| |Id|Suppressed rule|Justification| |--|---------------|-------------| @@ -700,6 +702,12 @@ dotnet_diagnostic.MA0172.severity = none # MA0173: Use LazyInitializer.EnsureInitialize dotnet_diagnostic.MA0173.severity = suggestion + +# MA0174: Record should use explicit 'class' keyword +dotnet_diagnostic.MA0174.severity = none + +# MA0175: Record should not use explicit 'class' keyword +dotnet_diagnostic.MA0175.severity = none ``` # .editorconfig - all rules disabled @@ -1220,4 +1228,10 @@ dotnet_diagnostic.MA0172.severity = none # MA0173: Use LazyInitializer.EnsureInitialize dotnet_diagnostic.MA0173.severity = none + +# MA0174: Record should use explicit 'class' keyword +dotnet_diagnostic.MA0174.severity = none + +# MA0175: Record should not use explicit 'class' keyword +dotnet_diagnostic.MA0175.severity = none ``` diff --git a/docs/Rules/MA0174.md b/docs/Rules/MA0174.md new file mode 100644 index 00000000..b705d736 --- /dev/null +++ b/docs/Rules/MA0174.md @@ -0,0 +1,12 @@ +# MA0174 - Record should use explicit 'class' keyword + +This rule suggests adding the explicit `class` keyword to record declarations that don't specify it. + +```csharp +public sealed record Customer; // non-compliant +public sealed record class Customer; // compliant +``` + +## Related rules + +- [MA0175](MA0175.md) - Record should not use explicit 'class' keyword (opposite rule) diff --git a/docs/Rules/MA0175.md b/docs/Rules/MA0175.md new file mode 100644 index 00000000..a32f60c5 --- /dev/null +++ b/docs/Rules/MA0175.md @@ -0,0 +1,12 @@ +# MA0175 - Record should not use explicit 'class' keyword + +This rule suggests adding the explicit `class` keyword to record declarations that don't specify it. + +```csharp +public sealed record class Customer; // non-compliant +public sealed record Customer; // compliant +``` + +## Related rules + +- [MA0174](MA0174.md) - Record should use explicit 'class' keyword (opposite rule) diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index 5cad7507..02b9eeee 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -517,3 +517,9 @@ dotnet_diagnostic.MA0172.severity = none # MA0173: Use LazyInitializer.EnsureInitialize dotnet_diagnostic.MA0173.severity = suggestion + +# MA0174: Record should use explicit 'class' keyword +dotnet_diagnostic.MA0174.severity = none + +# MA0175: Record should not use explicit 'class' keyword +dotnet_diagnostic.MA0175.severity = none diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 1770f021..b1856ed3 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -517,3 +517,9 @@ dotnet_diagnostic.MA0172.severity = none # MA0173: Use LazyInitializer.EnsureInitialize dotnet_diagnostic.MA0173.severity = none + +# MA0174: Record should use explicit 'class' keyword +dotnet_diagnostic.MA0174.severity = none + +# MA0175: Record should not use explicit 'class' keyword +dotnet_diagnostic.MA0175.severity = none diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index 56b55de8..91e44d7f 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -176,6 +176,8 @@ internal static class RuleIdentifiers public const string UsePatternMatchingInsteadOfHasvalue = "MA0171"; public const string BothSideOfTheConditionAreIdentical = "MA0172"; public const string UseLazyInitializerEnsureInitialize = "MA0173"; + public const string RecordClassDeclarationShouldBeExplicit = "MA0174"; + public const string RecordClassDeclarationShouldBeImplicit = "MA0175"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/RecordClassDeclarationShouldBeExplicitAnalyzer.cs b/src/Meziantou.Analyzer/Rules/RecordClassDeclarationShouldBeExplicitAnalyzer.cs new file mode 100644 index 00000000..cc529bbb --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/RecordClassDeclarationShouldBeExplicitAnalyzer.cs @@ -0,0 +1,48 @@ +#if CSHARP10_OR_GREATER +using System.Collections.Immutable; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Meziantou.Analyzer.Rules; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class RecordClassDeclarationShouldBeExplicitAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.RecordClassDeclarationShouldBeExplicit, + title: "Record should use explicit 'class' keyword", + messageFormat: "Record should be declared with explicit 'class' keyword", + RuleCategories.Style, + DiagnosticSeverity.Info, + isEnabledByDefault: false, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.RecordClassDeclarationShouldBeExplicit)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSyntaxNodeAction(AnalyzeRecordDeclaration, SyntaxKind.RecordDeclaration); + } + + private static void AnalyzeRecordDeclaration(SyntaxNodeAnalysisContext context) + { + var recordDeclaration = (RecordDeclarationSyntax)context.Node; + + // Check if this is a record without the explicit 'class' keyword + // RecordDeclarationSyntax.ClassOrStructKeyword will be null/missing for implicit record classes + // and will contain 'class' or 'struct' for explicit ones + if (recordDeclaration.ClassOrStructKeyword.IsKind(SyntaxKind.None)) + { + // This is an implicit record class (no 'class' or 'struct' keyword) + // Report diagnostic on the record keyword + context.ReportDiagnostic(Rule, recordDeclaration.Keyword); + } + } +} +#endif diff --git a/src/Meziantou.Analyzer/Rules/RecordClassDeclarationShouldBeImplicitAnalyzer.cs b/src/Meziantou.Analyzer/Rules/RecordClassDeclarationShouldBeImplicitAnalyzer.cs new file mode 100644 index 00000000..0422fc1b --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/RecordClassDeclarationShouldBeImplicitAnalyzer.cs @@ -0,0 +1,45 @@ +#if CSHARP10_OR_GREATER +using System.Collections.Immutable; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Meziantou.Analyzer.Rules; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class RecordClassDeclarationShouldBeImplicitAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.RecordClassDeclarationShouldBeImplicit, + title: "Record should not use explicit 'class' keyword", + messageFormat: "Record should not be declared with explicit 'class' keyword", + RuleCategories.Style, + DiagnosticSeverity.Info, + isEnabledByDefault: false, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.RecordClassDeclarationShouldBeImplicit)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSyntaxNodeAction(AnalyzeRecordDeclaration, SyntaxKind.RecordDeclaration); + } + + private static void AnalyzeRecordDeclaration(SyntaxNodeAnalysisContext context) + { + var recordDeclaration = (RecordDeclarationSyntax)context.Node; + + // Check if this is a record with the explicit 'class' keyword + if (recordDeclaration.ClassOrStructKeyword.IsKind(SyntaxKind.ClassKeyword)) + { + // This is an explicit record class - report diagnostic on the 'class' keyword + context.ReportDiagnostic(Rule, recordDeclaration.ClassOrStructKeyword); + } + } +} +#endif \ No newline at end of file diff --git a/tests/Meziantou.Analyzer.Test/Rules/RecordClassDeclarationShouldBeExplicitAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/RecordClassDeclarationShouldBeExplicitAnalyzerTests.cs new file mode 100644 index 00000000..f2f980d1 --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/RecordClassDeclarationShouldBeExplicitAnalyzerTests.cs @@ -0,0 +1,154 @@ +#if CSHARP10_OR_GREATER +using Meziantou.Analyzer.Rules; +using Meziantou.Analyzer.Test.Helpers; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class RecordClassDeclarationShouldBeExplicitAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer() + .WithTargetFramework(TargetFramework.NetLatest); + } + + [Fact] + public async Task ImplicitRecordClass_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public [|record|] Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ImplicitRecordClass_WithModifiers_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public sealed [|record|] Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordClass_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record class Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordStruct_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record struct Target { } + """) + .ValidateAsync(); + } + +[Fact] + public async Task RegularClass_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public class Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task RegularStruct_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public struct Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ImplicitRecordClass_WithParameters_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public [|record|] Target(int Id) { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordClass_WithParameters_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record class Target(int Id) { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ImplicitRecordClass_Generic_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public [|record|] Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ImplicitRecordClass_InNamespace_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + namespace MyNamespace + { + public [|record|] Target { } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ImplicitRecordClass_WithInheritance_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public abstract [|record|] BaseRecord { } + public [|record|] Target : BaseRecord { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task RxplicitRecordClass_WithInheritance_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public abstract record class BaseRecord { } + public record class Target : BaseRecord { } + """) + .ValidateAsync(); + } +} +#endif \ No newline at end of file diff --git a/tests/Meziantou.Analyzer.Test/Rules/RecordClassDeclarationShouldBeImplicitAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/RecordClassDeclarationShouldBeImplicitAnalyzerTests.cs new file mode 100644 index 00000000..c5a4b44e --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/RecordClassDeclarationShouldBeImplicitAnalyzerTests.cs @@ -0,0 +1,142 @@ +#if CSHARP10_OR_GREATER +using Meziantou.Analyzer.Rules; +using Meziantou.Analyzer.Test.Helpers; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class RecordClassDeclarationShouldBeImplicitAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer() + .WithTargetFramework(TargetFramework.NetLatest); + } + + [Fact] + public async Task ExplicitRecordClass_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record [|class|] Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordClass_WithModifiers_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public sealed record [|class|] Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ImplicitRecordClass_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordStruct_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record struct Target { } + """) + .ValidateAsync(); + } + +[Fact] + public async Task RegularClass_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public class Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task RegularStruct_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public struct Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordClass_WithParameters_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record [|class|] Target(int Id) { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ImplicitRecordClass_WithParameters_ShouldNotReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record Target(int Id) { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordClass_Generic_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public record [|class|] Target { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordClass_InNamespace_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + namespace MyNamespace + { + public record [|class|] Target { } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ExplicitRecordClass_WithInheritance_ShouldReportDiagnostic() + { + + await CreateProjectBuilder() + .WithSourceCode(""" + public abstract record BaseRecord { } + public record [|class|] Target : BaseRecord { } + """) + .ValidateAsync(); + } +} +#endif \ No newline at end of file