diff --git a/Atc.Analyzer.slnx b/Atc.Analyzer.slnx index fc60e87..e0f50f1 100644 --- a/Atc.Analyzer.slnx +++ b/Atc.Analyzer.slnx @@ -13,6 +13,7 @@ + diff --git a/CLAUDE.md b/CLAUDE.md index e901dcc..66c5877 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,40 @@ private static void AnalyzeSomething(SyntaxNodeAnalysisContext context) This pattern is used by GlobalUsings analyzers and should be applied to other analyzers that shouldn't analyze generated code. +#### Analyzing Source-Generated Code + +**Default behavior:** Most analyzers use `ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None)` to skip generated code. + +**Special case:** Analyzers that need to analyze **user-written attributes or declarations** that trigger source generators should use different flags: + +```csharp +public override void Initialize(AnalysisContext context) +{ + // For analyzers that need to analyze source-generated code declarations + context.ConfigureGeneratedCodeAnalysis( + GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeMethodDeclaration, SyntaxKind.MethodDeclaration); +} +``` + +**When to use `Analyze | ReportDiagnostics`:** +- Analyzing attributes that trigger source generators (e.g., `[GeneratedRegex]`) +- Examining method/type declarations that will have generated implementations +- Checking parameters or configurations of source generator attributes +- Any scenario where the user-written code (not the generated output) needs analysis + +**Example:** +The `GeneratedRegexCompiledFlagAnalyzer` (ATC301) must use `Analyze | ReportDiagnostics` because: +- It analyzes the `[GeneratedRegex]` attribute parameters (user-written code) +- The attribute triggers a source generator, but the attribute itself is in user source files +- The redundant flag can be detected before code generation occurs + +**Important:** Register for the appropriate syntax kind: +- For attribute-triggered analyzers, register for the decorated member type (e.g., `SyntaxKind.MethodDeclaration`) +- NOT `SyntaxKind.Attribute` directly, as source-generated methods may not trigger attribute node analysis + ### Code Fix Provider Implementation Pattern When an analyzer can suggest automatic fixes, implement a CodeFixProvider in the same directory: diff --git a/Directory.Build.props b/Directory.Build.props index 2d1bf7b..37e5d32 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -41,7 +41,7 @@ - + diff --git a/README.md b/README.md index a30acd0..b7ca17a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Once installed, the analyzer will automatically run during compilation and highl | [ATC210](docs/rules/ATC210.md) | Style | Use expression body syntax when appropriate | ⚠️ Warning | ✔️ Yes | ✅ Yes | | [ATC220](docs/rules/ATC220.md) | Style | Use global usings for all namespaces (strict policy) | ⚠️ Warning | ✔️ Yes | ✅ Yes | | [ATC221](docs/rules/ATC221.md) | Style | Use global usings for common namespaces (lenient policy) | ⚠️ Warning | ✔️ Yes | ✅ Yes | +| [ATC301](docs/rules/ATC301.md) | Usage | Remove redundant RegexOptions.Compiled flag from [GeneratedRegex] attribute | ⚠️ Warning | ✔️ Yes | ✅ Yes | ### Severity Levels @@ -36,6 +37,10 @@ Once installed, the analyzer will automatically run during compilation and highl Rules that enforce consistent code formatting and style conventions. +### Usage + +Rules that detect incorrect or redundant API usage patterns. + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/docs/rules/ATC301.md b/docs/rules/ATC301.md new file mode 100644 index 0000000..4faa906 --- /dev/null +++ b/docs/rules/ATC301.md @@ -0,0 +1,247 @@ +# 🔗 ATC301: Remove redundant RegexOptions.Compiled flag from [GeneratedRegex] attribute + +## 📂 Category + +Usage + +## ⚠️ Severity + +Warning + +## 📖 Description + +Detects and removes redundant `RegexOptions.Compiled` flags from `[GeneratedRegex]` attributes. The `[GeneratedRegex]` attribute uses source generators to produce compiled regex code at build time, making the `Compiled` flag unnecessary and redundant. + +## 📋 Rules + +1. **GeneratedRegex attributes**: Do not use `RegexOptions.Compiled` flag with `[GeneratedRegex]` attributes +2. **Already compiled**: The `[GeneratedRegex]` attribute generates compiled regex code at build time using source generators +3. **No performance benefit**: Adding `RegexOptions.Compiled` has no effect and provides no additional performance benefit +4. **Code clarity**: Removing the redundant flag improves code clarity by eliminating confusion about how `[GeneratedRegex]` works +5. **Applies to**: All usages of the `[GeneratedRegex]` attribute from `System.Text.RegularExpressions` +6. **Does NOT apply to**: Traditional regex construction using `new Regex()` where `RegexOptions.Compiled` is still meaningful + +## 💡 Motivation + +Using `RegexOptions.Compiled` with `[GeneratedRegex]`: +- Is redundant because the source generator already produces compiled code +- Can confuse developers about how source-generated regexes work +- Adds unnecessary noise to the code +- May mislead readers into thinking it provides additional optimization +- Violates the principle of avoiding redundant code + +The `[GeneratedRegex]` attribute was introduced in .NET 7 to generate highly optimized regex code at compile time using source generators. This generated code is already compiled and optimized, making the `RegexOptions.Compiled` flag meaningless in this context. + +## 📝 Examples + +### Basic usage with single flag + +```csharp +// non-compliant - Compiled flag is redundant +[GeneratedRegex(@"\d+", RegexOptions.Compiled)] +private static partial Regex NumberRegex(); + +// compliant - No Compiled flag needed +[GeneratedRegex(@"\d+")] +private static partial Regex NumberRegex(); +``` + +### Usage with multiple flags + +```csharp +// non-compliant - Compiled flag is redundant even with other flags +[GeneratedRegex(@"[a-z]+", RegexOptions.Compiled | RegexOptions.IgnoreCase)] +private static partial Regex CaseInsensitiveRegex(); + +// compliant - Keep only meaningful flags +[GeneratedRegex(@"[a-z]+", RegexOptions.IgnoreCase)] +private static partial Regex CaseInsensitiveRegex(); +``` + +### Named parameters + +```csharp +// non-compliant - Compiled flag in named parameter +[GeneratedRegex( + pattern: @"[$>]", + options: RegexOptions.Compiled | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] +private static partial Regex PromptRegex(); + +// compliant - Remove Compiled flag +[GeneratedRegex( + pattern: @"[$>]", + options: RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] +private static partial Regex PromptRegex(); +``` + +### Multiple flags with Compiled in different positions + +```csharp +// non-compliant - Compiled at the beginning +[GeneratedRegex(@"^\d+$", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase)] +private static partial Regex MultiRegex1(); + +// non-compliant - Compiled in the middle +[GeneratedRegex(@"^\d+$", RegexOptions.Multiline | RegexOptions.Compiled | RegexOptions.IgnoreCase)] +private static partial Regex MultiRegex2(); + +// non-compliant - Compiled at the end +[GeneratedRegex(@"^\d+$", RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.Compiled)] +private static partial Regex MultiRegex3(); + +// compliant - No Compiled flag +[GeneratedRegex(@"^\d+$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] +private static partial Regex MultiRegex(); +``` + +### Only Compiled flag + +```csharp +// non-compliant - Compiled is the only option +[GeneratedRegex(@"\w+", RegexOptions.Compiled)] +private static partial Regex WordRegex(); + +// compliant - Remove entire options parameter +[GeneratedRegex(@"\w+")] +private static partial Regex WordRegex(); +``` + +### Traditional Regex construction (not affected by this rule) + +```csharp +// compliant - This is NOT a GeneratedRegex attribute, so Compiled flag is meaningful +private static readonly Regex CompiledRegex = new Regex(@"\d+", RegexOptions.Compiled); + +// compliant - Traditional Regex construction with multiple options +private static readonly Regex MultiOptionsRegex = + new Regex(@"[a-z]+", RegexOptions.Compiled | RegexOptions.IgnoreCase); +``` + +### Real-world example from user's code + +```csharp +// non-compliant - User's original example +[GeneratedRegex( + pattern: "[$>]", + options: RegexOptions.Compiled | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: RegexTimeoutPrompt)] +private static partial Regex UserOrFtpPromptRegex(); + +// compliant - Corrected version +[GeneratedRegex( + pattern: "[$>]", + options: RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: RegexTimeoutPrompt)] +private static partial Regex UserOrFtpPromptRegex(); +``` + +## ⚙️ Configuration + +This rule is not configurable. It always flags `RegexOptions.Compiled` when used with `[GeneratedRegex]` attributes. + +## 🤖 Source-Generated Code + +This rule analyzes methods with the `[GeneratedRegex]` attribute, which is a source generator attribute. The analyzer needs to examine these methods because: + +1. **User-written attributes**: The `[GeneratedRegex]` attribute and its parameters are written by developers in source code +2. **Build-time generation**: The source generator creates the implementation at build time, but the attribute declaration remains in user code +3. **Detectable before generation**: The redundant `RegexOptions.Compiled` flag can be detected in the attribute before code generation occurs + +**Important:** While the `[GeneratedRegex]` attribute triggers source generation, the attribute itself and its parameters are part of the user's source code and should be analyzed for correctness. + +Example of analyzed code: + +```csharp +using System.Text.RegularExpressions; + +public partial class MyClass +{ + // ATC301 analyzes this attribute declaration (user-written code) + // even though the method implementation will be source-generated + [GeneratedRegex(@"\d+", RegexOptions.Compiled)] // ⚠️ ATC301 warning + private static partial Regex NumberRegex(); +} +``` + +## 🔧 Code Fix + +A code fix is available that automatically removes the redundant `RegexOptions.Compiled` flag from `[GeneratedRegex]` attributes. + +**How to apply:** +1. Position cursor on the `[GeneratedRegex]` attribute that contains `RegexOptions.Compiled` +2. Click the lightbulb (💡) or press `Ctrl+.` (Windows/Linux) or `Cmd+.` (Mac) +3. Select **"Remove redundant RegexOptions.Compiled flag"** + +The code fix will: +- Remove `RegexOptions.Compiled` from the options parameter +- If `Compiled` is combined with other flags using `|`, remove only the `Compiled` flag and one adjacent `|` operator +- If `Compiled` is the only flag, remove the entire options parameter +- Preserve all other flags and formatting +- Maintain proper syntax and structure of the attribute + +**Example 1 - Remove only flag:** +```csharp +// Before code fix +[GeneratedRegex(@"\d+", RegexOptions.Compiled)] +private static partial Regex NumberRegex(); + +// After code fix (automatic) +[GeneratedRegex(@"\d+")] +private static partial Regex NumberRegex(); +``` + +**Example 2 - Remove flag from combination (first position):** +```csharp +// Before code fix +[GeneratedRegex(@"\d+", RegexOptions.Compiled | RegexOptions.IgnoreCase)] +private static partial Regex NumberRegex(); + +// After code fix (automatic) +[GeneratedRegex(@"\d+", RegexOptions.IgnoreCase)] +private static partial Regex NumberRegex(); +``` + +**Example 3 - Remove flag from combination (last position):** +```csharp +// Before code fix +[GeneratedRegex(@"\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled)] +private static partial Regex NumberRegex(); + +// After code fix (automatic) +[GeneratedRegex(@"\d+", RegexOptions.IgnoreCase)] +private static partial Regex NumberRegex(); +``` + +**Example 4 - Remove flag from multiple flags (middle position):** +```csharp +// Before code fix +[GeneratedRegex(@"\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline)] +private static partial Regex NumberRegex(); + +// After code fix (automatic) +[GeneratedRegex(@"\d+", RegexOptions.IgnoreCase | RegexOptions.Multiline)] +private static partial Regex NumberRegex(); +``` + +**Example 5 - Multi-line attribute with named parameters:** +```csharp +// Before code fix +[GeneratedRegex( + pattern: @"[$>]", + options: RegexOptions.Compiled | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] +private static partial Regex PromptRegex(); + +// After code fix (automatic) +[GeneratedRegex( + pattern: @"[$>]", + options: RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] +private static partial Regex PromptRegex(); +``` + +## 🔗 Related Rules + +This is the first Usage category rule in the Atc.Analyzer project. As more Usage rules are added, they will be linked here. diff --git a/sample/Atc.Analyzer.Sample.Atc301/Atc.Analyzer.Sample.Atc301.csproj b/sample/Atc.Analyzer.Sample.Atc301/Atc.Analyzer.Sample.Atc301.csproj new file mode 100644 index 0000000..3b0ff39 --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc301/Atc.Analyzer.Sample.Atc301.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + false + false + + + + + false + Analyzer + + + + diff --git a/sample/Atc.Analyzer.Sample.Atc301/GlobalUsings.cs b/sample/Atc.Analyzer.Sample.Atc301/GlobalUsings.cs new file mode 100644 index 0000000..d95ab04 --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc301/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Global using directives + +global using System.Text.RegularExpressions; +global using Atc.Analyzer.Sample.Atc301.Scenarios; \ No newline at end of file diff --git a/sample/Atc.Analyzer.Sample.Atc301/Program.cs b/sample/Atc.Analyzer.Sample.Atc301/Program.cs new file mode 100644 index 0000000..771becf --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc301/Program.cs @@ -0,0 +1,27 @@ +namespace Atc.Analyzer.Sample.Atc301; + +#pragma warning disable CA1303 + +/// +/// This sample demonstrates VALID scenarios for ATC301 analyzer. +/// ATC301 (GeneratedRegexCompiledFlagAnalyzer): Remove redundant RegexOptions.Compiled flag from [GeneratedRegex] attribute. +/// - The [GeneratedRegex] attribute generates compiled regex code at build time using source generators +/// - Adding RegexOptions.Compiled to the options parameter is redundant and has no effect +/// - This sample shows correct usage WITHOUT the Compiled flag +/// +internal static class Program +{ + private static void Main() + { + Console.WriteLine("ATC301: GeneratedRegex Compiled Flag Analyzer - Valid Scenarios Demo"); + Console.WriteLine(); + + // Demonstrate various valid scenarios + ValidExamples.DemonstrateAllValidScenarios(); + Console.WriteLine(); + + // Note: Invalid examples (with RegexOptions.Compiled) are commented out + // because they would trigger ATC301 warnings + Console.WriteLine("Invalid examples are commented out in Scenarios/InvalidExamples.cs"); + } +} \ No newline at end of file diff --git a/sample/Atc.Analyzer.Sample.Atc301/Scenarios/InvalidExamples.cs b/sample/Atc.Analyzer.Sample.Atc301/Scenarios/InvalidExamples.cs new file mode 100644 index 0000000..5a18c05 --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc301/Scenarios/InvalidExamples.cs @@ -0,0 +1,38 @@ +namespace Atc.Analyzer.Sample.Atc301.Scenarios; + +/// +/// Invalid examples that would trigger ATC301 warnings. +/// These are commented out to allow the sample project to build without warnings. +/// +internal static partial class InvalidExamples +{ + // INVALID: GeneratedRegex with RegexOptions.Compiled flag + // The Compiled flag is redundant because GeneratedRegex already generates compiled code. + // ⚠️ ATC301: The RegexOptions.Compiled flag is redundant for [GeneratedRegex] attributes + // + // [GeneratedRegex(@"\d+", RegexOptions.Compiled)] + // private static partial Regex InvalidCompiledOnlyRegex(); + + // INVALID: GeneratedRegex with Compiled flag combined with other options + // ⚠️ ATC301: The RegexOptions.Compiled flag is redundant for [GeneratedRegex] attributes + // + // [GeneratedRegex(@"[a-z]+", RegexOptions.Compiled | RegexOptions.IgnoreCase)] + // private static partial Regex InvalidCompiledWithIgnoreCaseRegex(); + + // INVALID: GeneratedRegex with Compiled flag in named parameter + // ⚠️ ATC301: The RegexOptions.Compiled flag is redundant for [GeneratedRegex] attributes + // + // [GeneratedRegex( + // pattern: @"[$>]", + // options: RegexOptions.Compiled | RegexOptions.ExplicitCapture, + // matchTimeoutMilliseconds: 1000)] + // private static partial Regex InvalidNamedParameterRegex(); + + // INVALID: Multiple flags including Compiled + // ⚠️ ATC301: The RegexOptions.Compiled flag is redundant for [GeneratedRegex] attributes + // + // [GeneratedRegex( + // @"^\d+$", + // RegexOptions.Multiline | RegexOptions.Compiled | RegexOptions.IgnoreCase)] + // private static partial Regex InvalidMultipleFlagsRegex(); +} \ No newline at end of file diff --git a/sample/Atc.Analyzer.Sample.Atc301/Scenarios/ValidExamples.cs b/sample/Atc.Analyzer.Sample.Atc301/Scenarios/ValidExamples.cs new file mode 100644 index 0000000..1e9200e --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc301/Scenarios/ValidExamples.cs @@ -0,0 +1,81 @@ +// ReSharper disable RedundantVerbatimStringPrefix +namespace Atc.Analyzer.Sample.Atc301.Scenarios; + +#pragma warning disable CA1303, MA0009, MA0110, SYSLIB1045 + +/// +/// Valid examples of [GeneratedRegex] usage without RegexOptions.Compiled flag. +/// +internal static partial class ValidExamples +{ + /// + /// GeneratedRegex without any options parameter - VALID. + /// + [GeneratedRegex(@"\d+")] + private static partial Regex SimpleNumberRegex(); + + /// + /// GeneratedRegex with IgnoreCase option (no Compiled flag) - VALID. + /// + [GeneratedRegex(@"[a-z]+", RegexOptions.IgnoreCase)] + private static partial Regex CaseInsensitiveRegex(); + + /// + /// GeneratedRegex with multiple options (no Compiled flag) - VALID. + /// + [GeneratedRegex(@"^\d+$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] + private static partial Regex MultipleOptionsRegex(); + + /// + /// GeneratedRegex with named parameters (no Compiled flag) - VALID. + /// + [GeneratedRegex( + pattern: @"[$>]", + options: RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex UserOrFtpPromptRegex(); + + /// + /// GeneratedRegex with None option explicitly - VALID. + /// + [GeneratedRegex(@"\w+", RegexOptions.None)] + private static partial Regex NoneOptionRegex(); + + /// + /// Regular (non-generated) Regex with Compiled flag - VALID. + /// This is a normal Regex construction, not a GeneratedRegex attribute, + /// so ATC301 does not apply here. + /// + private static readonly Regex TraditionalCompiledRegex = + new(@"\d+", RegexOptions.Compiled); + + public static void DemonstrateAllValidScenarios() + { + Console.WriteLine("Valid GeneratedRegex Examples:"); + Console.WriteLine(); + + // Test SimpleNumberRegex + var numberMatch = SimpleNumberRegex().Match("abc123def"); + Console.WriteLine($"SimpleNumberRegex matched: {numberMatch.Value}"); + + // Test CaseInsensitiveRegex + var caseMatch = CaseInsensitiveRegex().Match("ABC"); + Console.WriteLine($"CaseInsensitiveRegex matched: {caseMatch.Success}"); + + // Test MultipleOptionsRegex + var multiMatch = MultipleOptionsRegex().Match("123"); + Console.WriteLine($"MultipleOptionsRegex matched: {multiMatch.Success}"); + + // Test UserOrFtpPromptRegex + var promptMatch = UserOrFtpPromptRegex().Match("user$ "); + Console.WriteLine($"UserOrFtpPromptRegex matched: {promptMatch.Value}"); + + // Test NoneOptionRegex + var noneMatch = NoneOptionRegex().Match("test"); + Console.WriteLine($"NoneOptionRegex matched: {noneMatch.Value}"); + + // Test TraditionalCompiledRegex + var traditionalMatch = TraditionalCompiledRegex.Match("456"); + Console.WriteLine($"TraditionalCompiledRegex matched: {traditionalMatch.Value}"); + } +} \ No newline at end of file diff --git a/src/Atc.Analyzer/AnalyzerReleases.Unshipped.md b/src/Atc.Analyzer/AnalyzerReleases.Unshipped.md index 9b238c2..80adc37 100644 --- a/src/Atc.Analyzer/AnalyzerReleases.Unshipped.md +++ b/src/Atc.Analyzer/AnalyzerReleases.Unshipped.md @@ -7,3 +7,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- ATC220 | Style | Warning | Use global usings for all namespaces (strict policy) ATC221 | Style | Warning | Use global usings for common namespaces (lenient policy) +ATC301 | Usage | Warning | Remove redundant RegexOptions.Compiled flag from [GeneratedRegex] attribute diff --git a/src/Atc.Analyzer/Atc.Analyzer.csproj b/src/Atc.Analyzer/Atc.Analyzer.csproj index edc03c8..058bb2c 100644 --- a/src/Atc.Analyzer/Atc.Analyzer.csproj +++ b/src/Atc.Analyzer/Atc.Analyzer.csproj @@ -32,8 +32,8 @@ - - + + diff --git a/src/Atc.Analyzer/Atc.Analyzer.sln b/src/Atc.Analyzer/Atc.Analyzer.sln new file mode 100644 index 0000000..bbf21d0 --- /dev/null +++ b/src/Atc.Analyzer/Atc.Analyzer.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atc.Analyzer", "Atc.Analyzer.csproj", "{A6AE4418-C740-D332-B6E6-093A872AC736}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A6AE4418-C740-D332-B6E6-093A872AC736}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6AE4418-C740-D332-B6E6-093A872AC736}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6AE4418-C740-D332-B6E6-093A872AC736}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6AE4418-C740-D332-B6E6-093A872AC736}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {77F2935A-8249-4C61-8097-8256C85468C2} + EndGlobalSection +EndGlobal diff --git a/src/Atc.Analyzer/RuleIdentifierConstants.cs b/src/Atc.Analyzer/RuleIdentifierConstants.cs index fd59e05..addb10b 100644 --- a/src/Atc.Analyzer/RuleIdentifierConstants.cs +++ b/src/Atc.Analyzer/RuleIdentifierConstants.cs @@ -40,4 +40,15 @@ internal static class Style /// internal const string GlobalUsingsCommon = "ATC221"; } + + /// + /// Usage - RuleIdentifiers from ATC301 to RuleIdentifier ATC399. + /// + internal static class Usage + { + /// + /// Remove redundant RegexOptions.Compiled flag from [GeneratedRegex] attribute. + /// + internal const string GeneratedRegexCompiledFlag = "ATC301"; + } } \ No newline at end of file diff --git a/src/Atc.Analyzer/Rules/Usage/GeneratedRegexCompiledFlagAnalyzer.cs b/src/Atc.Analyzer/Rules/Usage/GeneratedRegexCompiledFlagAnalyzer.cs new file mode 100644 index 0000000..d4fd7cc --- /dev/null +++ b/src/Atc.Analyzer/Rules/Usage/GeneratedRegexCompiledFlagAnalyzer.cs @@ -0,0 +1,206 @@ +namespace Atc.Analyzer.Rules.Usage; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class GeneratedRegexCompiledFlagAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifierConstants.Usage.GeneratedRegexCompiledFlag, + title: "Remove redundant RegexOptions.Compiled flag from [GeneratedRegex] attribute", + messageFormat: "The RegexOptions.Compiled flag is redundant for [GeneratedRegex] attributes as they are already compiled at build time", + RuleCategoryConstants.Usage, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The [GeneratedRegex] attribute generates compiled regex code at build time using source generators. Adding RegexOptions.Compiled to the options parameter is redundant and has no effect, as the generated regex is already compiled.", + helpLinkUri: RuleIdentifierHelper.GetHelpUri(RuleIdentifierConstants.Usage.GeneratedRegexCompiledFlag)); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeMethodDeclaration, SyntaxKind.MethodDeclaration); + } + + [SuppressMessage("Design", "MA0051:Method is too long", Justification = "OK.")] + private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context) + { + if (context.Node.SyntaxTree.IsAutoGeneratedFile()) + { + return; + } + + var methodDeclaration = (MethodDeclarationSyntax)context.Node; + + // Find GeneratedRegex attribute + AttributeSyntax? generatedRegexAttribute = null; + foreach (var attributeList in methodDeclaration.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var attributeName = attribute.Name.ToString(); + if (!attributeName.Contains("GeneratedRegex")) + { + continue; + } + + // Check if this is actually the GeneratedRegex attribute via semantic model + var symbolInfo = context.SemanticModel.GetSymbolInfo(attribute, context.CancellationToken); + if (symbolInfo.Symbol is IMethodSymbol methodSymbol && + methodSymbol.ContainingType?.ToDisplayString() == "System.Text.RegularExpressions.GeneratedRegexAttribute") + { + generatedRegexAttribute = attribute; + break; + } + } + + if (generatedRegexAttribute is not null) + { + break; + } + } + + if (generatedRegexAttribute is null) + { + return; + } + + var attributeSyntax = generatedRegexAttribute; + + // Get the attribute arguments + if (attributeSyntax.ArgumentList is null) + { + return; + } + + // Find the options parameter (second parameter or named parameter) + AttributeArgumentSyntax? optionsArgument = null; + + // Check for named parameter "options" + foreach (var argument in attributeSyntax.ArgumentList.Arguments) + { + if (argument.NameEquals?.Name.Identifier.Text == "options") + { + optionsArgument = argument; + break; + } + } + + // If not found as named parameter, check for second positional parameter + if (optionsArgument is null && attributeSyntax.ArgumentList.Arguments.Count >= 2) + { + optionsArgument = attributeSyntax.ArgumentList.Arguments[1]; + } + + if (optionsArgument is null) + { + return; + } + + // Check if the options expression contains RegexOptions.Compiled + var containsCompiled = ContainsRegexOptionsCompiled(context, optionsArgument.Expression); + if (!containsCompiled.HasValue || !containsCompiled.Value) + { + return; + } + + // Find the exact location of RegexOptions.Compiled in the expression + var compiledLocation = FindCompiledFlagLocation(context, optionsArgument.Expression); + if (compiledLocation is null) + { + // Fallback to the entire options argument + compiledLocation = optionsArgument.GetLocation(); + } + + var diagnostic = Diagnostic.Create(Rule, compiledLocation); + context.ReportDiagnostic(diagnostic); + } + + private static bool? ContainsRegexOptionsCompiled(SyntaxNodeAnalysisContext context, ExpressionSyntax expression) + { + // Check if this is a simple member access: RegexOptions.Compiled + if (expression is MemberAccessExpressionSyntax memberAccess) + { + var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccess, context.CancellationToken); + if (symbolInfo.Symbol is IFieldSymbol fieldSymbol) + { + return fieldSymbol.ContainingType?.ToDisplayString() == "System.Text.RegularExpressions.RegexOptions" + && fieldSymbol.Name == "Compiled"; + } + } + + // Check if this is a binary expression with OR operator + if (expression is BinaryExpressionSyntax binaryExpression && binaryExpression.IsKind(SyntaxKind.BitwiseOrExpression)) + { + var leftContains = ContainsRegexOptionsCompiled(context, binaryExpression.Left); + var rightContains = ContainsRegexOptionsCompiled(context, binaryExpression.Right); + + return (leftContains.HasValue && leftContains.Value) || (rightContains.HasValue && rightContains.Value); + } + + // Check if this is a cast expression + if (expression is CastExpressionSyntax castExpression) + { + return ContainsRegexOptionsCompiled(context, castExpression.Expression); + } + + // Check if this is a parenthesized expression + if (expression is ParenthesizedExpressionSyntax parenthesizedExpression) + { + return ContainsRegexOptionsCompiled(context, parenthesizedExpression.Expression); + } + + return false; + } + + private static Location? FindCompiledFlagLocation(SyntaxNodeAnalysisContext context, ExpressionSyntax expression) + { + // Check if this is a simple member access: RegexOptions.Compiled + if (expression is MemberAccessExpressionSyntax memberAccess) + { + var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccess, context.CancellationToken); + if (symbolInfo.Symbol is IFieldSymbol fieldSymbol + && fieldSymbol.ContainingType?.ToDisplayString() == "System.Text.RegularExpressions.RegexOptions" + && fieldSymbol.Name == "Compiled") + { + return memberAccess.GetLocation(); + } + } + + // Check if this is a binary expression with OR operator + if (expression is BinaryExpressionSyntax binaryExpression && binaryExpression.IsKind(SyntaxKind.BitwiseOrExpression)) + { + var leftLocation = FindCompiledFlagLocation(context, binaryExpression.Left); + if (leftLocation is not null) + { + return leftLocation; + } + + var rightLocation = FindCompiledFlagLocation(context, binaryExpression.Right); + if (rightLocation is not null) + { + return rightLocation; + } + } + + // Check if this is a cast expression + if (expression is CastExpressionSyntax castExpression) + { + return FindCompiledFlagLocation(context, castExpression.Expression); + } + + // Check if this is a parenthesized expression + if (expression is ParenthesizedExpressionSyntax parenthesizedExpression) + { + return FindCompiledFlagLocation(context, parenthesizedExpression.Expression); + } + + return null; + } +} \ No newline at end of file diff --git a/src/Atc.Analyzer/Rules/Usage/GeneratedRegexCompiledFlagCodeFixProvider.cs b/src/Atc.Analyzer/Rules/Usage/GeneratedRegexCompiledFlagCodeFixProvider.cs new file mode 100644 index 0000000..af73e18 --- /dev/null +++ b/src/Atc.Analyzer/Rules/Usage/GeneratedRegexCompiledFlagCodeFixProvider.cs @@ -0,0 +1,235 @@ +namespace Atc.Analyzer.Rules.Usage; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(GeneratedRegexCompiledFlagCodeFixProvider))] +[Shared] +public sealed class GeneratedRegexCompiledFlagCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds + => [RuleIdentifierConstants.Usage.GeneratedRegexCompiledFlag]; + + 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 diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + // Find the attribute node + var node = root.FindNode(diagnosticSpan); + + // Find the attribute containing this node + var attribute = node.FirstAncestorOrSelf(); + if (attribute is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: "Remove redundant RegexOptions.Compiled flag", + createChangedDocument: c => RemoveCompiledFlagAsync(context.Document, attribute, c), + equivalenceKey: nameof(GeneratedRegexCompiledFlagCodeFixProvider)), + context.Diagnostics); + } + + [SuppressMessage("Design", "MA0051:Method is too long", Justification = "OK.")] + private static async Task RemoveCompiledFlagAsync( + Document document, + AttributeSyntax attribute, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + if (attribute.ArgumentList is null) + { + return document; + } + + // Find the options argument + AttributeArgumentSyntax? optionsArgument = null; + var optionsArgumentIndex = -1; + + // Check for named parameter "options" + for (var i = 0; i < attribute.ArgumentList.Arguments.Count; i++) + { + var argument = attribute.ArgumentList.Arguments[i]; + if (argument.NameEquals?.Name.Identifier.Text == "options") + { + optionsArgument = argument; + optionsArgumentIndex = i; + break; + } + } + + // If not found as named parameter, check for second positional parameter + if (optionsArgument is null && attribute.ArgumentList.Arguments.Count >= 2) + { + optionsArgument = attribute.ArgumentList.Arguments[1]; + optionsArgumentIndex = 1; + } + + if (optionsArgument is null) + { + return document; + } + + // Try to remove just the Compiled flag from the expression + var newExpression = RemoveCompiledFromExpression(optionsArgument.Expression); + + AttributeSyntax newAttribute; + + if (newExpression is null) + { + // The Compiled flag was the only option, remove the entire options argument + var newArguments = attribute.ArgumentList.Arguments.RemoveAt(optionsArgumentIndex); + + // Clean up separators and whitespace + var newArgumentList = attribute.ArgumentList.WithArguments(newArguments); + + // If we removed the last argument and there's trailing trivia, clean it up + if (newArguments.Count > 0 && optionsArgumentIndex == attribute.ArgumentList.Arguments.Count - 1) + { + var lastArgIndex = newArguments.Count - 1; + var lastArg = newArguments[lastArgIndex]; + newArgumentList = newArgumentList.WithArguments( + newArguments.Replace(lastArg, lastArg.WithTrailingTrivia())); + } + + newAttribute = attribute.WithArgumentList(newArgumentList); + } + else + { + // Replace the options argument with the modified expression + var newOptionsArgument = optionsArgument.WithExpression(newExpression); + var newArguments = attribute.ArgumentList.Arguments.Replace(optionsArgument, newOptionsArgument); + newAttribute = attribute.WithArgumentList(attribute.ArgumentList.WithArguments(newArguments)); + } + + var newRoot = root.ReplaceNode(attribute, newAttribute); + return document.WithSyntaxRoot(newRoot); + } + + [SuppressMessage("Design", "MA0051:Method is too long", Justification = "OK.")] + private static ExpressionSyntax? RemoveCompiledFromExpression(ExpressionSyntax expression) + { + // If this is just RegexOptions.Compiled by itself, return null to indicate removal + if (IsCompiledFlag(expression)) + { + return null; + } + + // If this is a binary expression with OR operator + if (expression is BinaryExpressionSyntax binaryExpression && binaryExpression.IsKind(SyntaxKind.BitwiseOrExpression)) + { + var leftIsCompiled = IsCompiledFlag(binaryExpression.Left); + var rightIsCompiled = IsCompiledFlag(binaryExpression.Right); + + if (leftIsCompiled) + { + // Remove left side and the OR operator, return just the right side + // Preserve any leading trivia from the binary expression + return binaryExpression.Right.WithLeadingTrivia(binaryExpression.GetLeadingTrivia()); + } + + if (rightIsCompiled) + { + // Remove right side and the OR operator, return just the left side + // Preserve any trailing trivia from the binary expression + return binaryExpression.Left.WithTrailingTrivia(binaryExpression.GetTrailingTrivia()); + } + + // Check if Compiled is nested deeper in the left or right side + var newLeft = RemoveCompiledFromExpression(binaryExpression.Left); + if (newLeft is not null && newLeft != binaryExpression.Left) + { + return binaryExpression.WithLeft(newLeft); + } + + var newRight = RemoveCompiledFromExpression(binaryExpression.Right); + if (newRight is not null && newRight != binaryExpression.Right) + { + return binaryExpression.WithRight(newRight); + } + + // If the left side was removed (null), return just the right + if (newLeft is null) + { + return binaryExpression.Right.WithLeadingTrivia(binaryExpression.GetLeadingTrivia()); + } + + // If the right side was removed (null), return just the left + if (newRight is null) + { + return binaryExpression.Left.WithTrailingTrivia(binaryExpression.GetTrailingTrivia()); + } + } + + // If this is a parenthesized expression + if (expression is ParenthesizedExpressionSyntax parenthesizedExpression) + { + var newInner = RemoveCompiledFromExpression(parenthesizedExpression.Expression); + if (newInner is null) + { + return null; + } + + if (newInner != parenthesizedExpression.Expression) + { + // If the inner expression changed, update it + // If the inner is no longer a binary expression, we might want to remove the parentheses + if (newInner is not BinaryExpressionSyntax) + { + return newInner.WithTriviaFrom(parenthesizedExpression); + } + + return parenthesizedExpression.WithExpression(newInner); + } + } + + // If this is a cast expression + if (expression is CastExpressionSyntax castExpression) + { + var newInner = RemoveCompiledFromExpression(castExpression.Expression); + if (newInner is null) + { + return null; + } + + if (newInner != castExpression.Expression) + { + return castExpression.WithExpression(newInner); + } + } + + return expression; + } + + private static bool IsCompiledFlag(ExpressionSyntax expression) + { + // Strip parentheses + while (expression is ParenthesizedExpressionSyntax parenthesized) + { + expression = parenthesized.Expression; + } + + // Check if this is RegexOptions.Compiled + if (expression is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text == "Compiled" + && memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "RegexOptions" }; + } + + return false; + } +} \ No newline at end of file diff --git a/test/Atc.Analyzer.Tests/Atc.Analyzer.Tests.csproj b/test/Atc.Analyzer.Tests/Atc.Analyzer.Tests.csproj index 6f07521..4133725 100644 --- a/test/Atc.Analyzer.Tests/Atc.Analyzer.Tests.csproj +++ b/test/Atc.Analyzer.Tests/Atc.Analyzer.Tests.csproj @@ -18,7 +18,7 @@ - + diff --git a/test/Atc.Analyzer.Tests/GlobalUsings.cs b/test/Atc.Analyzer.Tests/GlobalUsings.cs index e0127f5..b24449b 100644 --- a/test/Atc.Analyzer.Tests/GlobalUsings.cs +++ b/test/Atc.Analyzer.Tests/GlobalUsings.cs @@ -2,6 +2,7 @@ global using Atc.Analyzer.Extensions; global using Atc.Analyzer.Rules.Style; +global using Atc.Analyzer.Rules.Usage; global using Atc.Analyzer.Tests.Extensions; global using Atc.Analyzer.Tests.Verifiers; diff --git a/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagAnalyzerNoDiagnosticTests.cs b/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagAnalyzerNoDiagnosticTests.cs new file mode 100644 index 0000000..b177712 --- /dev/null +++ b/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagAnalyzerNoDiagnosticTests.cs @@ -0,0 +1,146 @@ +namespace Atc.Analyzer.Tests.Rules.Usage; + +#pragma warning disable SA1135 // Using directives must be qualified +using AnalyzerVerifier = CSharpAnalyzerVerifier; +#pragma warning restore SA1135 // Using directives must be qualified + +[SuppressMessage("", "AsyncFixer01:The method does not need to use async/await", Justification = "OK - Test code")] +[SuppressMessage("Naming", "MA0048:File name must match type name", Justification = "OK - Partial class")] +public sealed partial class GeneratedRegexCompiledFlagAnalyzerTests +{ + [Fact] + public async Task NoDiagnostic_GeneratedRegexWithoutOptions() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+")] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoDiagnostic_GeneratedRegexWithIgnoreCaseOption() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoDiagnostic_GeneratedRegexWithMultilineOption() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("^\\d+$", RegexOptions.Multiline)] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoDiagnostic_GeneratedRegexWithMultipleOptionsWithoutCompiled() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase | RegexOptions.Multiline)] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoDiagnostic_GeneratedRegexWithNamedOptionsParameter() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", options: RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoDiagnostic_GeneratedRegexWithTimeoutParameter() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.None, 1000)] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoDiagnostic_RegularRegexWithCompiled() + { + const string source = """ + using System.Text.RegularExpressions; + + public class Sample + { + private static readonly Regex NumberRegex = new Regex("\\d+", RegexOptions.Compiled); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoDiagnostic_NonRegexAttribute() + { + const string source = """ + using System; + + public class SampleAttribute : Attribute + { + public SampleAttribute(string pattern, int options) + { + } + } + + public class Test + { + [Sample("test", 1)] + public void Method() + { + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } +} \ No newline at end of file diff --git a/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagAnalyzerReportsDiagnosticTests.cs b/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagAnalyzerReportsDiagnosticTests.cs new file mode 100644 index 0000000..522668d --- /dev/null +++ b/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagAnalyzerReportsDiagnosticTests.cs @@ -0,0 +1,106 @@ +namespace Atc.Analyzer.Tests.Rules.Usage; + +#pragma warning disable SA1135 // Using directives must be qualified +using AnalyzerVerifier = CSharpAnalyzerVerifier; +#pragma warning restore SA1135 // Using directives must be qualified + +[SuppressMessage("", "AsyncFixer01:The method does not need to use async/await", Justification = "OK - Test code")] +[SuppressMessage("Naming", "MA0048:File name must match type name", Justification = "OK - Partial class")] +public sealed partial class GeneratedRegexCompiledFlagAnalyzerTests +{ + [Fact] + public async Task ReportsDiagnostic_GeneratedRegexWithCompiledOption() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", [|RegexOptions.Compiled|])] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task ReportsDiagnostic_GeneratedRegexWithCompiledAndIgnoreCase() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", [|RegexOptions.Compiled|] | RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task ReportsDiagnostic_GeneratedRegexWithIgnoreCaseAndCompiled() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase | [|RegexOptions.Compiled|])] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task ReportsDiagnostic_GeneratedRegexWithMultipleOptionsIncludingCompiled() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase | [|RegexOptions.Compiled|] | RegexOptions.Multiline)] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task ReportsDiagnostic_GeneratedRegexWithNamedOptionsParameter() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", options: [|RegexOptions.Compiled|])] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task ReportsDiagnostic_GeneratedRegexWithNamedOptionsParameterAndMultipleFlags() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", options: [|RegexOptions.Compiled|] | RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(source); + } +} \ No newline at end of file diff --git a/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagCodeFixProviderTests.cs b/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagCodeFixProviderTests.cs new file mode 100644 index 0000000..c509f3e --- /dev/null +++ b/test/Atc.Analyzer.Tests/Rules/Usage/GeneratedRegexCompiledFlagCodeFixProviderTests.cs @@ -0,0 +1,253 @@ +namespace Atc.Analyzer.Tests.Rules.Usage; + +#pragma warning disable SA1135 // Using directives must be qualified +using CodeFixVerifier = CSharpCodeFixVerifier; +#pragma warning restore SA1135 // Using directives must be qualified + +[SuppressMessage("", "AsyncFixer01:The method does not need to use async/await", Justification = "OK - Test code")] +public sealed class GeneratedRegexCompiledFlagCodeFixProviderTests +{ + [Fact] + public async Task FixCompiledOption_WhenOnlyOption() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.Compiled)] + private static partial Regex NumberRegex(); + } + """; + + const string fixedSource = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+")] + private static partial Regex NumberRegex(); + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC301", + DiagnosticSeverity.Warning) + .WithSpan(5, 29, 5, 50), + }; + + await CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task FixCompiledOption_WhenCompiledIsFirstInCombination() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.Compiled | RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + const string fixedSource = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC301", + DiagnosticSeverity.Warning) + .WithSpan(5, 29, 5, 50), + }; + + await CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task FixCompiledOption_WhenCompiledIsLastInCombination() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex NumberRegex(); + } + """; + + const string fixedSource = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC301", + DiagnosticSeverity.Warning) + .WithSpan(5, 55, 5, 76), + }; + + await CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task FixCompiledOption_WhenCompiledIsInMiddleOfCombination() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline)] + private static partial Regex NumberRegex(); + } + """; + + const string fixedSource = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", RegexOptions.IgnoreCase | RegexOptions.Multiline)] + private static partial Regex NumberRegex(); + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC301", + DiagnosticSeverity.Warning) + .WithSpan(5, 55, 5, 76), + }; + + await CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task FixCompiledOption_WithNamedParameter() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", options: RegexOptions.Compiled)] + private static partial Regex NumberRegex(); + } + """; + + const string fixedSource = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+")] + private static partial Regex NumberRegex(); + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC301", + DiagnosticSeverity.Warning) + .WithSpan(5, 38, 5, 59), + }; + + await CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task FixCompiledOption_WithNamedParameterAndMultipleOptions() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", options: RegexOptions.Compiled | RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + const string fixedSource = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex("\\d+", options: RegexOptions.IgnoreCase)] + private static partial Regex NumberRegex(); + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC301", + DiagnosticSeverity.Warning) + .WithSpan(5, 38, 5, 59), + }; + + await CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task FixCompiledOption_WithMultilineAttribute() + { + const string source = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex( + pattern: "\\d+", + options: RegexOptions.Compiled | RegexOptions.IgnoreCase, + matchTimeoutMilliseconds: 1000)] + private static partial Regex NumberRegex(); + } + """; + + const string fixedSource = """ + using System.Text.RegularExpressions; + + public partial class Sample + { + [GeneratedRegex( + pattern: "\\d+", + options: RegexOptions.IgnoreCase, + matchTimeoutMilliseconds: 1000)] + private static partial Regex NumberRegex(); + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC301", + DiagnosticSeverity.Warning) + .WithSpan(7, 18, 7, 39), + }; + + await CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } +} \ No newline at end of file