From 630f9abe3eb47cc6905772256921bad869522759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 22 May 2026 15:16:15 -0400 Subject: [PATCH 1/3] Add MA0202 for duplicate preprocessor branches Add MA0202 to detect identical code across #if/#elif/#else branches in the same conditional compilation block, with tests and generated documentation/configuration updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + docs/README.md | 7 ++ docs/Rules/MA0202.md | 15 +++ .../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 + ...CompilationBranchesAreIdenticalAnalyzer.cs | 108 ++++++++++++++++++ ...lationBranchesAreIdenticalAnalyzerTests.cs | 76 ++++++++++++ 11 files changed, 223 insertions(+) create mode 100644 docs/Rules/MA0202.md create mode 100644 src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs create mode 100644 tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs diff --git a/README.md b/README.md index 6112f70d..7b7ea15f 100755 --- a/README.md +++ b/README.md @@ -220,6 +220,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0199](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0199.md)|Design|Do not use inheritdoc on types without inheritance source|⚠️|✔️|❌| |[MA0200](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0200.md)|Usage|Do not use empty property patterns with non-nullable value types|ℹ️|✔️|✔️| |[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|⚠️|✔️|❌| diff --git a/docs/README.md b/docs/README.md index e4320c0f..a0638221 100755 --- a/docs/README.md +++ b/docs/README.md @@ -200,6 +200,7 @@ |[MA0199](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0199.md)|Design|Do not use inheritdoc on types without inheritance source|⚠️|✔️|❌| |[MA0200](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0200.md)|Usage|Do not use empty property patterns with non-nullable value types|ℹ️|✔️|✔️| |[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|⚠️|✔️|❌| |Id|Suppressed rule|Justification| |--|---------------|-------------| @@ -815,6 +816,9 @@ dotnet_diagnostic.MA0200.severity = suggestion # MA0201: Do not use zero-valued enum flags in flag checks dotnet_diagnostic.MA0201.severity = warning + +# MA0202: Conditional compilation branches have identical code +dotnet_diagnostic.MA0202.severity = warning ``` # .editorconfig - all rules disabled @@ -1416,4 +1420,7 @@ dotnet_diagnostic.MA0200.severity = none # MA0201: Do not use zero-valued enum flags in flag checks dotnet_diagnostic.MA0201.severity = none + +# MA0202: Conditional compilation branches have identical code +dotnet_diagnostic.MA0202.severity = none ``` diff --git a/docs/Rules/MA0202.md b/docs/Rules/MA0202.md new file mode 100644 index 00000000..15b306a5 --- /dev/null +++ b/docs/Rules/MA0202.md @@ -0,0 +1,15 @@ +# MA0202 - Conditional compilation branches have identical code + +Source: [ConditionalCompilationBranchesAreIdenticalAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs) + + +````c# +// non-compliant: #if and #elif branches contain the same code +#if NET6_0_OR_GREATER +Console.WriteLine("sample"); +#elif NET8_0_OR_GREATER +Console.WriteLine("sample"); +#else +Console.WriteLine("other"); +#endif +```` diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig index 65b502cb..2d3fbda3 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/all-errors.editorconfig @@ -598,3 +598,6 @@ dotnet_diagnostic.MA0200.severity = error # MA0201: Do not use zero-valued enum flags in flag checks dotnet_diagnostic.MA0201.severity = error + +# MA0202: Conditional compilation branches have identical code +dotnet_diagnostic.MA0202.severity = error diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig index 09aa7c3f..e1ba7cb9 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/all-suggestions.editorconfig @@ -598,3 +598,6 @@ dotnet_diagnostic.MA0200.severity = suggestion # MA0201: Do not use zero-valued enum flags in flag checks dotnet_diagnostic.MA0201.severity = suggestion + +# MA0202: Conditional compilation branches have identical code +dotnet_diagnostic.MA0202.severity = suggestion diff --git a/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig index fea58ce7..6ecca689 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/all-warnings.editorconfig @@ -598,3 +598,6 @@ dotnet_diagnostic.MA0200.severity = warning # MA0201: Do not use zero-valued enum flags in flag checks dotnet_diagnostic.MA0201.severity = warning + +# MA0202: Conditional compilation branches have identical code +dotnet_diagnostic.MA0202.severity = warning diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index b17b312a..e631a31b 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -598,3 +598,6 @@ dotnet_diagnostic.MA0200.severity = suggestion # MA0201: Do not use zero-valued enum flags in flag checks dotnet_diagnostic.MA0201.severity = warning + +# MA0202: Conditional compilation branches have identical code +dotnet_diagnostic.MA0202.severity = warning diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 32beb6a6..c8650158 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -598,3 +598,6 @@ dotnet_diagnostic.MA0200.severity = none # MA0201: Do not use zero-valued enum flags in flag checks dotnet_diagnostic.MA0201.severity = none + +# MA0202: Conditional compilation branches have identical code +dotnet_diagnostic.MA0202.severity = none diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index a33eca89..be028c14 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -201,6 +201,7 @@ internal static class RuleIdentifiers public const string InheritdocShouldHaveSourceOnTypes = "MA0199"; public const string DoNotUseEmptyPropertyPatternOnNonNullableValueType = "MA0200"; public const string DoNotUseZeroValuedEnumFlagsInFlagChecks = "MA0201"; + public const string ConditionalCompilationBranchesAreIdentical = "MA0202"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs b/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs new file mode 100644 index 00000000..786063cf --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Meziantou.Analyzer.Rules; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ConditionalCompilationBranchesAreIdenticalAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.ConditionalCompilationBranchesAreIdentical, + title: "Conditional compilation branches have identical code", + messageFormat: "Conditional compilation branches have identical code", + RuleCategories.Design, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.ConditionalCompilationBranchesAreIdentical)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterSyntaxTreeAction(AnalyzeSyntaxTree); + } + + private static void AnalyzeSyntaxTree(SyntaxTreeAnalysisContext context) + { + var root = context.Tree.GetRoot(context.CancellationToken); + var sourceText = context.Tree.GetText(context.CancellationToken); + foreach (var trivia in root.DescendantTrivia(descendIntoTrivia: true)) + { + if (!trivia.HasStructure) + continue; + + if (trivia.GetStructure() is IfDirectiveTriviaSyntax ifDirective) + { + AnalyzeDirectiveGroup(context, sourceText, ifDirective); + } + } + } + + private static void AnalyzeDirectiveGroup(SyntaxTreeAnalysisContext context, SourceText sourceText, IfDirectiveTriviaSyntax ifDirective) + { + var relatedDirectives = ifDirective.GetRelatedDirectives(); + List branchDirectives = []; + DirectiveTriviaSyntax? endIfDirective = null; + foreach (var directive in relatedDirectives) + { + if (directive.IsKind(SyntaxKind.IfDirectiveTrivia) || directive.IsKind(SyntaxKind.ElifDirectiveTrivia) || directive.IsKind(SyntaxKind.ElseDirectiveTrivia)) + { + branchDirectives.Add(directive); + } + else if (directive.IsKind(SyntaxKind.EndIfDirectiveTrivia)) + { + endIfDirective = directive; + } + } + + if (endIfDirective is null || branchDirectives.Count < 2) + return; + + Dictionary previousBranchBySignature = new(StringComparer.Ordinal); + for (var i = 0; i < branchDirectives.Count; i++) + { + var startDirective = branchDirectives[i]; + var endDirective = i + 1 < branchDirectives.Count ? branchDirectives[i + 1] : endIfDirective; + var branchSpan = TextSpan.FromBounds(startDirective.FullSpan.End, endDirective.FullSpan.Start); + var signature = ComputeBranchSignature(sourceText, branchSpan); + if (previousBranchBySignature.ContainsKey(signature)) + { + context.ReportDiagnostic(Diagnostic.Create(Rule, startDirective.GetLocation())); + } + else + { + previousBranchBySignature.Add(signature, startDirective); + } + } + } + + private static string ComputeBranchSignature(SourceText sourceText, TextSpan span) + { + var text = sourceText.ToString(span); + var tokens = SyntaxFactory.ParseTokens(text); + var builder = new StringBuilder(); + foreach (var token in tokens) + { + if (token.IsKind(SyntaxKind.EndOfFileToken)) + continue; + + builder.Append(token.RawKind); + builder.Append(':'); + builder.Append(token.Text); + builder.Append(';'); + } + + return builder.ToString(); + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs new file mode 100644 index 00000000..079e7be5 --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs @@ -0,0 +1,76 @@ +using Meziantou.Analyzer.Rules; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class ConditionalCompilationBranchesAreIdenticalAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() => + new ProjectBuilder() + .WithOutputKind(Microsoft.CodeAnalysis.OutputKind.ConsoleApplication) + .WithAnalyzer(); + + [Fact] + public Task IfElif_SameCode() => CreateProjectBuilder() + .WithSourceCode(""" + #if A + _ = 0; + {|MA0202:#elif B|} + _ = 0; + #else + _ = 1; + #endif + """) + .ValidateAsync(); + + [Fact] + public Task IfElse_SameCode() => CreateProjectBuilder() + .WithSourceCode(""" + #if A + _ = 0; + {|MA0202:#else|} + _ = 0; + #endif + """) + .ValidateAsync(); + + [Fact] + public Task NonAdjacentDuplicateBranch() => CreateProjectBuilder() + .WithSourceCode(""" + #if A + _ = 0; + #elif B + _ = 1; + {|MA0202:#else|} + _ = 0; + #endif + """) + .ValidateAsync(); + + [Fact] + public Task SameCodeWithDifferentComments() => CreateProjectBuilder() + .WithSourceCode(""" + #if A + _ = 0; + {|MA0202:#elif B|} + // comment + _ = 0; + #else + _ = 1; + #endif + """) + .ValidateAsync(); + + [Fact] + public Task DifferentBranches() => CreateProjectBuilder() + .WithSourceCode(""" + #if A + _ = 0; + #elif B + _ = 1; + #else + _ = 2; + #endif + """) + .ValidateAsync(); +} From ee9ca5e75bdfe9ea415e36b345f457e6fdca9cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 22 May 2026 20:06:58 -0400 Subject: [PATCH 2/3] wip --- docs/Rules/MA0202.md | 27 ++++++++++++-- ...lationBranchesAreIdenticalAnalyzerTests.cs | 35 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/Rules/MA0202.md b/docs/Rules/MA0202.md index 15b306a5..71f856d5 100644 --- a/docs/Rules/MA0202.md +++ b/docs/Rules/MA0202.md @@ -3,13 +3,34 @@ Source: [ConditionalCompilationBranchesAreIdenticalAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs) -````c# -// non-compliant: #if and #elif branches contain the same code +This rule reports duplicate code in a single `#if` / `#elif` / `#else` block. + +If a branch has the same code as any previous branch in the same conditional compilation block, the directive is redundant and should be simplified. + +The comparison ignores trivia (such as comments and whitespace), so only the actual code structure is considered. + +## Non-compliant code + +````csharp +// #elif and #else duplicate earlier branches #if NET6_0_OR_GREATER Console.WriteLine("sample"); #elif NET8_0_OR_GREATER Console.WriteLine("sample"); #else -Console.WriteLine("other"); +// same behavior as NET6_0_OR_GREATER +Console.WriteLine("sample"); +#endif +```` + +## Compliant code + +````csharp +#if NET9_0_OR_GREATER +Console.WriteLine("net9"); +#elif NET8_0_OR_GREATER +Console.WriteLine("net8"); +#else +Console.WriteLine("legacy"); #endif ```` diff --git a/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs index 079e7be5..4e3ce009 100644 --- a/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs @@ -73,4 +73,39 @@ public Task DifferentBranches() => CreateProjectBuilder() #endif """) .ValidateAsync(); + + [Fact] + public Task IfElse_SameCode_PartialExpression() => CreateProjectBuilder() + .WithSourceCode(""" + _ = + #if A + 1; + {|MA0202:#else|} + 1; + #endif + """) + .ValidateAsync(); + + [Fact] + public Task IfElse_SameCode_PartialTypeDeclaration() => CreateProjectBuilder() + .WithSourceCode(""" + interface ISample { } + interface ISpanFormattable { } + + #if A + public + #else + internal + #endif + class Sample : ISample + #if NET10_0 + , ISpanFormattable + {|MA0202:#else|} + , ISpanFormattable + #endif + { } + + static class Program { static void Main() { } } + """) + .ValidateAsync(); } From c31d0b5f8bc4d8529eb1d728291a717968c8daf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 22 May 2026 20:27:18 -0400 Subject: [PATCH 3/3] wip --- README.md | 2 +- docs/README.md | 2 +- docs/Rules/MA0202.md | 2 +- ...nalCompilationBranchesAreIdenticalFixer.cs | 162 ++++++++++++++++++ ...CompilationBranchesAreIdenticalAnalyzer.cs | 60 +------ ...alCompilationBranchesAreIdenticalCommon.cs | 150 ++++++++++++++++ ...lationBranchesAreIdenticalAnalyzerTests.cs | 86 +++++++++- 7 files changed, 408 insertions(+), 56 deletions(-) create mode 100644 src/Meziantou.Analyzer.CodeFixers/Rules/ConditionalCompilationBranchesAreIdenticalFixer.cs create mode 100644 src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalCommon.cs diff --git a/README.md b/README.md index 7b7ea15f..71e1f443 100755 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0199](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0199.md)|Design|Do not use inheritdoc on types without inheritance source|⚠️|✔️|❌| |[MA0200](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0200.md)|Usage|Do not use empty property patterns with non-nullable value types|ℹ️|✔️|✔️| |[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|⚠️|✔️|❌| +|[MA0202](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0202.md)|Design|Conditional compilation branches have identical code|⚠️|✔️|✔️| diff --git a/docs/README.md b/docs/README.md index a0638221..7f1ac787 100755 --- a/docs/README.md +++ b/docs/README.md @@ -200,7 +200,7 @@ |[MA0199](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0199.md)|Design|Do not use inheritdoc on types without inheritance source|⚠️|✔️|❌| |[MA0200](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0200.md)|Usage|Do not use empty property patterns with non-nullable value types|ℹ️|✔️|✔️| |[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|⚠️|✔️|❌| +|[MA0202](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0202.md)|Design|Conditional compilation branches have identical code|⚠️|✔️|✔️| |Id|Suppressed rule|Justification| |--|---------------|-------------| diff --git a/docs/Rules/MA0202.md b/docs/Rules/MA0202.md index 71f856d5..8a3ddaf5 100644 --- a/docs/Rules/MA0202.md +++ b/docs/Rules/MA0202.md @@ -1,6 +1,6 @@ # MA0202 - Conditional compilation branches have identical code -Source: [ConditionalCompilationBranchesAreIdenticalAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs) +Sources: [ConditionalCompilationBranchesAreIdenticalAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs), [ConditionalCompilationBranchesAreIdenticalFixer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer.CodeFixers/Rules/ConditionalCompilationBranchesAreIdenticalFixer.cs) This rule reports duplicate code in a single `#if` / `#elif` / `#else` block. diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/ConditionalCompilationBranchesAreIdenticalFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/ConditionalCompilationBranchesAreIdenticalFixer.cs new file mode 100644 index 00000000..e2932cd6 --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/ConditionalCompilationBranchesAreIdenticalFixer.cs @@ -0,0 +1,162 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Meziantou.Analyzer.Rules; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public sealed class ConditionalCompilationBranchesAreIdenticalFixer : CodeFixProvider +{ + private const string MergeTitle = "Merge duplicate conditional compilation branches"; + private const string RemoveTitle = "Remove redundant conditional compilation block"; + + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(RuleIdentifiers.ConditionalCompilationBranchesAreIdentical); + + public override FixAllProvider GetFixAllProvider() => 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 sourceText = await context.Document.GetTextAsync(context.CancellationToken).ConfigureAwait(false); + if (!TryGetFixContext(root, sourceText, context.Span, out var fixContext)) + return; + + var currentBranch = fixContext.Group.Branches[fixContext.CurrentBranchIndex]; + var duplicateBranch = fixContext.Group.Branches[fixContext.DuplicateBranchIndex]; + if (currentBranch.Kind == ConditionalCompilationBranchesAreIdenticalCommon.BranchKind.Else && + duplicateBranch.Kind == ConditionalCompilationBranchesAreIdenticalCommon.BranchKind.If && + fixContext.Group.Branches.Count == 2 && + fixContext.CurrentBranchIndex == 1) + { + context.RegisterCodeFix( + CodeAction.Create( + RemoveTitle, + ct => RemoveRedundantConditionalCompilationAsync(context.Document, context.Diagnostics[0], ct), + equivalenceKey: RemoveTitle), + context.Diagnostics); + } + else if (currentBranch.Kind == ConditionalCompilationBranchesAreIdenticalCommon.BranchKind.Elif && + duplicateBranch.Kind is ConditionalCompilationBranchesAreIdenticalCommon.BranchKind.If or ConditionalCompilationBranchesAreIdenticalCommon.BranchKind.Elif && + currentBranch.ConditionText is not null && + duplicateBranch.ConditionText is not null) + { + context.RegisterCodeFix( + CodeAction.Create( + MergeTitle, + ct => MergeDuplicateConditionalCompilationBranchAsync(context.Document, context.Diagnostics[0], ct), + equivalenceKey: MergeTitle), + context.Diagnostics); + } + } + + private static async Task RemoveRedundantConditionalCompilationAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + if (!TryGetFixContext(root, sourceText, diagnostic.Location.SourceSpan, out var fixContext)) + return document; + + var firstBranch = fixContext.Group.Branches[0]; + var replacementText = sourceText.ToString(firstBranch.ContentSpan); + var replacementSpan = TextSpan.FromBounds(fixContext.Group.FirstIfDirective.FullSpan.Start, fixContext.Group.EndIfDirective.FullSpan.End); + var updatedText = sourceText.WithChanges(new TextChange(replacementSpan, replacementText)); + return document.WithText(updatedText); + } + + private static async Task MergeDuplicateConditionalCompilationBranchAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + if (!TryGetFixContext(root, sourceText, diagnostic.Location.SourceSpan, out var fixContext)) + return document; + + var currentBranch = fixContext.Group.Branches[fixContext.CurrentBranchIndex]; + var duplicateBranch = fixContext.Group.Branches[fixContext.DuplicateBranchIndex]; + if (currentBranch.Kind != ConditionalCompilationBranchesAreIdenticalCommon.BranchKind.Elif || + currentBranch.ConditionText is null || + duplicateBranch.ConditionText is null) + { + return document; + } + + var mergedCondition = $"({duplicateBranch.ConditionText}) || ({currentBranch.ConditionText})"; + var mergedDirective = duplicateBranch.Kind switch + { + ConditionalCompilationBranchesAreIdenticalCommon.BranchKind.If => "#if " + mergedCondition, + ConditionalCompilationBranchesAreIdenticalCommon.BranchKind.Elif => "#elif " + mergedCondition, + _ => null, + }; + + if (mergedDirective is null) + return document; + + var removeBranchSpan = TextSpan.FromBounds(currentBranch.StartDirective.FullSpan.Start, currentBranch.NextDirective.FullSpan.Start); + var updatedText = sourceText.WithChanges( + new TextChange(duplicateBranch.StartDirective.Span, mergedDirective), + new TextChange(removeBranchSpan, string.Empty)); + + return document.WithText(updatedText); + } + + private static bool TryGetFixContext(SyntaxNode root, SourceText sourceText, TextSpan span, out FixContext fixContext) + { + var directive = FindDirective(root, span); + if (directive is null) + { + fixContext = default; + return false; + } + + if (!ConditionalCompilationBranchesAreIdenticalCommon.TryCreateBranchGroup(directive, sourceText, out var branchGroup)) + { + fixContext = default; + return false; + } + + var currentBranchIndex = branchGroup.GetBranchIndex(directive); + if (currentBranchIndex <= 0) + { + fixContext = default; + return false; + } + + var duplicateBranchIndex = branchGroup.FindPreviousDuplicateBranchIndex(currentBranchIndex); + if (duplicateBranchIndex < 0) + { + fixContext = default; + return false; + } + + fixContext = new FixContext(branchGroup, currentBranchIndex, duplicateBranchIndex); + return true; + } + + private static DirectiveTriviaSyntax? FindDirective(SyntaxNode root, TextSpan span) + { + foreach (var trivia in root.DescendantTrivia(descendIntoTrivia: true)) + { + if (!trivia.HasStructure || !trivia.FullSpan.Contains(span.Start)) + continue; + + if (trivia.GetStructure() is DirectiveTriviaSyntax directive) + return directive; + } + + return null; + } + + private readonly record struct FixContext(ConditionalCompilationBranchesAreIdenticalCommon.BranchGroup Group, int CurrentBranchIndex, int DuplicateBranchIndex); +} diff --git a/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs b/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs index 786063cf..00256bf7 100644 --- a/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzer.cs @@ -1,12 +1,8 @@ -using System.Collections.Generic; using System.Collections.Immutable; -using System.Text; using Meziantou.Analyzer.Internals; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Text; namespace Meziantou.Analyzer.Rules; @@ -42,67 +38,27 @@ private static void AnalyzeSyntaxTree(SyntaxTreeAnalysisContext context) if (!trivia.HasStructure) continue; - if (trivia.GetStructure() is IfDirectiveTriviaSyntax ifDirective) + if (trivia.GetStructure() is IfDirectiveTriviaSyntax ifDirective && + ConditionalCompilationBranchesAreIdenticalCommon.TryCreateBranchGroup(ifDirective, sourceText, out var group)) { - AnalyzeDirectiveGroup(context, sourceText, ifDirective); + AnalyzeDirectiveGroup(context, group); } } } - private static void AnalyzeDirectiveGroup(SyntaxTreeAnalysisContext context, SourceText sourceText, IfDirectiveTriviaSyntax ifDirective) + private static void AnalyzeDirectiveGroup(SyntaxTreeAnalysisContext context, ConditionalCompilationBranchesAreIdenticalCommon.BranchGroup group) { - var relatedDirectives = ifDirective.GetRelatedDirectives(); - List branchDirectives = []; - DirectiveTriviaSyntax? endIfDirective = null; - foreach (var directive in relatedDirectives) - { - if (directive.IsKind(SyntaxKind.IfDirectiveTrivia) || directive.IsKind(SyntaxKind.ElifDirectiveTrivia) || directive.IsKind(SyntaxKind.ElseDirectiveTrivia)) - { - branchDirectives.Add(directive); - } - else if (directive.IsKind(SyntaxKind.EndIfDirectiveTrivia)) - { - endIfDirective = directive; - } - } - - if (endIfDirective is null || branchDirectives.Count < 2) - return; - Dictionary previousBranchBySignature = new(StringComparer.Ordinal); - for (var i = 0; i < branchDirectives.Count; i++) + foreach (var branch in group.Branches) { - var startDirective = branchDirectives[i]; - var endDirective = i + 1 < branchDirectives.Count ? branchDirectives[i + 1] : endIfDirective; - var branchSpan = TextSpan.FromBounds(startDirective.FullSpan.End, endDirective.FullSpan.Start); - var signature = ComputeBranchSignature(sourceText, branchSpan); - if (previousBranchBySignature.ContainsKey(signature)) + if (previousBranchBySignature.ContainsKey(branch.Signature)) { - context.ReportDiagnostic(Diagnostic.Create(Rule, startDirective.GetLocation())); + context.ReportDiagnostic(Diagnostic.Create(Rule, branch.StartDirective.GetLocation())); } else { - previousBranchBySignature.Add(signature, startDirective); + previousBranchBySignature.Add(branch.Signature, branch.StartDirective); } } } - - private static string ComputeBranchSignature(SourceText sourceText, TextSpan span) - { - var text = sourceText.ToString(span); - var tokens = SyntaxFactory.ParseTokens(text); - var builder = new StringBuilder(); - foreach (var token in tokens) - { - if (token.IsKind(SyntaxKind.EndOfFileToken)) - continue; - - builder.Append(token.RawKind); - builder.Append(':'); - builder.Append(token.Text); - builder.Append(';'); - } - - return builder.ToString(); - } } diff --git a/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalCommon.cs b/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalCommon.cs new file mode 100644 index 00000000..f108603e --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/ConditionalCompilationBranchesAreIdenticalCommon.cs @@ -0,0 +1,150 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Meziantou.Analyzer.Rules; + +internal static class ConditionalCompilationBranchesAreIdenticalCommon +{ + internal static bool TryCreateBranchGroup(DirectiveTriviaSyntax directive, SourceText sourceText, [NotNullWhen(true)] out BranchGroup? group) + { + var relatedDirectives = directive.GetRelatedDirectives(); + DirectiveTriviaSyntax? firstIfDirective = null; + DirectiveTriviaSyntax? endIfDirective = null; + List branchDirectives = []; + foreach (var relatedDirective in relatedDirectives) + { + switch (relatedDirective) + { + case IfDirectiveTriviaSyntax: + firstIfDirective ??= relatedDirective; + branchDirectives.Add(relatedDirective); + break; + case ElifDirectiveTriviaSyntax: + case ElseDirectiveTriviaSyntax: + branchDirectives.Add(relatedDirective); + break; + case EndIfDirectiveTriviaSyntax: + endIfDirective = relatedDirective; + break; + } + } + + if (firstIfDirective is null || endIfDirective is null || branchDirectives.Count < 2) + { + group = null; + return false; + } + + List branches = []; + for (var i = 0; i < branchDirectives.Count; i++) + { + var startDirective = branchDirectives[i]; + var endDirective = i + 1 < branchDirectives.Count ? branchDirectives[i + 1] : endIfDirective; + var contentSpan = TextSpan.FromBounds(startDirective.FullSpan.End, endDirective.FullSpan.Start); + + var kind = startDirective switch + { + IfDirectiveTriviaSyntax => BranchKind.If, + ElifDirectiveTriviaSyntax => BranchKind.Elif, + ElseDirectiveTriviaSyntax => BranchKind.Else, + _ => throw new InvalidOperationException("Unexpected directive kind"), + }; + + string? conditionText = startDirective switch + { + IfDirectiveTriviaSyntax ifDirective => ifDirective.Condition.ToString(), + ElifDirectiveTriviaSyntax elifDirective => elifDirective.Condition.ToString(), + _ => null, + }; + + branches.Add(new Branch( + startDirective: startDirective, + nextDirective: endDirective, + contentSpan: contentSpan, + kind: kind, + conditionText: conditionText, + signature: ComputeBranchSignature(sourceText, contentSpan))); + } + + group = new BranchGroup(firstIfDirective, endIfDirective, branches); + return true; + } + + private static string ComputeBranchSignature(SourceText sourceText, TextSpan span) + { + var text = sourceText.ToString(span); + var tokens = SyntaxFactory.ParseTokens(text); + var builder = new StringBuilder(); + foreach (var token in tokens) + { + if (token.RawKind == (int)SyntaxKind.EndOfFileToken) + continue; + + builder.Append(token.RawKind); + builder.Append(':'); + builder.Append(token.Text); + builder.Append(';'); + } + + return builder.ToString(); + } + + internal sealed class BranchGroup( + DirectiveTriviaSyntax firstIfDirective, + DirectiveTriviaSyntax endIfDirective, + IReadOnlyList branches) + { + internal DirectiveTriviaSyntax FirstIfDirective { get; } = firstIfDirective; + internal DirectiveTriviaSyntax EndIfDirective { get; } = endIfDirective; + internal IReadOnlyList Branches { get; } = branches; + + internal int GetBranchIndex(DirectiveTriviaSyntax directive) + { + for (var i = 0; i < Branches.Count; i++) + { + if (Branches[i].StartDirective.SpanStart == directive.SpanStart) + return i; + } + + return -1; + } + + internal int FindPreviousDuplicateBranchIndex(int currentBranchIndex) + { + var signature = Branches[currentBranchIndex].Signature; + for (var i = 0; i < currentBranchIndex; i++) + { + if (string.Equals(Branches[i].Signature, signature, StringComparison.Ordinal)) + return i; + } + + return -1; + } + } + + internal sealed class Branch( + DirectiveTriviaSyntax startDirective, + DirectiveTriviaSyntax nextDirective, + TextSpan contentSpan, + BranchKind kind, + string? conditionText, + string signature) + { + internal DirectiveTriviaSyntax StartDirective { get; } = startDirective; + internal DirectiveTriviaSyntax NextDirective { get; } = nextDirective; + internal TextSpan ContentSpan { get; } = contentSpan; + internal BranchKind Kind { get; } = kind; + internal string? ConditionText { get; } = conditionText; + internal string Signature { get; } = signature; + } + + internal enum BranchKind + { + If, + Elif, + Else, + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs index 4e3ce009..e3b74b1a 100644 --- a/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/ConditionalCompilationBranchesAreIdenticalAnalyzerTests.cs @@ -8,7 +8,8 @@ public sealed class ConditionalCompilationBranchesAreIdenticalAnalyzerTests private static ProjectBuilder CreateProjectBuilder() => new ProjectBuilder() .WithOutputKind(Microsoft.CodeAnalysis.OutputKind.ConsoleApplication) - .WithAnalyzer(); + .WithAnalyzer() + .WithCodeFixProvider(); [Fact] public Task IfElif_SameCode() => CreateProjectBuilder() @@ -105,6 +106,89 @@ class Sample : ISample #endif { } + static class Program { static void Main() { } } + """) + .ValidateAsync(); + + [Fact] + public Task Fix_IfElif_SameCode_MergesConditions() => CreateProjectBuilder() + .WithSourceCode(""" + #if A + _ = 0; + {|MA0202:#elif B|} + _ = 0; + #else + _ = 1; + #endif + """) + .ShouldFixCodeWith(""" + #if (A) || (B) + _ = 0; + #else + _ = 1; + #endif + """) + .ValidateAsync(); + + [Fact] + public Task Fix_IfElse_SameCode_RemovesPreprocessor() => CreateProjectBuilder() + .WithSourceCode(""" + #if A + _ = 0; + {|MA0202:#else|} + _ = 0; + #endif + """) + .ShouldFixCodeWith("_ = 0;\n") + .ValidateAsync(); + + [Fact] + public Task Fix_IfElse_SameCode_PartialExpression_RemovesPreprocessor() => CreateProjectBuilder() + .WithSourceCode(""" + _ = + #if A + 1; + {|MA0202:#else|} + 1; + #endif + """) + .ShouldFixCodeWith("_ =\n 1;\n") + .ValidateAsync(); + + [Fact] + public Task Fix_IfElse_SameCode_PartialTypeDeclaration_RemovesPreprocessor() => CreateProjectBuilder() + .WithSourceCode(""" + interface ISample { } + interface ISpanFormattable { } + + #if A + public + #else + internal + #endif + class Sample : ISample + #if NET10_0 + , ISpanFormattable + {|MA0202:#else|} + , ISpanFormattable + #endif + { } + + static class Program { static void Main() { } } + """) + .ShouldFixCodeWith(""" + interface ISample { } + interface ISpanFormattable { } + + #if A + public + #else + internal + #endif + class Sample : ISample + , ISpanFormattable + { } + static class Program { static void Main() { } } """) .ValidateAsync();