From 832199b1aab4ae88735803f29042179a790223d8 Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Fri, 20 Feb 2026 22:01:24 -0700 Subject: [PATCH 1/5] Fix SYSLIB1045 code fixer generating duplicate MyRegex names across partial class declarations When the BatchFixer applies multiple fixes concurrently, each fix sees the original compilation and independently picks the same first-available name. This causes CS0756 when two partial declarations of the same class both contain Regex calls that need fixing. The fix scans all partial declarations of the containing type for Regex call sites that would generate new property names, orders them deterministically (by file path, then position), and offsets the generated name by the current node's index in that list. Fixes dotnet/runtime#77409 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/UpgradeToGeneratedRegexAnalyzer.cs | 2 +- .../gen/UpgradeToGeneratedRegexCodeFixer.cs | 88 +++++ .../UpgradeToGeneratedRegexAnalyzerTests.cs | 305 ++++++++++++++++++ 3 files changed, 394 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexAnalyzer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexAnalyzer.cs index 9e5c4788aa4cce..ddcff6a193413b 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexAnalyzer.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexAnalyzer.cs @@ -153,7 +153,7 @@ private static void AnalyzeObjectCreation(OperationAnalysisContext context, INam /// Validates the operation arguments ensuring the pattern and options are constant values. /// Returns false if a timeout argument is used. /// - private static bool ValidateParameters(ImmutableArray arguments) + internal static bool ValidateParameters(ImmutableArray arguments) { const string timeoutArgumentName = "timeout"; const string matchTimeoutArgumentName = "matchTimeout"; diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs index 4f21419199016b..7a473f57e51a5d 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs @@ -121,6 +121,21 @@ operation is not (IInvocationOperation or IObjectCreationOperation)) { memberName = $"{DefaultRegexPropertyName}{memberCount++}"; } + + // When the BatchFixer applies multiple fixes concurrently, each fix sees the + // original compilation and picks the same first-available name. To avoid + // duplicates, determine this node's position among all Regex call sites in + // the type that would generate new names, and offset accordingly. + int precedingCount = CountPrecedingRegexCallSites( + typeSymbol, compilation, regexSymbol, nodeToFix, cancellationToken); + for (int i = 0; i < precedingCount; i++) + { + memberName = $"{DefaultRegexPropertyName}{memberCount++}"; + while (GetAllMembers(typeSymbol).Any(m => m.Name == memberName)) + { + memberName = $"{DefaultRegexPropertyName}{memberCount++}"; + } + } } // Add partial to all ancestors. @@ -457,5 +472,78 @@ private static IEnumerable GetAllMembers(INamedTypeSymbol typeSymbol) } } } + + /// + /// Counts how many Regex call sites in the same type (across all partial declarations) + /// appear before the given node in a deterministic order. This ensures that when the + /// BatchFixer applies fixes concurrently against the original compilation, each fix + /// picks a unique generated method name. + /// + private static int CountPrecedingRegexCallSites( + INamedTypeSymbol typeSymbol, Compilation compilation, + INamedTypeSymbol regexSymbol, SyntaxNode nodeToFix, + CancellationToken cancellationToken) + { + var callSites = new List<(string FilePath, int Position)>(); + + foreach (SyntaxReference syntaxRef in typeSymbol.DeclaringSyntaxReferences) + { + SyntaxNode declSyntax = syntaxRef.GetSyntax(cancellationToken); + SemanticModel declModel = compilation.GetSemanticModel(syntaxRef.SyntaxTree); + + foreach (SyntaxNode descendant in declSyntax.DescendantNodes()) + { + if (descendant is not (InvocationExpressionSyntax or ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax)) + { + continue; + } + + // Skip call sites inside nested type declarations — they belong to + // a different type and won't affect this type's generated names. + // Extension blocks are not nested types, so don't skip those. + if (descendant.Ancestors().Any(a => + a is TypeDeclarationSyntax && a != declSyntax && a is not ExtensionBlockDeclarationSyntax)) + { + continue; + } + + IOperation? op = declModel.GetOperation(descendant, cancellationToken); + bool isFixableRegexCall = op switch + { + IInvocationOperation inv => inv.TargetMethod.IsStatic && + SymbolEqualityComparer.Default.Equals(inv.TargetMethod.ContainingType, regexSymbol) && + UpgradeToGeneratedRegexAnalyzer.ValidateParameters(inv.Arguments), + IObjectCreationOperation create => SymbolEqualityComparer.Default.Equals(create.Type, regexSymbol) && + create.Arguments.Length <= 2 && + UpgradeToGeneratedRegexAnalyzer.ValidateParameters(create.Arguments), + _ => false + }; + + if (isFixableRegexCall) + { + callSites.Add((syntaxRef.SyntaxTree.FilePath ?? string.Empty, descendant.SpanStart)); + } + } + } + + if (callSites.Count <= 1) + { + return 0; + } + + callSites.Sort((a, b) => + { + int cmp = StringComparer.Ordinal.Compare(a.FilePath, b.FilePath); + return cmp != 0 ? cmp : a.Position.CompareTo(b.Position); + }); + + string currentFilePath = nodeToFix.SyntaxTree.FilePath ?? string.Empty; + int currentPosition = nodeToFix.SpanStart; + + int index = callSites.FindIndex(c => + StringComparer.Ordinal.Equals(c.FilePath, currentFilePath) && c.Position == currentPosition); + + return index > 0 ? index : 0; + } } } diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs index 24b7308fe93fd4..da0ebf57ef129b 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs @@ -1085,6 +1085,311 @@ public static void Main() }.RunAsync(); } + [Fact] + public async Task CodeFixerGeneratesUniqueNamesAcrossPartialClassDeclarations() + { + string test = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + var r = [|new Regex|](""abc""); + return r; + } +} + +internal partial class C +{ + public static Regex B() + { + var r = [|new Regex|](""def""); + return r; + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + var r = MyRegex; + return r; + } + + [GeneratedRegex(""abc"")] + private static partial Regex MyRegex { get; } +} + +internal partial class C +{ + public static Regex B() + { + var r = MyRegex1; + return r; + } + + [GeneratedRegex(""def"")] + private static partial Regex MyRegex1 { get; } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, fixedSource); + } + + [Fact] + public async Task CodeFixerGeneratesUniqueNamesAcrossPartialClassDeclarationsInSeparateFiles() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return [|new Regex|](""abc""); + } +} +", + @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex B() + { + return [|new Regex|](""def""); + } +} +" + } + }, + FixedState = + { + Sources = + { + @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return MyRegex; + } + + [GeneratedRegex(""abc"")] + private static partial Regex MyRegex { get; } +} +", + @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex B() + { + return MyRegex1; + } + + [GeneratedRegex(""def"")] + private static partial Regex MyRegex1 { get; } +} +" + } + }, + }.RunAsync(); + } + + [Fact] + public async Task CodeFixerGeneratesUniqueNamesAcrossThreePartialClassDeclarations() + { + string test = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return [|new Regex|](""aaa""); + } +} + +internal partial class C +{ + public static Regex B() + { + return [|new Regex|](""bbb""); + } +} + +internal partial class C +{ + public static Regex D() + { + return [|new Regex|](""ccc""); + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return MyRegex; + } + + [GeneratedRegex(""aaa"")] + private static partial Regex MyRegex { get; } +} + +internal partial class C +{ + public static Regex B() + { + return MyRegex1; + } + + [GeneratedRegex(""bbb"")] + private static partial Regex MyRegex1 { get; } +} + +internal partial class C +{ + public static Regex D() + { + return MyRegex2; + } + + [GeneratedRegex(""ccc"")] + private static partial Regex MyRegex2 { get; } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, fixedSource); + } + + [Fact] + public async Task CodeFixerGeneratesUniqueNamesWhenExistingMemberNamedMyRegex() + { + string test = @"using System.Text.RegularExpressions; + +internal partial class C +{ + private static void MyRegex() { } + + public static Regex A() + { + return [|new Regex|](""abc""); + } +} + +internal partial class C +{ + public static Regex B() + { + return [|new Regex|](""def""); + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial class C +{ + private static void MyRegex() { } + + public static Regex A() + { + return MyRegex1; + } + + [GeneratedRegex(""abc"")] + private static partial Regex MyRegex1 { get; } +} + +internal partial class C +{ + public static Regex B() + { + return MyRegex2; + } + + [GeneratedRegex(""def"")] + private static partial Regex MyRegex2 { get; } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, fixedSource); + } + + [Fact] + public async Task CodeFixerIgnoresNonFixableRegexCallsAcrossPartialDeclarations() + { + string test = @"using System; +using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return [|new Regex|](""abc""); + } + + public static Regex NonFixable() + { + return new Regex(""def"", RegexOptions.None, TimeSpan.FromSeconds(1)); + } +} + +internal partial class C +{ + public static Regex B() + { + return [|new Regex|](""ghi""); + } +} +"; + + string fixedSource = @"using System; +using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return MyRegex; + } + + public static Regex NonFixable() + { + return new Regex(""def"", RegexOptions.None, TimeSpan.FromSeconds(1)); + } + + [GeneratedRegex(""abc"")] + private static partial Regex MyRegex { get; } +} + +internal partial class C +{ + public static Regex B() + { + return MyRegex1; + } + + [GeneratedRegex(""ghi"")] + private static partial Regex MyRegex1 { get; } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, fixedSource); + } + [Fact] public async Task CodeFixerSupportsNamedParameters() { From c9e46b8e1f9fba16062ecac6720e7bc21794cf74 Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Fri, 20 Feb 2026 23:02:48 -0700 Subject: [PATCH 2/5] Filter non-fixable static Regex methods in CountPrecedingRegexCallSites Extract fixable method names into shared FixableMethodNames set on the analyzer, referenced by both the analyzer and the fixer. Add test verifying Regex.Escape calls don't inflate the generated name offset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/UpgradeToGeneratedRegexAnalyzer.cs | 9 ++- .../gen/UpgradeToGeneratedRegexCodeFixer.cs | 1 + .../UpgradeToGeneratedRegexAnalyzerTests.cs | 60 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexAnalyzer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexAnalyzer.cs index ddcff6a193413b..cf231dd2f9941c 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexAnalyzer.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexAnalyzer.cs @@ -28,6 +28,9 @@ public sealed class UpgradeToGeneratedRegexAnalyzer : DiagnosticAnalyzer internal const string PatternArgumentName = "pattern"; internal const string OptionsArgumentName = "options"; + /// Names of static Regex methods the analyzer considers fixable. + internal static readonly HashSet FixableMethodNames = new() { "Count", "EnumerateMatches", "IsMatch", "Match", "Matches", "Split", "Replace" }; + /// public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.UseRegexSourceGeneration); @@ -51,7 +54,7 @@ public override void Initialize(AnalysisContext context) // 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" }); + FixableMethodNames); // Register analysis of calls to the Regex constructors context.RegisterOperationAction(context => AnalyzeObjectCreation(context, regexTypeSymbol), OperationKind.ObjectCreation); @@ -63,10 +66,6 @@ public override void Initialize(AnalysisContext context) // 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. HashSet hash = new HashSet(SymbolEqualityComparer.Default); ImmutableArray allMembers = regexTypeSymbol.GetMembers(); diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs index 7a473f57e51a5d..0e0b62cf845e2d 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs @@ -512,6 +512,7 @@ private static int CountPrecedingRegexCallSites( { IInvocationOperation inv => inv.TargetMethod.IsStatic && SymbolEqualityComparer.Default.Equals(inv.TargetMethod.ContainingType, regexSymbol) && + UpgradeToGeneratedRegexAnalyzer.FixableMethodNames.Contains(inv.TargetMethod.Name) && UpgradeToGeneratedRegexAnalyzer.ValidateParameters(inv.Arguments), IObjectCreationOperation create => SymbolEqualityComparer.Default.Equals(create.Type, regexSymbol) && create.Arguments.Length <= 2 && diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs index da0ebf57ef129b..b3e12f4d1f91dd 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs @@ -1390,6 +1390,66 @@ public static Regex B() await VerifyCS.VerifyCodeFixAsync(test, fixedSource); } + [Fact] + public async Task CodeFixerIgnoresNonFixableStaticMethodsAcrossPartialDeclarations() + { + string test = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static string A() + { + return Regex.Escape(""abc""); + } + + public static Regex B() + { + return [|new Regex|](""def""); + } +} + +internal partial class C +{ + public static Regex D() + { + return [|new Regex|](""ghi""); + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static string A() + { + return Regex.Escape(""abc""); + } + + public static Regex B() + { + return MyRegex; + } + + [GeneratedRegex(""def"")] + private static partial Regex MyRegex { get; } +} + +internal partial class C +{ + public static Regex D() + { + return MyRegex1; + } + + [GeneratedRegex(""ghi"")] + private static partial Regex MyRegex1 { get; } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, fixedSource); + } + [Fact] public async Task CodeFixerSupportsNamedParameters() { From 8b336367e74ab6b7541917e35611e7cfe3b1e3d8 Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Wed, 18 Mar 2026 10:56:53 -0600 Subject: [PATCH 3/5] Add edge case tests for SYSLIB1045 fixer and fix nested type counting bug Add 5 edge case tests for the CountPrecedingRegexCallSites logic: - Nested type calls excluded from outer type naming - Non-constant patterns ignored in counting - Partial struct support - Mixed constructor and static method calls across partials - Nested partial types with independent naming Fix bug in CountPrecedingRegexCallSites where nested types inside an outer class would have all their calls skipped because the ancestor check matched the outer TypeDeclarationSyntax. Use TakeWhile to only check ancestors within the declaring syntax node. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/UpgradeToGeneratedRegexCodeFixer.cs | 7 +- .../UpgradeToGeneratedRegexAnalyzerTests.cs | 329 ++++++++++++++++++ 2 files changed, 334 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs index 0e0b62cf845e2d..846d067b4cfe99 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs @@ -501,8 +501,11 @@ private static int CountPrecedingRegexCallSites( // Skip call sites inside nested type declarations — they belong to // a different type and won't affect this type's generated names. // Extension blocks are not nested types, so don't skip those. - if (descendant.Ancestors().Any(a => - a is TypeDeclarationSyntax && a != declSyntax && a is not ExtensionBlockDeclarationSyntax)) + // Only check ancestors up to (not including) declSyntax, so that + // types *containing* declSyntax (e.g., an outer class) are not + // mistaken for nested types. + if (descendant.Ancestors().TakeWhile(a => a != declSyntax).Any(a => + a is TypeDeclarationSyntax && a is not ExtensionBlockDeclarationSyntax)) { continue; } diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs index b3e12f4d1f91dd..578cfb9f9ae260 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs @@ -1450,6 +1450,335 @@ public static Regex D() await VerifyCS.VerifyCodeFixAsync(test, fixedSource); } + [Fact] + public async Task CodeFixerNestedTypeCallsDoNotAffectOuterTypeNaming() + { + string test = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return [|new Regex|](""abc""); + } + + class Inner + { + public static Regex X() + { + return [|new Regex|](""inner""); + } + } +} + +internal partial class C +{ + public static Regex B() + { + return [|new Regex|](""def""); + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return MyRegex; + } + + partial class Inner + { + public static Regex X() + { + return MyRegex; + } + + [GeneratedRegex(""inner"")] + private static partial Regex MyRegex { get; } + } + + [GeneratedRegex(""abc"")] + private static partial Regex MyRegex { get; } +} + +internal partial class C +{ + public static Regex B() + { + return MyRegex1; + } + + [GeneratedRegex(""def"")] + private static partial Regex MyRegex1 { get; } +} +"; + + await new VerifyCS.Test + { + TestCode = test, + FixedCode = fixedSource, + NumberOfFixAllIterations = 2, + }.RunAsync(); + } + + [Fact] + public async Task CodeFixerNonConstantPatternDoesNotAffectNaming() + { + string test = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A(string pattern) + { + var r = new Regex(pattern); + return r; + } + + public static Regex B() + { + return [|new Regex|](""abc""); + } +} + +internal partial class C +{ + public static Regex D() + { + return [|new Regex|](""def""); + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A(string pattern) + { + var r = new Regex(pattern); + return r; + } + + public static Regex B() + { + return MyRegex; + } + + [GeneratedRegex(""abc"")] + private static partial Regex MyRegex { get; } +} + +internal partial class C +{ + public static Regex D() + { + return MyRegex1; + } + + [GeneratedRegex(""def"")] + private static partial Regex MyRegex1 { get; } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, fixedSource); + } + + [Fact] + public async Task CodeFixerPartialStructGeneratesUniqueNames() + { + string test = @"using System.Text.RegularExpressions; + +internal partial struct S +{ + public static Regex A() + { + return [|new Regex|](""abc""); + } +} + +internal partial struct S +{ + public static Regex B() + { + return [|new Regex|](""def""); + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial struct S +{ + public static Regex A() + { + return MyRegex; + } + + [GeneratedRegex(""abc"")] + private static partial Regex MyRegex { get; } +} + +internal partial struct S +{ + public static Regex B() + { + return MyRegex1; + } + + [GeneratedRegex(""def"")] + private static partial Regex MyRegex1 { get; } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, fixedSource); + } + + [Fact] + public async Task CodeFixerMixedConstructorAndStaticMethodCallsAcrossPartials() + { + string test = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return [|new Regex|](""abc""); + } +} + +internal partial class C +{ + public static bool B(string input) + { + return [|Regex.IsMatch|](input, ""def""); + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial class C +{ + public static Regex A() + { + return MyRegex; + } + + [GeneratedRegex(""abc"")] + private static partial Regex MyRegex { get; } +} + +internal partial class C +{ + public static bool B(string input) + { + return MyRegex1.IsMatch(input); + } + + [GeneratedRegex(""def"")] + private static partial Regex MyRegex1 { get; } +} +"; + + await VerifyCS.VerifyCodeFixAsync(test, fixedSource); + } + + [Fact] + public async Task CodeFixerNestedPartialTypeDoesNotInterfereWithOuterPartial() + { + string test = @"using System.Text.RegularExpressions; + +internal partial class Outer +{ + public static Regex A() + { + return [|new Regex|](""outer1""); + } + + internal partial class Inner + { + public static Regex X() + { + return [|new Regex|](""inner1""); + } + } +} + +internal partial class Outer +{ + public static Regex B() + { + return [|new Regex|](""outer2""); + } + + internal partial class Inner + { + public static Regex Y() + { + return [|new Regex|](""inner2""); + } + } +} +"; + + string fixedSource = @"using System.Text.RegularExpressions; + +internal partial class Outer +{ + public static Regex A() + { + return MyRegex; + } + + internal partial class Inner + { + public static Regex X() + { + return MyRegex; + } + + [GeneratedRegex(""inner1"")] + private static partial Regex MyRegex { get; } + } + + [GeneratedRegex(""outer1"")] + private static partial Regex MyRegex { get; } +} + +internal partial class Outer +{ + public static Regex B() + { + return MyRegex1; + } + + internal partial class Inner + { + public static Regex Y() + { + return MyRegex1; + } + + [GeneratedRegex(""inner2"")] + private static partial Regex MyRegex1 { get; } + } + + [GeneratedRegex(""outer2"")] + private static partial Regex MyRegex1 { get; } +} +"; + + await new VerifyCS.Test + { + TestCode = test, + FixedCode = fixedSource, + NumberOfFixAllIterations = 2, + }.RunAsync(); + } + [Fact] public async Task CodeFixerSupportsNamedParameters() { From 0fa6a45bbe16bbc96c149ddc6f57cbccb7f0b49c Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Wed, 18 Mar 2026 11:51:00 -0600 Subject: [PATCH 4/5] Address review: tree-index tiebreaker, SemanticModel cache, doc fix - Add SyntaxTree index as tiebreaker in sort key to prevent indeterminate ordering when FilePath is null/empty across different in-memory SyntaxTrees with same SpanStart. - Cache SemanticModel per SyntaxTree to avoid redundant calls when multiple partial declarations share the same tree. - Fix doc comment: 'method' to 'property'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/UpgradeToGeneratedRegexCodeFixer.cs | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs index 846d067b4cfe99..3a41e629c9f155 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs @@ -477,19 +477,36 @@ private static IEnumerable GetAllMembers(INamedTypeSymbol typeSymbol) /// Counts how many Regex call sites in the same type (across all partial declarations) /// appear before the given node in a deterministic order. This ensures that when the /// BatchFixer applies fixes concurrently against the original compilation, each fix - /// picks a unique generated method name. + /// picks a unique generated property name. /// private static int CountPrecedingRegexCallSites( INamedTypeSymbol typeSymbol, Compilation compilation, INamedTypeSymbol regexSymbol, SyntaxNode nodeToFix, CancellationToken cancellationToken) { - var callSites = new List<(string FilePath, int Position)>(); + // Build a map from SyntaxTree to its index in the compilation, used as a + // tiebreaker when FilePath is null/empty (e.g., in-memory documents). + var treeIndexMap = new Dictionary(); + int treeCounter = 0; + foreach (SyntaxTree tree in compilation.SyntaxTrees) + { + treeIndexMap[tree] = treeCounter++; + } + + var callSites = new List<(string FilePath, int TreeIndex, int Position)>(); + var semanticModelCache = new Dictionary(); foreach (SyntaxReference syntaxRef in typeSymbol.DeclaringSyntaxReferences) { SyntaxNode declSyntax = syntaxRef.GetSyntax(cancellationToken); - SemanticModel declModel = compilation.GetSemanticModel(syntaxRef.SyntaxTree); + + if (!semanticModelCache.TryGetValue(syntaxRef.SyntaxTree, out SemanticModel? declModel)) + { + declModel = compilation.GetSemanticModel(syntaxRef.SyntaxTree); + semanticModelCache[syntaxRef.SyntaxTree] = declModel; + } + + int treeIndex = treeIndexMap.TryGetValue(syntaxRef.SyntaxTree, out int idx) ? idx : -1; foreach (SyntaxNode descendant in declSyntax.DescendantNodes()) { @@ -525,7 +542,7 @@ private static int CountPrecedingRegexCallSites( if (isFixableRegexCall) { - callSites.Add((syntaxRef.SyntaxTree.FilePath ?? string.Empty, descendant.SpanStart)); + callSites.Add((syntaxRef.SyntaxTree.FilePath ?? string.Empty, treeIndex, descendant.SpanStart)); } } } @@ -538,14 +555,19 @@ private static int CountPrecedingRegexCallSites( callSites.Sort((a, b) => { int cmp = StringComparer.Ordinal.Compare(a.FilePath, b.FilePath); + if (cmp != 0) return cmp; + cmp = a.TreeIndex.CompareTo(b.TreeIndex); return cmp != 0 ? cmp : a.Position.CompareTo(b.Position); }); string currentFilePath = nodeToFix.SyntaxTree.FilePath ?? string.Empty; + int currentTreeIndex = treeIndexMap.TryGetValue(nodeToFix.SyntaxTree, out int currentIdx) ? currentIdx : -1; int currentPosition = nodeToFix.SpanStart; int index = callSites.FindIndex(c => - StringComparer.Ordinal.Equals(c.FilePath, currentFilePath) && c.Position == currentPosition); + StringComparer.Ordinal.Equals(c.FilePath, currentFilePath) && + c.TreeIndex == currentTreeIndex && + c.Position == currentPosition); return index > 0 ? index : 0; } From 0da74f1a3dbe61b129cb302950b5d9fe3db76403 Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Wed, 18 Mar 2026 12:25:10 -0600 Subject: [PATCH 5/5] Add blank line before generated [GeneratedRegex] members The fixer was not including leading trivia when inserting new members via AddMembers, causing generated properties to lack blank line separation from preceding members. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/UpgradeToGeneratedRegexCodeFixer.cs | 4 ++++ .../FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs | 1 + 2 files changed, 5 insertions(+) diff --git a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs index 3a41e629c9f155..f27c55a12de764 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/UpgradeToGeneratedRegexCodeFixer.cs @@ -339,6 +339,10 @@ private static Document TryAddNewMember( // Add the member to the type. if (oldMember is null) { + // Prepend a blank line so the generated member is visually separated from preceding members. + newMember = newMember.WithLeadingTrivia( + newMember.GetLeadingTrivia().Insert(0, SyntaxFactory.ElasticCarriageReturnLineFeed)); + newTypeDeclarationOrCompilationUnit = newTypeDeclarationOrCompilationUnit is TypeDeclarationSyntax newTypeDeclaration ? newTypeDeclaration.AddMembers((MemberDeclarationSyntax)newMember) : ((CompilationUnitSyntax)newTypeDeclarationOrCompilationUnit).AddMembers((ClassDeclarationSyntax)generator.ClassDeclaration("Program", modifiers: DeclarationModifiers.Partial, members: new[] { newMember })); diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs index 578cfb9f9ae260..f6dd73f7d8918a 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.cs @@ -1073,6 +1073,7 @@ public static void Main() [GeneratedRegex(""a|b"")] private static partial Regex MyRegex { get; } + [GeneratedRegex(""c|d"", RegexOptions.CultureInvariant)] private static partial Regex MyRegex1 { get; } }