From 2e35f7cefd362ebae6b7c764a4223b0f89ef2da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Sat, 20 Jun 2026 12:45:08 -0400 Subject: [PATCH] Add MA0206 configuration and identifier --- README.md | 1 + docs/README.md | 1 + docs/Rules/MA0206.md | 24 ++++ ...UnnecessaryBracesInTypeDeclarationFixer.cs | 90 ++++++++++++ .../configuration/all-errors.editorconfig | 3 + .../all-suggestions.editorconfig | 3 + .../configuration/all-warnings.editorconfig | 3 + .../configuration/default.editorconfig | 3 + .../configuration/none.editorconfig | 3 + src/Meziantou.Analyzer/RuleIdentifiers.cs | 1 + ...ecessaryBracesInTypeDeclarationAnalyzer.cs | 83 +++++++++++ ...aryBracesInTypeDeclarationAnalyzerTests.cs | 133 ++++++++++++++++++ 12 files changed, 348 insertions(+) create mode 100644 docs/Rules/MA0206.md create mode 100644 src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryBracesInTypeDeclarationFixer.cs create mode 100644 src/Meziantou.Analyzer/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzer.cs create mode 100644 tests/Meziantou.Analyzer.Test/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzerTests.cs diff --git a/README.md b/README.md index 94809d2f..adb72616 100755 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0201](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0201.md)|Usage|Do not use zero-valued enum flags in flag checks|⚠️|✔️|❌| |[MA0202](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0202.md)|Design|Conditional compilation branches have identical code|⚠️|✔️|✔️| |[MA0203](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0203.md)|Design|Do not use return tag for void method|⚠️|✔️|❌| +|[MA0206](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0206.md)|Style|Remove unnecessary braces in type declaration|ℹ️|✔️|✔️| diff --git a/docs/README.md b/docs/README.md index 59543821..2ad50c93 100755 --- a/docs/README.md +++ b/docs/README.md @@ -202,6 +202,7 @@ |[MA0201](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0201.md)|Usage|Do not use zero-valued enum flags in flag checks|⚠️|✔️|❌| |[MA0202](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0202.md)|Design|Conditional compilation branches have identical code|⚠️|✔️|✔️| |[MA0203](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0203.md)|Design|Do not use return tag for void method|⚠️|✔️|❌| +|[MA0206](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0206.md)|Style|Remove unnecessary braces in type declaration|ℹ️|✔️|✔️| |Id|Suppressed rule|Justification| |--|---------------|-------------| diff --git a/docs/Rules/MA0206.md b/docs/Rules/MA0206.md new file mode 100644 index 00000000..6beea330 --- /dev/null +++ b/docs/Rules/MA0206.md @@ -0,0 +1,24 @@ +# MA0206 - Remove unnecessary braces in type declaration + +Sources: [RemoveUnnecessaryBracesInTypeDeclarationAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzer.cs), [RemoveUnnecessaryBracesInTypeDeclarationFixer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryBracesInTypeDeclarationFixer.cs) + + +This rule reports empty braces in type declarations that can be replaced with a semicolon. + +The rule applies to positional records and to C# 12 type declarations with primary constructors when the type body contains no members, comments, or directives. + +## Non-compliant code + +````csharp +public record Foo(string Value1, string Value2) { } + +public class Bar(string Value1, string Value2) { } +```` + +## Compliant code + +````csharp +public record Foo(string Value1, string Value2); + +public class Bar(string Value1, string Value2); +```` diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryBracesInTypeDeclarationFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryBracesInTypeDeclarationFixer.cs new file mode 100644 index 00000000..3b4b2308 --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryBracesInTypeDeclarationFixer.cs @@ -0,0 +1,90 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Formatting; + +namespace Meziantou.Analyzer.Rules; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public sealed class RemoveUnnecessaryBracesInTypeDeclarationFixer : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(RuleIdentifiers.RemoveUnnecessaryBracesInTypeDeclaration); + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var typeDeclaration = root?.FindNode(context.Span, getInnermostNodeForTie: true).FirstAncestorOrSelf(); + if (typeDeclaration is null || !CanRemoveBraces(typeDeclaration)) + return; + + const string Title = "Remove unnecessary braces"; + var codeAction = CodeAction.Create( + Title, + ct => RemoveBraces(context.Document, typeDeclaration, ct), + equivalenceKey: Title); + + context.RegisterCodeFix(codeAction, context.Diagnostics); + } + + private static async Task RemoveBraces(Document document, TypeDeclarationSyntax typeDeclaration, CancellationToken cancellationToken) + { + var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + var semicolonToken = SyntaxFactory.Token(SyntaxKind.SemicolonToken) + .WithTrailingTrivia(typeDeclaration.CloseBraceToken.TrailingTrivia); + + var newNode = typeDeclaration + .WithOpenBraceToken(default) + .WithCloseBraceToken(default) + .WithSemicolonToken(semicolonToken) + .WithAdditionalAnnotations(Formatter.Annotation); + + editor.ReplaceNode(typeDeclaration, newNode); + return editor.GetChangedDocument(); + } + + private static bool CanRemoveBraces(TypeDeclarationSyntax typeDeclaration) + { + if (!HasParameterList(typeDeclaration)) + return false; + + if (typeDeclaration.Members.Count != 0) + return false; + + if (typeDeclaration.OpenBraceToken.IsMissing || typeDeclaration.CloseBraceToken.IsMissing || typeDeclaration.SemicolonToken.IsKind(SyntaxKind.SemicolonToken)) + return false; + + return !ContainsCommentOrDirectiveInBraces(typeDeclaration); + } + + private static bool HasParameterList(TypeDeclarationSyntax typeDeclaration) + { + if (typeDeclaration is RecordDeclarationSyntax { ParameterList: not null }) + return true; + +#if CSHARP12_OR_GREATER + if (!typeDeclaration.GetCSharpLanguageVersion().IsCSharp12OrAbove()) + return false; + + if (typeDeclaration is ClassDeclarationSyntax { ParameterList: not null } or StructDeclarationSyntax { ParameterList: not null }) + return true; +#endif + + return false; + } + + private static bool ContainsCommentOrDirectiveInBraces(TypeDeclarationSyntax typeDeclaration) + { + return typeDeclaration.OpenBraceToken.TrailingTrivia + .Concat(typeDeclaration.CloseBraceToken.LeadingTrivia) + .Any(static trivia => trivia.IsDirective || trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia)); + } +} diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig index 4910b616..51e4d9e7 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig @@ -604,3 +604,6 @@ dotnet_diagnostic.MA0202.severity = error # MA0203: Do not use return tag for void method dotnet_diagnostic.MA0203.severity = error + +# MA0206: Remove unnecessary braces in type declaration +dotnet_diagnostic.MA0206.severity = error diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig index 95bc2894..f4ad2d22 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig @@ -604,3 +604,6 @@ dotnet_diagnostic.MA0202.severity = suggestion # MA0203: Do not use return tag for void method dotnet_diagnostic.MA0203.severity = suggestion + +# MA0206: Remove unnecessary braces in type declaration +dotnet_diagnostic.MA0206.severity = suggestion diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig index fcd8960a..227b23b9 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig @@ -604,3 +604,6 @@ dotnet_diagnostic.MA0202.severity = warning # MA0203: Do not use return tag for void method dotnet_diagnostic.MA0203.severity = warning + +# MA0206: Remove unnecessary braces in type declaration +dotnet_diagnostic.MA0206.severity = warning diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index f3e2995a..0e1b0858 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -604,3 +604,6 @@ dotnet_diagnostic.MA0202.severity = warning # MA0203: Do not use return tag for void method dotnet_diagnostic.MA0203.severity = warning + +# MA0206: Remove unnecessary braces in type declaration +dotnet_diagnostic.MA0206.severity = suggestion diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 75e27a31..46ffe87a 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -604,3 +604,6 @@ dotnet_diagnostic.MA0202.severity = none # MA0203: Do not use return tag for void method dotnet_diagnostic.MA0203.severity = none + +# MA0206: Remove unnecessary braces in type declaration +dotnet_diagnostic.MA0206.severity = none diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index f85ae919..4a304f6c 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -203,6 +203,7 @@ internal static class RuleIdentifiers public const string DoNotUseZeroValuedEnumFlagsInFlagChecks = "MA0201"; public const string ConditionalCompilationBranchesAreIdentical = "MA0202"; public const string DoNotUseReturnTagForVoidMethod = "MA0203"; + public const string RemoveUnnecessaryBracesInTypeDeclaration = "MA0206"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzer.cs b/src/Meziantou.Analyzer/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzer.cs new file mode 100644 index 00000000..bfc6dd32 --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzer.cs @@ -0,0 +1,83 @@ +using System.Collections.Immutable; +using System.Linq; +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 RemoveUnnecessaryBracesInTypeDeclarationAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.RemoveUnnecessaryBracesInTypeDeclaration, + title: "Remove unnecessary braces in type declaration", + messageFormat: "Remove unnecessary braces in type declaration", + RuleCategories.Style, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.RemoveUnnecessaryBracesInTypeDeclaration)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterSyntaxNodeAction(AnalyzeTypeDeclaration, SyntaxKind.RecordDeclaration); +#if CSHARP12_OR_GREATER + context.RegisterSyntaxNodeAction(AnalyzeTypeDeclaration, SyntaxKind.ClassDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeTypeDeclaration, SyntaxKind.StructDeclaration); +#endif + } + + private static void AnalyzeTypeDeclaration(SyntaxNodeAnalysisContext context) + { + var typeDeclaration = (TypeDeclarationSyntax)context.Node; + if (!CanRemoveBraces(typeDeclaration, context.Compilation.GetCSharpLanguageVersion())) + return; + + context.ReportDiagnostic(Diagnostic.Create(Rule, typeDeclaration.OpenBraceToken.GetLocation())); + } + + private static bool CanRemoveBraces(TypeDeclarationSyntax typeDeclaration, LanguageVersion languageVersion) + { + if (!HasParameterList(typeDeclaration, languageVersion)) + return false; + + if (typeDeclaration.Members.Count != 0) + return false; + + if (typeDeclaration.OpenBraceToken.IsMissing || typeDeclaration.CloseBraceToken.IsMissing || typeDeclaration.SemicolonToken.IsKind(SyntaxKind.SemicolonToken)) + return false; + + return !ContainsCommentOrDirectiveInBraces(typeDeclaration); + } + + private static bool HasParameterList(TypeDeclarationSyntax typeDeclaration, LanguageVersion languageVersion) + { + if (typeDeclaration is RecordDeclarationSyntax { ParameterList: not null }) + return true; + +#if CSHARP12_OR_GREATER + if (!languageVersion.IsCSharp12OrAbove()) + return false; + + if (typeDeclaration is ClassDeclarationSyntax { ParameterList: not null } or StructDeclarationSyntax { ParameterList: not null }) + return true; +#endif + + return false; + } + + private static bool ContainsCommentOrDirectiveInBraces(TypeDeclarationSyntax typeDeclaration) + { + return typeDeclaration.OpenBraceToken.TrailingTrivia + .Concat(typeDeclaration.CloseBraceToken.LeadingTrivia) + .Any(static trivia => trivia.IsDirective || trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia)); + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzerTests.cs new file mode 100644 index 00000000..c844b822 --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/RemoveUnnecessaryBracesInTypeDeclarationAnalyzerTests.cs @@ -0,0 +1,133 @@ +using Meziantou.Analyzer.Rules; +using Microsoft.CodeAnalysis.CSharp; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class RemoveUnnecessaryBracesInTypeDeclarationAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer() + .WithCodeFixProvider(); + } + + [Fact] + public async Task PositionalRecord_WithEmptyBraces() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp9) + .WithSourceCode(""" + public record Foo(string Value1, string Value2) [|{|]} + """) + .ValidateAsync(); + } + + [Fact] + public async Task PositionalRecord_CodeFix() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp9) + .WithSourceCode(""" + public record Foo(string Value1, string Value2) [|{|]} + """) + .ShouldFixCodeWith(""" + public record Foo(string Value1, string Value2); + """) + .ValidateAsync(); + } + + [Fact] + public async Task PositionalRecord_WithSemicolon() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp9) + .WithSourceCode(""" + public record Foo(string Value1, string Value2); + """) + .ValidateAsync(); + } + + [Fact] + public async Task PositionalRecord_WithMember() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp9) + .WithSourceCode(""" + public record Foo(string Value1, string Value2) + { + public string Value3 { get; init; } = ""; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task PositionalRecord_WithComment() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp9) + .WithSourceCode(""" + public record Foo(string Value1, string Value2) + { + // Keep this comment + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task RecordWithoutParameterList() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp9) + .WithSourceCode(""" + public record Foo + { + } + """) + .ValidateAsync(); + } + +#if CSHARP12_OR_GREATER + [Fact] + public async Task ClassPrimaryConstructor_WithEmptyBraces() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp12) + .WithSourceCode(""" + public class Foo(string Value1, string Value2) [|{|]} + """) + .ValidateAsync(); + } + + [Fact] + public async Task ClassPrimaryConstructor_CodeFix() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp12) + .WithSourceCode(""" + public class Foo(string Value1, string Value2) [|{|]} + """) + .ShouldFixCodeWith(""" + public class Foo(string Value1, string Value2); + """) + .ValidateAsync(); + } + + [Fact] + public async Task StructPrimaryConstructor_CodeFix() + { + await CreateProjectBuilder() + .WithLanguageVersion(LanguageVersion.CSharp12) + .WithSourceCode(""" + public struct Foo(string Value1, string Value2) [|{|]} + """) + .ShouldFixCodeWith(""" + public struct Foo(string Value1, string Value2); + """) + .ValidateAsync(); + } +#endif +}