diff --git a/README.md b/README.md index 94809d2f..c6ccf261 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|⚠️|✔️|❌| +|[MA0204](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0204.md)|Design|Remove unnecessary partial modifier|ℹ️|✔️|✔️| diff --git a/docs/README.md b/docs/README.md index 59543821..fa035adb 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|⚠️|✔️|❌| +|[MA0204](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0204.md)|Design|Remove unnecessary partial modifier|ℹ️|✔️|✔️| |Id|Suppressed rule|Justification| |--|---------------|-------------| diff --git a/docs/Rules/MA0204.md b/docs/Rules/MA0204.md new file mode 100644 index 00000000..72103b0f --- /dev/null +++ b/docs/Rules/MA0204.md @@ -0,0 +1,24 @@ +# MA0204 - Remove unnecessary partial modifier + +Sources: [RemoveUnnecessaryPartialModifierAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/RemoveUnnecessaryPartialModifierAnalyzer.cs), [RemoveUnnecessaryPartialModifierFixer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryPartialModifierFixer.cs) + + +This rule reports a `partial` modifier on a type when the type has a single declaration and no partial members. + +The `partial` modifier can be removed when there is no other declaration to merge with. + +## Non-compliant code + +````csharp +partial class Sample +{ +} +```` + +## Compliant code + +````csharp +class Sample +{ +} +```` diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryPartialModifierFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryPartialModifierFixer.cs new file mode 100644 index 00000000..c05f1c69 --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/RemoveUnnecessaryPartialModifierFixer.cs @@ -0,0 +1,81 @@ +using System.Collections.Immutable; +using System.Composition; +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 RemoveUnnecessaryPartialModifierFixer : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(RuleIdentifiers.RemoveUnnecessaryPartialModifier); + + public override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var partialKeyword = root.FindToken(context.Span.Start); + if (!partialKeyword.IsKind(SyntaxKind.PartialKeyword)) + return; + + var typeDeclaration = partialKeyword.Parent?.AncestorsAndSelf().OfType().FirstOrDefault(); + if (typeDeclaration is null || !typeDeclaration.Modifiers.Contains(partialKeyword)) + return; + + var title = "Remove partial modifier"; + var codeAction = CodeAction.Create( + title, + ct => RemovePartialModifierAsync(context.Document, partialKeyword, ct), + equivalenceKey: title); + + context.RegisterCodeFix(codeAction, context.Diagnostics); + } + + private static async Task RemovePartialModifierAsync(Document document, SyntaxToken partialKeyword, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + var currentPartialKeyword = root.FindToken(partialKeyword.SpanStart); + var typeDeclaration = currentPartialKeyword.Parent?.AncestorsAndSelf().OfType().FirstOrDefault(); + if (typeDeclaration is null || !typeDeclaration.Modifiers.Contains(currentPartialKeyword)) + return document; + + var newTypeDeclaration = RemovePartialModifier(typeDeclaration, currentPartialKeyword); + return document.WithSyntaxRoot(root.ReplaceNode(typeDeclaration, newTypeDeclaration)); + } + + private static TypeDeclarationSyntax RemovePartialModifier(TypeDeclarationSyntax typeDeclaration, SyntaxToken partialKeyword) + { + var modifiers = typeDeclaration.Modifiers; + var partialKeywordIndex = modifiers.IndexOf(partialKeyword); + if (partialKeywordIndex < 0) + return typeDeclaration; + + var nonspaceTrivia = partialKeyword.LeadingTrivia.Concat(partialKeyword.TrailingTrivia).Where(t => !t.IsKind(SyntaxKind.WhitespaceTrivia) && !t.IsKind(SyntaxKind.EndOfLineTrivia)).ToList(); + + if (partialKeywordIndex + 1 < modifiers.Count) + { + var nextModifier = modifiers[partialKeywordIndex + 1]; + modifiers = modifiers.Replace(nextModifier, nextModifier.WithLeadingTrivia(nonspaceTrivia.Concat(nextModifier.LeadingTrivia))); + return typeDeclaration.WithModifiers(modifiers.Remove(partialKeyword)); + } + + return typeDeclaration + .WithModifiers(modifiers.Remove(partialKeyword)) + .WithKeyword(typeDeclaration.Keyword.WithLeadingTrivia(nonspaceTrivia.Concat(typeDeclaration.Keyword.LeadingTrivia))); + } +} diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig index 4910b616..93fd358c 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 + +# MA0204: Remove unnecessary partial modifier +dotnet_diagnostic.MA0204.severity = error diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig index 95bc2894..e1f4e48d 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 + +# MA0204: Remove unnecessary partial modifier +dotnet_diagnostic.MA0204.severity = suggestion diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig index fcd8960a..a2514576 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 + +# MA0204: Remove unnecessary partial modifier +dotnet_diagnostic.MA0204.severity = warning diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index f3e2995a..850b7838 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 + +# MA0204: Remove unnecessary partial modifier +dotnet_diagnostic.MA0204.severity = suggestion diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 75e27a31..ae54568d 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 + +# MA0204: Remove unnecessary partial modifier +dotnet_diagnostic.MA0204.severity = none diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index f85ae919..e1e42b9a 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 RemoveUnnecessaryPartialModifier = "MA0204"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/RemoveUnnecessaryPartialModifierAnalyzer.cs b/src/Meziantou.Analyzer/Rules/RemoveUnnecessaryPartialModifierAnalyzer.cs new file mode 100644 index 00000000..2ba1946f --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/RemoveUnnecessaryPartialModifierAnalyzer.cs @@ -0,0 +1,51 @@ +using System.Collections.Immutable; +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 RemoveUnnecessaryPartialModifierAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.RemoveUnnecessaryPartialModifier, + title: "Remove unnecessary partial modifier", + messageFormat: "Remove unnecessary partial modifier", + RuleCategories.Design, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.RemoveUnnecessaryPartialModifier)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + + context.RegisterSymbolAction(AnalyzeNamedTypeSymbol, SymbolKind.NamedType); + } + + private static void AnalyzeNamedTypeSymbol(SymbolAnalysisContext context) + { + var symbol = (INamedTypeSymbol)context.Symbol; + if (symbol.TypeKind is not (TypeKind.Class or TypeKind.Struct or TypeKind.Interface)) + return; + + if (symbol.DeclaringSyntaxReferences.Length != 1) + return; + + var typeDeclaration = symbol.DeclaringSyntaxReferences[0].GetSyntax(context.CancellationToken) as TypeDeclarationSyntax; + if (typeDeclaration is null) + return; + + var partialToken = typeDeclaration.Modifiers.FirstOrDefault(modifier => modifier.IsKind(SyntaxKind.PartialKeyword)); + if (partialToken == default) + return; + + context.ReportDiagnostic(Diagnostic.Create(Rule, partialToken.GetLocation())); + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/RemoveUnnecessaryPartialModifierAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/RemoveUnnecessaryPartialModifierAnalyzerTests.cs new file mode 100644 index 00000000..40b512a7 --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/RemoveUnnecessaryPartialModifierAnalyzerTests.cs @@ -0,0 +1,225 @@ +using Meziantou.Analyzer.Rules; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class RemoveUnnecessaryPartialModifierAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer() + .WithCodeFixProvider(); + } + + [Fact] + public async Task PartialClass_WithSingleDeclaration_ReportsDiagnostic() + { + const string SourceCode = """ + [|partial|] class Sample + { + } + """; + + const string CodeFix = """ + class Sample + { + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(CodeFix) + .ValidateAsync(); + } + + [Fact] + public async Task PartialClass_WithSingleDeclaration_PreserveComments_Keyword_ReportsDiagnostic() + { + const string SourceCode = """ + /*sample*/[|partial|] class Sample + { + } + """; + + const string CodeFix = """ + /*sample*/class Sample + { + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(CodeFix) + .ValidateAsync(); + } + + [Fact] + public async Task PartialClass_WithSingleDeclaration_PreserveComments_Modifier_ReportsDiagnostic() + { + const string SourceCode = """ + static /*sample*/[|partial|] class Sample + { + } + """; + + const string CodeFix = """ + static /*sample*/class Sample + { + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(CodeFix) + .ValidateAsync(); + } + + [Fact] + public async Task PartialClass_WithOtherModifiers_ReportsDiagnostic() + { + const string SourceCode = """ + public sealed [|partial|] class Sample + { + } + """; + + const string CodeFix = """ + public sealed class Sample + { + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(CodeFix) + .ValidateAsync(); + } + + [Fact] + public async Task PartialRecord_WithSingleDeclaration_ReportsDiagnostic() + { + const string SourceCode = """ + [|partial|] record Sample; + """; + + const string CodeFix = """ + record Sample; + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(CodeFix) + .ValidateAsync(); + } + + [Fact] + public async Task PartialStruct_WithSingleDeclaration_ReportsDiagnostic() + { + const string SourceCode = """ + [|partial|] struct Sample + { + } + """; + + const string CodeFix = """ + struct Sample + { + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(CodeFix) + .ValidateAsync(); + } + + [Fact] + public async Task PartialInterface_WithSingleDeclaration_ReportsDiagnostic() + { + const string SourceCode = """ + [|partial|] interface ISample + { + } + """; + + const string CodeFix = """ + interface ISample + { + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(CodeFix) + .ValidateAsync(); + } + + [Fact] + public async Task PartialClass_WithMultipleDeclarations_NoDiagnostic() + { + const string SourceCode = """ + partial class Sample + { + } + + partial class Sample + { + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task PartialClass_WithPartialMethod_NoDiagnostic() + { + const string SourceCode = """ + [|partial|] class Sample + { + partial void M(); + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task PartialClass_WithNestedPartialType_ReportsDiagnostic() + { + const string SourceCode = """ + [|partial|] class Sample + { + partial class Nested + { + } + + partial class Nested + { + } + } + """; + + const string CodeFix = """ + class Sample + { + partial class Nested + { + } + + partial class Nested + { + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(CodeFix) + .ValidateAsync(); + } +}