From ff81ba6ab17d02e32aabcfd2315fbb9ea529360f Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 26 May 2022 09:20:35 -0700 Subject: [PATCH] Adding analyzer/fixer for the Regex Source Generator (#68976) * Adding analyzer/fixer for the Regex Source Generator * Adding some tests to the analyzer and fixer * Fix build and reference live ref pack * Address remaining feedback and fix top-level statement programs * Addressing PR Feedback * Disabling the tests for Mono --- .../gen/DiagnosticDescriptors.cs | 8 + .../gen/Resources/Strings.resx | 8 +- .../gen/Resources/xlf/Strings.cs.xlf | 10 + .../gen/Resources/xlf/Strings.de.xlf | 10 + .../gen/Resources/xlf/Strings.es.xlf | 10 + .../gen/Resources/xlf/Strings.fr.xlf | 10 + .../gen/Resources/xlf/Strings.it.xlf | 10 + .../gen/Resources/xlf/Strings.ja.xlf | 10 + .../gen/Resources/xlf/Strings.ko.xlf | 10 + .../gen/Resources/xlf/Strings.pl.xlf | 10 + .../gen/Resources/xlf/Strings.pt-BR.xlf | 10 + .../gen/Resources/xlf/Strings.ru.xlf | 10 + .../gen/Resources/xlf/Strings.tr.xlf | 10 + .../gen/Resources/xlf/Strings.zh-Hans.xlf | 10 + .../gen/Resources/xlf/Strings.zh-Hant.xlf | 10 + .../gen/UpgradeToRegexGeneratorAnalyzer.cs | 296 +++++++ .../gen/UpgradeToRegexGeneratorCodeFixer.cs | 304 +++++++ .../src/Resources/Strings.resx | 5 +- .../UnitTests/CSharpCodeFixVerifier`2.cs | 107 +++ .../tests/UnitTests/Stubs.cs | 15 + ....Text.RegularExpressions.Unit.Tests.csproj | 14 +- .../UpgradeToRegexGeneratorAnalyzerTests.cs | 772 ++++++++++++++++++ 22 files changed, 1656 insertions(+), 3 deletions(-) create mode 100644 src/libraries/System.Text.RegularExpressions/gen/UpgradeToRegexGeneratorAnalyzer.cs create mode 100644 src/libraries/System.Text.RegularExpressions/gen/UpgradeToRegexGeneratorCodeFixer.cs create mode 100644 src/libraries/System.Text.RegularExpressions/tests/UnitTests/CSharpCodeFixVerifier`2.cs create mode 100644 src/libraries/System.Text.RegularExpressions/tests/UnitTests/UpgradeToRegexGeneratorAnalyzerTests.cs diff --git a/src/libraries/System.Text.RegularExpressions/gen/DiagnosticDescriptors.cs b/src/libraries/System.Text.RegularExpressions/gen/DiagnosticDescriptors.cs index 2c1d2a0d4a881..5c59055e11ecb 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/DiagnosticDescriptors.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/DiagnosticDescriptors.cs @@ -62,5 +62,13 @@ internal static class DiagnosticDescriptors category: Category, DiagnosticSeverity.Info, isEnabledByDefault: true); + + public static DiagnosticDescriptor UseRegexSourceGeneration { get; } = new DiagnosticDescriptor( + id: "SYSLIB1046", + title: new LocalizableResourceString(nameof(SR.UseRegexSourceGeneratorTitle), SR.ResourceManager, typeof(FxResources.System.Text.RegularExpressions.Generator.SR)), + messageFormat: new LocalizableResourceString(nameof(SR.UseRegexSourceGeneratorMessage), SR.ResourceManager, typeof(FxResources.System.Text.RegularExpressions.Generator.SR)), + category: Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true); } } diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/Strings.resx b/src/libraries/System.Text.RegularExpressions/gen/Resources/Strings.resx index 4fd64a89335f9..dfee4c675b49c 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/Strings.resx +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/Strings.resx @@ -284,4 +284,10 @@ Regex replacements with substitutions of groups are not supported with RegexOptions.NonBacktracking. {Locked="RegexOptions.NonBacktracking"} - + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.cs.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.cs.xlf index b65a3d3233ecf..9ca26e78bdd48 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.cs.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.cs.xlf @@ -272,6 +272,16 @@ Neukončený komentář (?#...). + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.de.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.de.xlf index 058937f0c2c96..f94bcea6d402c 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.de.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.de.xlf @@ -272,6 +272,16 @@ Nicht abgeschlossener (?#...)-Kommentar. + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.es.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.es.xlf index f2a89061af3e0..40fde404fdfc5 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.es.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.es.xlf @@ -272,6 +272,16 @@ Comentario (?#...) sin terminar. + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.fr.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.fr.xlf index ece8855c96ddc..1db9425597ffb 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.fr.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.fr.xlf @@ -272,6 +272,16 @@ Commentaire (?#...) inachevé. + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.it.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.it.xlf index f0f23759feafe..458bc18b84b5c 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.it.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.it.xlf @@ -272,6 +272,16 @@ Commento (?#...) non terminato. + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ja.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ja.xlf index 39f4ee1483142..02f2a8c91a2bf 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ja.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ja.xlf @@ -272,6 +272,16 @@ 未終了の (?#...) コメントです。 + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ko.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ko.xlf index b096ce1d4762d..c9f2586872984 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ko.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ko.xlf @@ -272,6 +272,16 @@ 종결되지 않은 (?#...) 주석입니다. + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.pl.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.pl.xlf index c5e3f71bf92b9..06cbe46b86ac3 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.pl.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.pl.xlf @@ -272,6 +272,16 @@ Niezakończony komentarz (?#...). + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.pt-BR.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.pt-BR.xlf index 3527b61b791e0..9f6a7101fbaf5 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.pt-BR.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.pt-BR.xlf @@ -272,6 +272,16 @@ Comentário (?#...) não finalizado. + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ru.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ru.xlf index 0e31de23787c5..ce61c81def8f2 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ru.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.ru.xlf @@ -272,6 +272,16 @@ Комментарий (?#...) без признака завершения. + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.tr.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.tr.xlf index 180d2bfa015a5..4d19136042455 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.tr.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.tr.xlf @@ -272,6 +272,16 @@ Sonlandırılmayan (?#...) yorumu. + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.zh-Hans.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.zh-Hans.xlf index 9b035a7e6c221..755ac679ad99c 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.zh-Hans.xlf @@ -272,6 +272,16 @@ 未终止的(?#...)注释。 + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.zh-Hant.xlf b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.zh-Hant.xlf index de77fca34ad29..885f95256b377 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/libraries/System.Text.RegularExpressions/gen/Resources/xlf/Strings.zh-Hant.xlf @@ -272,6 +272,16 @@ 未結束的 (?#...) 註解。 + + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + Use 'RegexGeneratorAttribute' to generate the regular expression implementation at compile-time. + + + + Convert to 'RegexGenerator'. + Convert to 'RegexGenerator'. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToRegexGeneratorAnalyzer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToRegexGeneratorAnalyzer.cs new file mode 100644 index 0000000000000..c40ef8dae7dd9 --- /dev/null +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToRegexGeneratorAnalyzer.cs @@ -0,0 +1,296 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace System.Text.RegularExpressions.Generator +{ + /// + /// Roslyn analyzer that searches for invocations of the Regex constructors, or the + /// Regex static methods and analyzes if the callsite could be using the Regex Generator instead. + /// If so, it will emit an informational diagnostic to suggest use the Regex Generator. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class UpgradeToRegexGeneratorAnalyzer : DiagnosticAnalyzer + { + private const string RegexTypeName = "System.Text.RegularExpressions.Regex"; + private const string RegexGeneratorTypeName = "System.Text.RegularExpressions.RegexGeneratorAttribute"; + + internal const string PatternIndexName = "PatternIndex"; + internal const string RegexOptionsIndexName = "RegexOptionsIndex"; + + /// + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.UseRegexSourceGeneration); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(async compilationContext => + { + Compilation compilation = compilationContext.Compilation; + + // Validate that the project supports the Regex Source Generator based on target framework, + // language version, etc. + if (!ProjectSupportsRegexSourceGenerator(compilation, out INamedTypeSymbol? regexTypeSymbol)) + { + return; + } + + // Validate that the project is not using top-level statements, since if it were, the code-fixer + // can't easily convert to the source generator without having to make the program not use top-level + // statements any longer. + if (await ProjectUsesTopLevelStatements(compilation, compilationContext.CancellationToken).ConfigureAwait(false)) + { + return; + } + + // Pre-compute a hash with all of the method symbols that we want to analyze for possibly emitting + // a diagnostic. + HashSet staticMethodsToDetect = GetMethodSymbolHash(regexTypeSymbol, + new HashSet { "Count", "EnumerateMatches", "IsMatch", "Match", "Matches", "Split", "Replace" }); + + // Register analysis of calls to the Regex constructors + compilationContext.RegisterOperationAction(context => AnalyzeObjectCreation(context, regexTypeSymbol), OperationKind.ObjectCreation); + + // Register analysis of calls to Regex static methods + compilationContext.RegisterOperationAction(context => AnalyzeInvocation(context, regexTypeSymbol, staticMethodsToDetect), OperationKind.Invocation); + }); + + // Creates a HashSet of all of the method Symbols containing the static methods to analyze. + static HashSet GetMethodSymbolHash(INamedTypeSymbol regexTypeSymbol, HashSet methodNames) + { + // This warning is due to a false positive bug https://github.com/dotnet/roslyn-analyzers/issues/5804 + // This issue has now been fixed, but we are not yet consuming the fix and getting this package + // as a transitive dependency from Microsoft.CodeAnalysis.CSharp.Workspaces. Once that dependency + // is updated at the repo-level, we should come and remove the pragma disable. +#pragma warning disable RS1024 // Compare symbols correctly + HashSet hash = new HashSet(SymbolEqualityComparer.Default); +#pragma warning restore RS1024 // Compare symbols correctly + ImmutableArray allMembers = regexTypeSymbol.GetMembers(); + + foreach(ISymbol member in allMembers) + { + if (member is IMethodSymbol method && + method.IsStatic && + methodNames.Contains(method.Name)) + { + hash.Add(method); + } + } + + return hash; + } + } + + /// + /// Analyzes an invocation expression to see if the invocation is a call to one of the Regex static methods, + /// and checks if they could be using the source generator instead. + /// + /// The compilation context representing the invocation. + private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTypeSymbol regexTypeSymbol, HashSet staticMethodsToDetect) + { + // Ensure the invocation is a Regex static method. + IInvocationOperation invocationOperation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocationOperation.TargetMethod; + if (!method.IsStatic || !SymbolEqualityComparer.Default.Equals(method.ContainingType, regexTypeSymbol)) + { + return; + } + + // We need to save the parameters as properties so that we can save them onto the diagnostic so that the + // code fixer can later use that property bag to generate the code fix and emit the RegexGenerator attribute. + if (staticMethodsToDetect.Contains(method)) + { + string? patternArgumentIndex = null; + string? optionsArgumentIndex = null; + + // Validate that arguments pattern and options are constant and timeout was not passed in. + if (!TryValidateParametersAndExtractArgumentIndices(invocationOperation.Arguments, ref patternArgumentIndex, ref optionsArgumentIndex)) + { + return; + } + + // Create the property bag. + ImmutableDictionary properties = ImmutableDictionary.CreateRange(new[] + { + new KeyValuePair(PatternIndexName, patternArgumentIndex), + new KeyValuePair(RegexOptionsIndexName, optionsArgumentIndex) + }); + + // Report the diagnostic. + SyntaxNode? syntaxNodeForDiagnostic = invocationOperation.Syntax; + Debug.Assert(syntaxNodeForDiagnostic != null); + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UseRegexSourceGeneration, syntaxNodeForDiagnostic.GetLocation(), properties)); + } + } + + /// + /// Analyzes an object creation expression to see if the invocation is a call to one of the Regex constructors, + /// and checks if they could be using the source generator instead. + /// + /// The object creation context. + private static void AnalyzeObjectCreation(OperationAnalysisContext context, INamedTypeSymbol regexTypeSymbol) + { + // Ensure the object creation is a call to the Regex constructor. + IObjectCreationOperation operation = (IObjectCreationOperation)context.Operation; + if (!SymbolEqualityComparer.Default.Equals(operation.Type, regexTypeSymbol)) + { + return; + } + + // If the constructor also has a timeout argument, then don't emit a diagnostic. + if (operation.Arguments.Length > 2) + { + return; + } + + string? patternArgumentIndex = null; + string? optionsArgumentIndex = null; + + if (!TryValidateParametersAndExtractArgumentIndices(operation.Arguments, ref patternArgumentIndex, ref optionsArgumentIndex)) + { + return; + } + + // Create the property bag. + ImmutableDictionary properties = ImmutableDictionary.CreateRange(new[] + { + new KeyValuePair(PatternIndexName, patternArgumentIndex), + new KeyValuePair(RegexOptionsIndexName, optionsArgumentIndex) + }); + + // Report the diagnostic. + SyntaxNode? syntaxNodeForDiagnostic = operation.Syntax; + Debug.Assert(syntaxNodeForDiagnostic is not null); + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UseRegexSourceGeneration, syntaxNodeForDiagnostic.GetLocation(), properties)); + } + + /// + /// Validates the operation arguments ensuring they all have constant values, and if so it stores the argument + /// indices for the pattern and options. If timeout argument was used, then this returns false. + /// + private static bool TryValidateParametersAndExtractArgumentIndices(ImmutableArray arguments, ref string? patternArgumentIndex, ref string? optionsArgumentIndex) + { + const string timeoutArgumentName = "timeout"; + const string matchTimeoutArgumentName = "matchTimeout"; + const string patternArgumentName = "pattern"; + const string optionsArgumentName = "options"; + + if (arguments == null) + { + return false; + } + + for (int i = 0; i < arguments.Length; i++) + { + IArgumentOperation argument = arguments[i]; + string argumentName = argument.Parameter.Name; + + // If one of the arguments is a timeout, then we don't emit a diagnostic. + if (argumentName.Equals(timeoutArgumentName, StringComparison.OrdinalIgnoreCase) || + argumentName.Equals(matchTimeoutArgumentName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // If the argument is the pattern, then we validate that it is constant and we store the index. + if (argumentName.Equals(patternArgumentName, StringComparison.OrdinalIgnoreCase)) + { + if (!IsConstant(argument)) + { + return false; + } + + patternArgumentIndex = i.ToString(); + continue; + } + + // If the argument is the options, then we validate that it is constant, that it doesn't have RegexOptions.NonBacktracking, and we store the index. + if (argumentName.Equals(optionsArgumentName, StringComparison.OrdinalIgnoreCase)) + { + if (!IsConstant(argument)) + { + return false; + } + + RegexOptions value = (RegexOptions)((int)argument.Value.ConstantValue.Value); + if ((value & RegexOptions.NonBacktracking) > 0) + { + return false; + } + + optionsArgumentIndex = i.ToString(); + continue; + } + } + + return true; + } + + /// + /// Ensures that the input to the constructor or invocation is constant at compile time + /// which is a requirement in order to be able to use the source generator. + /// + /// The argument to be analyzed. + /// if the argument is constant; otherwise, . + private static bool IsConstant(IArgumentOperation argument) + => argument.Value.ConstantValue.HasValue; + + /// + /// Detects whether or not the current project is using top-level statements. + /// + private static async Task ProjectUsesTopLevelStatements(Compilation compilation, CancellationToken cancellationToken) + { + SyntaxNode? root = await compilation.SyntaxTrees.FirstOrDefault().GetRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return false; + } + + return root.DescendantNodesAndSelf().Where(node => node.IsKind(SyntaxKind.GlobalStatement)).Any(); + } + + /// + /// Ensures that the compilation can find the Regex and RegexAttribute types, and also validates that the + /// LangVersion of the project is >= 10.0 (which is the current requirement for the Regex source generator. + /// + /// The compilation to be analyzed. + /// The resolved Regex type symbol + /// if source generator is supported in the project; otherwise, . + private static bool ProjectSupportsRegexSourceGenerator(Compilation compilation, [NotNullWhen(true)] out INamedTypeSymbol? regexTypeSymbol) + { + regexTypeSymbol = compilation.GetTypeByMetadataName(RegexTypeName); + if (regexTypeSymbol == null) + { + return false; + } + + INamedTypeSymbol regexGeneratorAttributeTypeSymbol = compilation.GetTypeByMetadataName(RegexGeneratorTypeName); + if (regexGeneratorAttributeTypeSymbol == null) + { + return false; + } + + if (compilation.SyntaxTrees.FirstOrDefault().Options is CSharpParseOptions options && options.LanguageVersion <= (LanguageVersion)1000) + { + return false; + } + + return true; + } + } +} diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToRegexGeneratorCodeFixer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToRegexGeneratorCodeFixer.cs new file mode 100644 index 0000000000000..105bc2dcb3d3b --- /dev/null +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToRegexGeneratorCodeFixer.cs @@ -0,0 +1,304 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +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.Operations; +using Microsoft.CodeAnalysis.Simplification; +using Microsoft.CodeAnalysis.Text; + +namespace System.Text.RegularExpressions.Generator +{ + /// + /// Roslyn code fixer that will listen to SysLIB1046 diagnostics and will provide a code fix which onboards a particular Regex into + /// source generation. + /// + [ExportCodeFixProvider(LanguageNames.CSharp)] + public sealed class UpgradeToRegexGeneratorCodeFixer : CodeFixProvider + { + private const string RegexTypeName = "System.Text.RegularExpressions.Regex"; + private const string RegexGeneratorTypeName = "System.Text.RegularExpressions.RegexGeneratorAttribute"; + private const string DefaultRegexMethodName = "MyRegex"; + + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(DiagnosticDescriptors.UseRegexSourceGeneration.Id); + + public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + // Fetch the node to fix, and register the codefix by invoking the ConvertToSourceGenerator method. + SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + SyntaxNode nodeToFix = root.FindNode(context.Span, getInnermostNodeForTie: false); + if (nodeToFix is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + SR.UseRegexSourceGeneratorTitle, + cancellationToken => ConvertToSourceGenerator(context.Document, context.Diagnostics[0], cancellationToken), + equivalenceKey: SR.UseRegexSourceGeneratorTitle), + context.Diagnostics); + } + + /// + /// Takes a and a and returns a new with the replaced + /// nodes in order to apply the code fix to the diagnostic. + /// + /// The original document. + /// The diagnostic to fix. + /// The cancellation token for the async operation. + /// The new document with the replaced nodes after applying the code fix. + private static async Task ConvertToSourceGenerator(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + // We first get the compilation object from the document + SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return document; + } + Compilation compilation = semanticModel.Compilation; + + // We then get the symbols for the Regex and RegexGeneratorAttribute types. + INamedTypeSymbol? regexSymbol = compilation.GetTypeByMetadataName(RegexTypeName); + INamedTypeSymbol? regexGeneratorAttributeSymbol = compilation.GetTypeByMetadataName(RegexGeneratorTypeName); + if (regexSymbol is null || regexGeneratorAttributeSymbol is null) + { + return document; + } + + // Find the node that corresponding to the diagnostic which we will then fix. + SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + SyntaxNode nodeToFix = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: false); + // Save the operation object from the nodeToFix before it gets replaced by the new method invocation. + // We will later use this operation to get the parameters out and pass them into the RegexGenerator attribute. + IOperation? operation = semanticModel.GetOperation(nodeToFix, cancellationToken); + if (operation is null) + { + return document; + } + + // Get the parent type declaration so that we can inspect its methods as well as check if we need to add the partial keyword. + TypeDeclarationSyntax? typeDeclaration = nodeToFix.Ancestors().OfType().FirstOrDefault(); + + if (typeDeclaration is null) + { + return document; + } + + // Calculate what name should be used for the generated static partial method + string methodName = DefaultRegexMethodName; + ITypeSymbol? typeSymbol = semanticModel.GetDeclaredSymbol(typeDeclaration, cancellationToken) as ITypeSymbol; + if (typeSymbol is not null) + { + IEnumerable members = GetAllMembers(typeSymbol); + int memberCount = 1; + while (members.Any(m => m.Name == methodName)) + { + methodName = $"{DefaultRegexMethodName}{memberCount++}"; + } + } + + // Walk the type hirerarchy of the node to fix, and add the partial modifier to each ancestor (if it doesn't have it already) + // We also keep a count of how many partial keywords we added so that we can later find the nodeToFix again on the new root using the text offset. + int typesModified = 0; + root = root.ReplaceNodes( + nodeToFix.Ancestors().OfType(), + (_, typeDeclaration) => + { + if (!typeDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword))) + { + typesModified++; + return typeDeclaration.AddModifiers(SyntaxFactory.Token(SyntaxKind.PartialKeyword)).WithAdditionalAnnotations(Simplifier.Annotation); + } + + return typeDeclaration; + }); + + // We find nodeToFix again by calculating the offset of how many partial keywords we had to add. + nodeToFix = root.FindNode(new TextSpan(nodeToFix.Span.Start + (typesModified * "partial".Length), nodeToFix.Span.Length)); + if (nodeToFix is null) + { + return document; + } + + // We need to find the typeDeclaration again, but now using the new root. + typeDeclaration = nodeToFix.Ancestors().OfType().FirstOrDefault(); + Debug.Assert(typeDeclaration is not null); + TypeDeclarationSyntax newTypeDeclaration = typeDeclaration; + + // We generate a new invocation node to call our new partial method, and use it to replace the nodeToFix. + DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + SyntaxGenerator generator = editor.Generator; + ImmutableDictionary properties = diagnostic.Properties; + + // Generate the modified type declaration depending on whether the callsite was a Regex constructor call + // or a Regex static method invocation. + if (operation is IInvocationOperation invocationOperation) // When using a Regex static method + { + ImmutableArray arguments = invocationOperation.Arguments; + + // Parse the idices for where to get the arguments from. + int?[] indices = new[] + { + TryParseInt32(properties, UpgradeToRegexGeneratorAnalyzer.PatternIndexName), + TryParseInt32(properties, UpgradeToRegexGeneratorAnalyzer.RegexOptionsIndexName) + }; + + foreach (int? index in indices.Where(value => value != null).OrderByDescending(value => value)) + { + arguments = arguments.RemoveAt(index.GetValueOrDefault()); + } + + SyntaxNode createRegexMethod = generator.InvocationExpression(generator.IdentifierName(methodName)); + SyntaxNode method = generator.InvocationExpression(generator.MemberAccessExpression(createRegexMethod, invocationOperation.TargetMethod.Name), arguments.Select(arg => arg.Syntax).ToArray()); + + newTypeDeclaration = newTypeDeclaration.ReplaceNode(nodeToFix, method); + } + else // When using a Regex constructor + { + SyntaxNode invokeMethod = generator.InvocationExpression(generator.IdentifierName(methodName)); + newTypeDeclaration = newTypeDeclaration.ReplaceNode(nodeToFix, invokeMethod); + } + + // Initialize the inputs for the RegexGenerator attribute. + SyntaxNode? patternValue = null; + SyntaxNode? regexOptionsValue = null; + + // Try to get the pattern and RegexOptions values out from the diagnostic's property bag. + if (operation is IObjectCreationOperation objectCreationOperation) // When using the Regex constructors + { + patternValue = GetNode((objectCreationOperation).Arguments, properties, UpgradeToRegexGeneratorAnalyzer.PatternIndexName, generator, useOptionsMemberExpression: false, compilation, cancellationToken); + regexOptionsValue = GetNode((objectCreationOperation).Arguments, properties, UpgradeToRegexGeneratorAnalyzer.RegexOptionsIndexName, generator, useOptionsMemberExpression: true, compilation, cancellationToken); + } + else if (operation is IInvocationOperation invocation) // When using the Regex static methods. + { + patternValue = GetNode(invocation.Arguments, properties, UpgradeToRegexGeneratorAnalyzer.PatternIndexName, generator, useOptionsMemberExpression: false, compilation, cancellationToken); + regexOptionsValue = GetNode(invocation.Arguments, properties, UpgradeToRegexGeneratorAnalyzer.RegexOptionsIndexName, generator, useOptionsMemberExpression: true, compilation, cancellationToken); + } + + // Generate the new static partial method + MethodDeclarationSyntax newMethod = (MethodDeclarationSyntax)generator.MethodDeclaration( + name: methodName, + returnType: generator.TypeExpression(regexSymbol), + modifiers: DeclarationModifiers.Static | DeclarationModifiers.Partial, + accessibility: Accessibility.Private); + + // Allow user to pick a different name for the method. + newMethod = newMethod.ReplaceToken(newMethod.Identifier, SyntaxFactory.Identifier(methodName).WithAdditionalAnnotations(RenameAnnotation.Create())); + + // Generate the RegexGenerator attribute syntax node with the specified parameters. + SyntaxNode attributes = generator.Attribute(generator.TypeExpression(regexGeneratorAttributeSymbol), attributeArguments: (patternValue, regexOptionsValue) switch + { + ({ }, null) => new[] { patternValue }, + ({ }, { }) => new[] { patternValue, regexOptionsValue }, + _ => Array.Empty(), + }); + + // Add the attribute to the generated method. + newMethod = (MethodDeclarationSyntax)generator.AddAttributes(newMethod, attributes); + + // Add the method to the type. + newTypeDeclaration = newTypeDeclaration.AddMembers(newMethod); + + // Replace the old type declaration with the new modified one, and return the document. + return document.WithSyntaxRoot(root.ReplaceNode(typeDeclaration, newTypeDeclaration)); + + static IEnumerable GetAllMembers(ITypeSymbol? symbol) + { + while (symbol != null) + { + foreach (ISymbol member in symbol.GetMembers()) + { + yield return member; + } + + symbol = symbol.BaseType; + } + } + + // Helper method that searches the passed in property bag for the property with the passed in name, and if found, it converts the + // value to an int. + static int? TryParseInt32(ImmutableDictionary properties, string name) + { + if (!properties.TryGetValue(name, out string? value)) + { + return null; + } + + if (!int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out int result)) + { + return null; + } + + return result; + } + + // Helper method that looks int the properties bag for the index of the passed in propertyname, and then returns that index from the args parameter. + static SyntaxNode? GetNode(ImmutableArray args, ImmutableDictionary properties, string propertyName, SyntaxGenerator generator, bool useOptionsMemberExpression, Compilation compilation, CancellationToken cancellationToken) + { + int? index = TryParseInt32(properties, propertyName); + if (index == null) + { + return null; + } + + if (!useOptionsMemberExpression) + { + return generator.LiteralExpression(args[index.Value].Value.ConstantValue.Value); + } + else + { + RegexOptions options = (RegexOptions)(int)args[index.Value].Value.ConstantValue.Value; + string optionsLiteral = Literal(options); + return SyntaxFactory.ParseExpression(optionsLiteral).SyntaxTree.GetRoot(cancellationToken); + } + } + + static string Literal(RegexOptions options) + { + string s = options.ToString(); + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + { + // The options were formatted as an int, which means the runtime couldn't + // produce a textual representation. So just output casting the value as an int. + Debug.Fail("This shouldn't happen, as we should only get to the point of emitting code if RegexOptions was valid."); + return $"(RegexOptions)({(int)options})"; + } + + // Parse the runtime-generated "Option1, Option2" into each piece and then concat + // them back together. + string[] parts = s.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + parts[i] = "RegexOptions." + parts[i].Trim(); + } + return string.Join(" | ", parts); + } + } + } +} diff --git a/src/libraries/System.Text.RegularExpressions/src/Resources/Strings.resx b/src/libraries/System.Text.RegularExpressions/src/Resources/Strings.resx index 452b4ccf8f2cf..e2d170c5ab9bc 100644 --- a/src/libraries/System.Text.RegularExpressions/src/Resources/Strings.resx +++ b/src/libraries/System.Text.RegularExpressions/src/Resources/Strings.resx @@ -288,4 +288,7 @@ Searching an input span using a pre-compiled Regex assembly is not supported. Please use the string overloads or use a newer Regex implementation. - + + Use the RegEx source generator. + + \ No newline at end of file diff --git a/src/libraries/System.Text.RegularExpressions/tests/UnitTests/CSharpCodeFixVerifier`2.cs b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/CSharpCodeFixVerifier`2.cs new file mode 100644 index 0000000000000..648eaff1c202b --- /dev/null +++ b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/CSharpCodeFixVerifier`2.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +namespace System.Text.RegularExpressions.Unit.Tests +{ + public static class CSharpCodeFixVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() + { + /// + public static DiagnosticResult Diagnostic() + => CSharpCodeFixVerifier.Diagnostic(); + + /// + public static DiagnosticResult Diagnostic(string diagnosticId) + => CSharpCodeFixVerifier.Diagnostic(diagnosticId); + + /// + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => CSharpCodeFixVerifier.Diagnostic(descriptor); + + /// + public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + => await VerifyAnalyzerAsync(source, null, usePreviewLanguageVersion: true, expected); + + /// + public static async Task VerifyAnalyzerAsync(string source, ReferenceAssemblies? references, bool usePreviewLanguageVersion, params DiagnosticResult[] expected) + { + Test test = new Test(references, usePreviewLanguageVersion, numberOfIterations: 1) + { + TestCode = source, + }; + + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } + + /// + public static async Task VerifyCodeFixAsync(string source, string fixedSource) + => await VerifyCodeFixAsync(source, DiagnosticResult.EmptyDiagnosticResults, fixedSource); + + /// + public static async Task VerifyCodeFixAsync(string source, DiagnosticResult expected, string fixedSource) + => await VerifyCodeFixAsync(source, new[] { expected }, fixedSource); + + /// + public static async Task VerifyCodeFixAsync(string source, DiagnosticResult[] expected, string fixedSource, int numberOfIterations = 1) + { + Test test = new Test(null, usePreviewLanguageVersion: true, numberOfIterations) + { + TestCode = source, + FixedCode = fixedSource, + }; + + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } + + public class Test : CSharpCodeFixTest + { + public Test(ReferenceAssemblies? references, bool usePreviewLanguageVersion, int numberOfIterations) + { + // Code Fixer generates partial methods that will need to use the source generator to be filled. + this.CompilerDiagnostics = CompilerDiagnostics.None; + + if (references != null) + { + ReferenceAssemblies = references; + } + else + { + // Clear out the default reference assemblies. We explicitly add references from the live ref pack, + // so we don't want the Roslyn test infrastructure to resolve/add any default reference assemblies + ReferenceAssemblies = new ReferenceAssemblies(string.Empty); + TestState.AdditionalReferences.AddRange(SourceGenerators.Tests.LiveReferencePack.GetMetadataReferences()); + } + + NumberOfFixAllIterations = numberOfIterations; + + SolutionTransforms.Add((solution, projectId) => + { + if (usePreviewLanguageVersion) + { + CSharpParseOptions parseOptions = solution.GetProject(projectId).ParseOptions as CSharpParseOptions; + parseOptions = parseOptions.WithLanguageVersion(LanguageVersion.Preview); + solution = solution.WithProjectParseOptions(projectId, parseOptions); + } + + return solution; + }); + } + } + } +} diff --git a/src/libraries/System.Text.RegularExpressions/tests/UnitTests/Stubs.cs b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/Stubs.cs index 70594d4e08bb4..9776dc3e38cd5 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/UnitTests/Stubs.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/Stubs.cs @@ -8,6 +8,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +using Microsoft.CodeAnalysis; namespace System.Text.RegularExpressions { @@ -22,3 +23,17 @@ public RegexReplacement(string rep, RegexNode concat, Hashtable caps) { } public const int WholeString = -4; } } + +namespace System.Text.RegularExpressions.Generator +{ + internal static class DiagnosticDescriptors + { + public static DiagnosticDescriptor UseRegexSourceGeneration { get; } = new DiagnosticDescriptor( + id: "SYSLIB1046", + title: "Use the Regex source generator.", + messageFormat: "The inputs to your Regex are known at compile-time so you could be using the source generator to boost performance.", + category: "RegexGenerator", + DiagnosticSeverity.Info, + isEnabledByDefault: true); + } +} diff --git a/src/libraries/System.Text.RegularExpressions/tests/UnitTests/System.Text.RegularExpressions.Unit.Tests.csproj b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/System.Text.RegularExpressions.Unit.Tests.csproj index acf507b90c8a6..f2e24cd806da3 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/UnitTests/System.Text.RegularExpressions.Unit.Tests.csproj +++ b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/System.Text.RegularExpressions.Unit.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -9,9 +9,13 @@ true true $(DefineConstants);DEBUG + true + + + @@ -43,8 +47,16 @@ + + + + + + + + diff --git a/src/libraries/System.Text.RegularExpressions/tests/UnitTests/UpgradeToRegexGeneratorAnalyzerTests.cs b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/UpgradeToRegexGeneratorAnalyzerTests.cs new file mode 100644 index 0000000000000..5ffe667edd962 --- /dev/null +++ b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/UpgradeToRegexGeneratorAnalyzerTests.cs @@ -0,0 +1,772 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions.Generator; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using VerifyCS = System.Text.RegularExpressions.Unit.Tests.CSharpCodeFixVerifier< + System.Text.RegularExpressions.Generator.UpgradeToRegexGeneratorAnalyzer, + System.Text.RegularExpressions.Generator.UpgradeToRegexGeneratorCodeFixer>; + +namespace System.Text.RegularExpressions.Unit.Tests +{ + [ActiveIssue("https://github.com/dotnet/runtime/issues/69823", TestRuntimes.Mono)] + public class UpgradeToRegexGeneratorAnalyzerTests + { + [Fact] + public async Task NoDiagnosticsForEmpty() + => await VerifyCS.VerifyAnalyzerAsync(source: string.Empty); + + public static IEnumerable ConstructorWithTimeoutTestData() + { + yield return new object[] { @"using System; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var regex = new Regex("""", RegexOptions.None, TimeSpan.FromSeconds(10)); + } +}" }; + + yield return new object[] { @"using System; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var regex = new Regex("""", timeout: TimeSpan.FromSeconds(10)); + } +}" }; + + yield return new object[] { @"using System; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var regex = new Regex(timeout: TimeSpan.FromSeconds(10), pattern: """"); + } +}" }; + } + + [Theory] + [MemberData(nameof(ConstructorWithTimeoutTestData))] + public async Task NoDiagnosticForConstructorWithTimeout(string test) + { + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task NoDiagnosticForTopLevelStatements() + { + string test = @"using System.Text.RegularExpressions; + +Regex r = new Regex("""");"; + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + public static IEnumerable StaticInvocationWithTimeoutTestData() + { + foreach(string method in new[] { "Count", "EnumerateMatches", "IsMatch", "Match", "Matches", "Split"}) + { + yield return new object[] { @"using System; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + Regex." + method + @"(""input"", ""a|b"", RegexOptions.None, TimeSpan.FromSeconds(10)); + } +}" }; + } + + // Replace is special since it takes an extra argument + yield return new object[] { @"using System; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + Regex.Replace(""input"", ""a|b"", ""replacement"" ,RegexOptions.None, TimeSpan.FromSeconds(10)); + } +}" }; + } + + [Theory] + [MemberData(nameof(StaticInvocationWithTimeoutTestData))] + public async Task NoDiagnosticForStaticInvocationWithTimeout(string test) + => await VerifyCS.VerifyAnalyzerAsync(test); + + [Theory] + [MemberData(nameof(InvocationTypes))] + public async Task NoDiagnosticsForNet60(InvocationType invocationType) + { + string isMatchInvocation = invocationType == InvocationType.Constructor ? @".IsMatch("""")" : string.Empty; + string test = @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var isMatch = " + ConstructRegexInvocation(invocationType, pattern: "\"\"") + isMatchInvocation + @"; + } +}"; + + await VerifyCS.VerifyAnalyzerAsync(test, ReferenceAssemblies.Net.Net60, usePreviewLanguageVersion: true); + } + + [Theory] + [MemberData(nameof(InvocationTypes))] + public async Task NoDiagnosticsForLowerLanguageVersion(InvocationType invocationType) + { + string isMatchInvocation = invocationType == InvocationType.Constructor ? @".IsMatch("""")" : string.Empty; + string test = @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var isMatch = " + ConstructRegexInvocation(invocationType, "\"\"") + isMatchInvocation + @"; + } +}"; + + await VerifyCS.VerifyAnalyzerAsync(test, null, usePreviewLanguageVersion: false); + } + + public static IEnumerable ConstantPatternTestData() + { + foreach (InvocationType invocationType in new[] { InvocationType.Constructor, InvocationType.StaticMethods }) + { + string isMatchInvocation = invocationType == InvocationType.Constructor ? @".IsMatch("""")" : string.Empty; + // Test constructor with a passed in literal pattern. + yield return new object[] { @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var isMatch = {|#0:" + ConstructRegexInvocation(invocationType, "\"\"") + @"|}" + isMatchInvocation + @"; + } +}", @"using System.Text; +using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main(string[] args) + { + var isMatch = MyRegex().IsMatch(""""); + } + + [RegexGenerator("""")] + private static partial Regex MyRegex(); +}" }; + + // Test constructor with a local constant pattern. + yield return new object[] { @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + const string pattern = @""""; + var isMatch = {|#0:" + ConstructRegexInvocation(invocationType, "\"\"") + @"|}" + isMatchInvocation + @"; + } +}", @"using System.Text; +using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main(string[] args) + { + const string pattern = @""""; + var isMatch = MyRegex().IsMatch(""""); + } + + [RegexGenerator("""")] + private static partial Regex MyRegex(); +}" }; + + // Test constructor with a constant field pattern. + yield return new object[] { @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + private const string pattern = @""""; + + public static void Main(string[] args) + { + var isMatch = {|#0:" + ConstructRegexInvocation(invocationType, "\"\"") + @"|}" + isMatchInvocation + @"; + } +}", @"using System.Text; +using System.Text.RegularExpressions; + +public partial class Program +{ + private const string pattern = @""""; + + public static void Main(string[] args) + { + var isMatch = MyRegex().IsMatch(""""); + } + + [RegexGenerator("""")] + private static partial Regex MyRegex(); +}" }; + } + } + + [Theory] + [MemberData(nameof(ConstantPatternTestData))] + public async Task DiagnosticEmittedForConstantPattern(string test, string fixedSource) + { + DiagnosticResult expectedDiagnostic = VerifyCS.Diagnostic(DiagnosticDescriptors.UseRegexSourceGeneration.Id).WithLocation(0); + await VerifyCS.VerifyCodeFixAsync(test, expectedDiagnostic, fixedSource); + } + + public static IEnumerable VariablePatternTestData() + { + foreach (InvocationType invocationType in new[] { InvocationType.Constructor, InvocationType.StaticMethods }) + { + string isMatchInvocation = invocationType == InvocationType.Constructor ? @".IsMatch("""")" : string.Empty; + // Test constructor with passed in parameter + yield return new object[] { @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var isMatch = " + ConstructRegexInvocation(invocationType, "args[0]") + isMatchInvocation + @"; + } +}" }; + + // Test constructor with passed in variable + yield return new object[] { @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + string somePattern = """"; + var isMatch = " + ConstructRegexInvocation(invocationType, "somePattern") + isMatchInvocation + @"; + } +}" }; + + // Test constructor with readonly property + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public string Pattern { get; } + + public void M() + { + var isMatch = " + ConstructRegexInvocation(invocationType, "Pattern") + isMatchInvocation + @"; + } +}" }; + + // Test constructor with field + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public readonly string Pattern; + + public void M() + { + var isMatch = " + ConstructRegexInvocation(invocationType, "Pattern") + isMatchInvocation + @"; + } +}" }; + + // Test constructor with return method + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public string GetMyPattern() => """"; + + public void M() + { + var isMatch = " + ConstructRegexInvocation(invocationType, "GetMyPattern()") + isMatchInvocation + @"; + } +}" }; + } + } + + [Theory] + [MemberData(nameof(VariablePatternTestData))] + public async Task DiagnosticNotEmittedForVariablePattern(string test) + => await VerifyCS.VerifyAnalyzerAsync(test); + + public static IEnumerable ConstantOptionsTestData() + { + foreach (InvocationType invocationType in new[] { InvocationType.Constructor, InvocationType.StaticMethods }) + { + string isMatchInvocation = invocationType == InvocationType.Constructor ? @".IsMatch("""")" : string.Empty; + // Test options as passed in literal + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var isMatch = {|#0:" + ConstructRegexInvocation(invocationType, "\"\"", "RegexOptions.None") + @"|}" + isMatchInvocation + @"; + } +}", @"using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main(string[] args) + { + var isMatch = MyRegex().IsMatch(""""); + } + + [RegexGenerator("""", RegexOptions.None)] + private static partial Regex MyRegex(); +}" }; + + // Test options as local constant + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + const RegexOptions options = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + var isMatch = {|#0:" + ConstructRegexInvocation(invocationType, "\"\"", "options") + @"|}" + isMatchInvocation + @"; + } +}", @"using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main(string[] args) + { + const RegexOptions options = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + var isMatch = MyRegex().IsMatch(""""); + } + + [RegexGenerator("""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex MyRegex(); +}" }; + + // Test options as constant field + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + const RegexOptions Options = RegexOptions.None; + + public static void Main(string[] args) + { + var isMatch = {|#0:" + ConstructRegexInvocation(invocationType, "\"\"", "Options") + @"|}" + isMatchInvocation + @"; + } +}", @"using System.Text.RegularExpressions; + +public partial class Program +{ + const RegexOptions Options = RegexOptions.None; + + public static void Main(string[] args) + { + var isMatch = MyRegex().IsMatch(""""); + } + + [RegexGenerator("""", RegexOptions.None)] + private static partial Regex MyRegex(); +}" }; + } + } + + [Theory] + [MemberData(nameof(ConstantOptionsTestData))] + public async Task DiagnosticEmittedForConstantOptions(string test, string fixedSource) + { + DiagnosticResult expected = VerifyCS.Diagnostic(DiagnosticDescriptors.UseRegexSourceGeneration.Id).WithLocation(0); + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedSource); + } + + public static IEnumerable VariableOptionsTestData() + { + foreach (InvocationType invocationType in new[] { InvocationType.Constructor, InvocationType.StaticMethods }) + { + string isMatchInvocation = invocationType == InvocationType.Constructor ? @".IsMatch("""")" : string.Empty; + // Test options as passed in parameter + yield return new object[] { @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(RegexOptions options) + { + var isMatch = " + ConstructRegexInvocation(invocationType, "\"\"", "options") + isMatchInvocation + @"; + } +}" }; + + // Test options as passed in variable + yield return new object[] { @"using System.Text; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + RegexOptions options = RegexOptions.None; + var isMatch = " + ConstructRegexInvocation(invocationType, "\"\"", "options") + isMatchInvocation + @"; + } +}" }; + + // Test options as readonly property + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public RegexOptions Options { get; } + + public void M() + { + var isMatch = " + ConstructRegexInvocation(invocationType, "\"\"", "Options") + isMatchInvocation + @"; + } +}" }; + + // Test options as readonly field + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public readonly RegexOptions Options; + + public void M() + { + var isMatch = " + ConstructRegexInvocation(invocationType, "\"\"", "Options") + isMatchInvocation + @"; + } +}" }; + + // Test options as return method. + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public RegexOptions GetMyOptions() => RegexOptions.None; + + public void M() + { + var isMatch = " + ConstructRegexInvocation(invocationType, "\"\"", "GetMyOptions()") + isMatchInvocation + @"; + } +}" }; + } + } + + [Theory] + [MemberData(nameof(VariableOptionsTestData))] + public async Task DiagnosticNotEmittedForVariableOptions(string test) + => await VerifyCS.VerifyAnalyzerAsync(test); + + public static IEnumerable StaticInvocationsAndFixedSourceTestData() + { + const string testTemplateWithOptions = @"using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + {|#0:Regex.@@Method@@(""input"", ""a|b"", RegexOptions.None)|}; + } +}"; + const string fixedSourceWithOptions = @"using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main(string[] args) + { + MyRegex().@@Method@@(""input""); + } + + [RegexGenerator(""a|b"", RegexOptions.None)] + private static partial Regex MyRegex(); +}"; + DiagnosticResult expectedDiagnostic = VerifyCS.Diagnostic(DiagnosticDescriptors.UseRegexSourceGeneration.Id).WithLocation(0); + + const string testTemplateWithoutOptions = @"using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + {|#0:Regex.@@Method@@(""input"", ""a|b"")|}; + } +}"; + const string fixedSourceWithoutOptions = @"using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main(string[] args) + { + MyRegex().@@Method@@(""input""); + } + + [RegexGenerator(""a|b"")] + private static partial Regex MyRegex(); +}"; + + foreach (bool includeRegexOptions in new[] { true, false }) + { + foreach (string methodName in new[] { "Count", "EnumerateMatches" , "IsMatch", "Match", "Matches", "Split" }) + { + if (includeRegexOptions) + { + yield return new object[] { testTemplateWithOptions.Replace("@@Method@@", methodName), expectedDiagnostic, fixedSourceWithOptions.Replace("@@Method@@", methodName) }; + } + else + { + yield return new object[] { testTemplateWithoutOptions.Replace("@@Method@@", methodName), expectedDiagnostic, fixedSourceWithoutOptions.Replace("@@Method@@", methodName) }; + + } + } + } + + // Replace has one additional parameter so we treat that case separately. + + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + {|#0:Regex.Replace(""input"", ""a[b|c]*"", ""replacement"", RegexOptions.CultureInvariant)|}; + } +} +", expectedDiagnostic, @"using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main(string[] args) + { + MyRegex().Replace(""input"", ""replacement""); + } + + [RegexGenerator(""a[b|c]*"", RegexOptions.CultureInvariant)] + private static partial Regex MyRegex(); +} +" }; + + yield return new object[] { @"using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + {|#0:Regex.Replace(""input"", ""a[b|c]*"", ""replacement"")|}; + } +} +", expectedDiagnostic, @"using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main(string[] args) + { + MyRegex().Replace(""input"", ""replacement""); + } + + [RegexGenerator(""a[b|c]*"")] + private static partial Regex MyRegex(); +} +" }; + } + + [Theory] + [MemberData(nameof(StaticInvocationsAndFixedSourceTestData))] + public async Task DiagnosticAndCodeFixForAllStaticMethods(string test, DiagnosticResult expectedDiagnostic, string fixedSource) + => await VerifyCS.VerifyCodeFixAsync(test, expectedDiagnostic, fixedSource); + + [Fact] + public async Task CodeFixSupportsNesting() + { + DiagnosticResult expectedDiagnostic = VerifyCS.Diagnostic(DiagnosticDescriptors.UseRegexSourceGeneration.Id).WithLocation(0); + string test = @"using System.Text.RegularExpressions; + +public class A +{ + public partial class B + { + public class C + { + public partial class D + { + public void Foo() + { + Regex regex = {|#0:new Regex(""pattern"", RegexOptions.IgnorePatternWhitespace)|}; + } + } + } + } +} +"; + string fixedSource = @"using System.Text.RegularExpressions; + +public partial class A +{ + public partial class B + { + public partial class C + { + public partial class D + { + public void Foo() + { + Regex regex = MyRegex(); + } + + [RegexGenerator(""pattern"", RegexOptions.IgnorePatternWhitespace)] + private static partial Regex MyRegex(); + } + } + } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, expectedDiagnostic, fixedSource); + } + + [Theory] + [MemberData(nameof(InvocationTypes))] + public async Task NoDiagnosticForRegexOptionsNonBacktracking(InvocationType invocationType) + { + string isMatchInvocation = invocationType == InvocationType.Constructor ? @".IsMatch("""")" : string.Empty; + string test = @"using System.Text.RegularExpressions; + +public class Program +{ + public static void Main(string[] args) + { + var isMatch = " + ConstructRegexInvocation(invocationType, "\"\"", "RegexOptions.IgnoreCase | RegexOptions.NonBacktracking") + isMatchInvocation + @"; + } +}"; + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task AnayzerSupportsMultipleDiagnostics() + { + string test = @"using System.Text.RegularExpressions; + +public class Program +{ + public static void Main() + { + Regex regex1 = {|#0:new Regex(""a|b"")|}; + Regex regex2 = {|#1:new Regex(""c|d"", RegexOptions.CultureInvariant)|}; + } +} +"; + DiagnosticResult[] expectedDiagnostics = new[] + { + VerifyCS.Diagnostic(DiagnosticDescriptors.UseRegexSourceGeneration.Id).WithLocation(0), + VerifyCS.Diagnostic(DiagnosticDescriptors.UseRegexSourceGeneration.Id).WithLocation(1) + }; + + string fixedSource = @"using System.Text.RegularExpressions; + +public partial class Program +{ + public static void Main() + { + Regex regex1 = MyRegex(); + Regex regex2 = MyRegex1(); + } + + [RegexGenerator(""a|b"")] + private static partial Regex MyRegex(); + [RegexGenerator(""c|d"", RegexOptions.CultureInvariant)] + private static partial Regex MyRegex1(); +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, expectedDiagnostics, fixedSource, 2); + } + + [Fact] + public async Task CodeFixerSupportsNamedParameters() + { + string test = @"using System.Text.RegularExpressions; + +class Program +{ + static void Main(string[] args) + { + Regex r = {|#0:new Regex(options: RegexOptions.None, pattern: ""a|b"")|}; + } +}"; + DiagnosticResult expectedDiagnostic = VerifyCS.Diagnostic(DiagnosticDescriptors.UseRegexSourceGeneration.Id).WithLocation(0); + + string fixedSource = @"using System.Text.RegularExpressions; + +partial class Program +{ + static void Main(string[] args) + { + Regex r = MyRegex(); + } + + [RegexGenerator(""a|b"", RegexOptions.None)] + private static partial Regex MyRegex(); +}"; + + await VerifyCS.VerifyCodeFixAsync(test, expectedDiagnostic, fixedSource); + } + + #region Test helpers + + private static string ConstructRegexInvocation(InvocationType invocationType, string pattern, string? options = null) + => invocationType switch + { + InvocationType.StaticMethods => (pattern is null, options is null) switch + { + (false, true) => $"Regex.IsMatch(\"\", {pattern})", + (false, false) => $"Regex.IsMatch(\"\", {pattern}, {options})", + _ => throw new InvalidOperationException() + }, + InvocationType.Constructor => (pattern is null, options is null) switch + { + (false, true) => $"new Regex({pattern})", + (false, false) => $"new Regex({pattern}, {options})", + _ => throw new InvalidOperationException() + }, + _ => throw new ArgumentOutOfRangeException(nameof(invocationType)) + }; + + public static IEnumerable InvocationTypes + => new object[][] + { + new object[] { InvocationType.StaticMethods }, + new object[] { InvocationType.Constructor } + }; + + public enum InvocationType + { + StaticMethods, + Constructor + } + + #endregion Test helpers + } +}