From e95c4ad2f9203537558ac24f1d6883623d6f8e90 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 27 Feb 2026 20:13:33 +0000 Subject: [PATCH 01/29] Fix source generator diagnostics to support #pragma warning disable Split each affected generator's RegisterSourceOutput pipeline into two separate pipelines: 1. Source generation pipeline - fully incremental, uses Select to extract just the equatable model. Only re-fires on structural changes. 2. Diagnostic pipeline - combines with CompilationProvider to recover the SyntaxTree from the Compilation at emission time. Uses Location.Create(SyntaxTree, TextSpan) to produce SourceLocation instances that support pragma suppression checks. Affected generators: - System.Text.Json (JsonSourceGenerator) - Microsoft.Extensions.Logging (LoggerMessageGenerator) - Microsoft.Extensions.Configuration.Binder (ConfigurationBindingGenerator) - System.Text.RegularExpressions (RegexGenerator) The shared DiagnosticInfo type gains a CreateDiagnostic(Compilation) overload that recovers the SyntaxTree from the trimmed ExternalFileLocation's file path, converting it back to a SourceLocation. The RegexGenerator's private DiagnosticData type gets an analogous ToDiagnostic(Compilation) overload. Fixes dotnet/runtime#92509 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConfigurationBindingGenerator.Parser.cs | 10 +- .../gen/ConfigurationBindingGenerator.cs | 42 +++--- .../gen/LoggerMessageGenerator.Parser.cs | 6 +- .../gen/LoggerMessageGenerator.Roslyn4.0.cs | 62 +++++---- .../gen/JsonSourceGenerator.Parser.cs | 4 +- .../gen/JsonSourceGenerator.Roslyn3.11.cs | 4 +- .../gen/JsonSourceGenerator.Roslyn4.0.cs | 39 ++++-- .../JsonSourceGeneratorIncrementalTests.cs | 4 +- .../gen/RegexGenerator.Parser.cs | 54 ++++---- .../gen/RegexGenerator.cs | 120 +++++++++--------- 10 files changed, 187 insertions(+), 158 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Parser.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Parser.cs index dc940d2cb07875..beb45cff274be5 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Parser.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Parser.cs @@ -30,13 +30,13 @@ internal sealed partial class Parser(CompilationData compilationData) private bool _emitEnumParseMethod; private bool _emitGenericParseEnum; - public List? Diagnostics { get; private set; } + public List? Diagnostics { get; private set; } public SourceGenerationSpec? GetSourceGenerationSpec(ImmutableArray invocations, CancellationToken cancellationToken) { if (!_langVersionIsSupported) { - RecordDiagnostic(DiagnosticDescriptors.LanguageVersionNotSupported, trimmedLocation: Location.None); + RecordDiagnostic(DiagnosticDescriptors.LanguageVersionNotSupported, location: Location.None); return null; } @@ -979,10 +979,10 @@ private void ReportContainingTypeDiagnosticIfRequired(TypeParseInfo typeParseInf } } - private void RecordDiagnostic(DiagnosticDescriptor descriptor, Location trimmedLocation, params object?[]? messageArgs) + private void RecordDiagnostic(DiagnosticDescriptor descriptor, Location location, params object?[]? messageArgs) { - Diagnostics ??= new List(); - Diagnostics.Add(DiagnosticInfo.Create(descriptor, trimmedLocation, messageArgs)); + Diagnostics ??= new List(); + Diagnostics.Add(Diagnostic.Create(descriptor, location, messageArgs)); } private void CheckIfToEmitParseEnumMethod() diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs index 4a3d5bbf7dea81..d9858801f67bc1 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs @@ -6,10 +6,10 @@ using System.Diagnostics; using System.Reflection; using System.Threading; +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using SourceGenerators; namespace Microsoft.Extensions.Configuration.Binder.SourceGeneration { @@ -37,7 +37,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ? new CompilationData((CSharpCompilation)compilation) : null); - IncrementalValueProvider<(SourceGenerationSpec?, ImmutableEquatableArray?)> genSpec = context.SyntaxProvider + IncrementalValueProvider<(SourceGenerationSpec?, ImmutableArray)> genSpec = context.SyntaxProvider .CreateSyntaxProvider( (node, _) => BinderInvocation.IsCandidateSyntaxNode(node), BinderInvocation.Create) @@ -48,14 +48,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { if (tuple.Right is not CompilationData compilationData) { - return (null, null); + return (null, default); } try { Parser parser = new(compilationData); SourceGenerationSpec? spec = parser.GetSourceGenerationSpec(tuple.Left, cancellationToken); - ImmutableEquatableArray? diagnostics = parser.Diagnostics?.ToImmutableEquatableArray(); + ImmutableArray diagnostics = parser.Diagnostics is { } diags + ? diags.ToImmutableArray() + : ImmutableArray.Empty; return (spec, diagnostics); } catch (Exception ex) @@ -65,7 +67,25 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }) .WithTrackingName(GenSpecTrackingName); - context.RegisterSourceOutput(genSpec, ReportDiagnosticsAndEmitSource); + // Pipeline 1: Source generation only. + // Uses Select to extract just the spec; the Select operator deduplicates by + // comparing model equality, so source generation only re-fires on structural changes. + context.RegisterSourceOutput(genSpec.Select(static (t, _) => t.Item1), EmitSource); + + // Pipeline 2: Diagnostics only. + // Diagnostics use raw SourceLocation instances that are pragma-suppressible. + // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) + // without triggering expensive source regeneration. + // See https://github.com/dotnet/runtime/issues/92509 for context. + context.RegisterSourceOutput( + genSpec, + static (context, tuple) => + { + foreach (Diagnostic diagnostic in tuple.Item2) + { + context.ReportDiagnostic(diagnostic); + } + }); if (!s_hasInitializedInterceptorVersion) { @@ -136,17 +156,9 @@ internal static int DetermineInterceptableVersion() /// public Action? OnSourceEmitting { get; init; } - private void ReportDiagnosticsAndEmitSource(SourceProductionContext sourceProductionContext, (SourceGenerationSpec? SourceGenerationSpec, ImmutableEquatableArray? Diagnostics) input) + private void EmitSource(SourceProductionContext sourceProductionContext, SourceGenerationSpec? spec) { - if (input.Diagnostics is ImmutableEquatableArray diagnostics) - { - foreach (DiagnosticInfo diagnostic in diagnostics) - { - sourceProductionContext.ReportDiagnostic(diagnostic.CreateDiagnostic()); - } - } - - if (input.SourceGenerationSpec is SourceGenerationSpec spec) + if (spec is not null) { OnSourceEmitting?.Invoke(spec); Emitter emitter = new(spec); diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs index c69c2db2c07e85..3681770cefa554 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs @@ -38,7 +38,7 @@ internal sealed class Parser private readonly INamedTypeSymbol _stringSymbol; private readonly Action? _reportDiagnostic; - public List Diagnostics { get; } = new(); + public List Diagnostics { get; } = new(); public Parser( INamedTypeSymbol loggerMessageAttribute, @@ -815,8 +815,8 @@ private void Diag(DiagnosticDescriptor desc, Location? location, params object?[ _reportDiagnostic?.Invoke(Diagnostic.Create(desc, location, messageArgs)); // Also collect for scenarios that need the diagnostics list; in Roslyn 4.0+ incremental generators, - // this list is exposed via parser.Diagnostics (as ImmutableEquatableArray) and reported in Execute. - Diagnostics.Add(DiagnosticInfo.Create(desc, location, messageArgs)); + // this list is exposed via parser.Diagnostics and reported in the diagnostic pipeline. + Diagnostics.Add(Diagnostic.Create(desc, location, messageArgs)); } private static bool IsBaseOrIdentity(ITypeSymbol source, ITypeSymbol dest, Compilation compilation) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs index ef9fa6582b53b3..45e52fb64592be 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs @@ -9,7 +9,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.DotnetRuntime.Extensions; using Microsoft.CodeAnalysis.Text; -using SourceGenerators; [assembly: System.Resources.NeutralResourcesLanguage("en-us")] @@ -25,7 +24,7 @@ public static class StepNames public void Initialize(IncrementalGeneratorInitializationContext context) { - IncrementalValuesProvider<(LoggerClassSpec? LoggerClassSpec, ImmutableEquatableArray Diagnostics, bool HasStringCreate)> loggerClasses = context.SyntaxProvider + IncrementalValuesProvider<(LoggerClassSpec? LoggerClassSpec, ImmutableArray Diagnostics, bool HasStringCreate)> loggerClasses = context.SyntaxProvider .ForAttributeWithMetadataName( #if !ROSLYN4_4_OR_GREATER context, @@ -66,7 +65,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (exceptionSymbol == null) { - var diagnostics = new[] { DiagnosticInfo.Create(DiagnosticDescriptors.MissingRequiredType, null, new object?[] { "System.Exception" }) }.ToImmutableEquatableArray(); + var diagnostics = ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.MissingRequiredType, null, new object?[] { "System.Exception" })); return (null, diagnostics, false); } @@ -92,17 +91,46 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Convert to immutable spec for incremental caching LoggerClassSpec? loggerClassSpec = logClasses.Count > 0 ? logClasses[0].ToSpec() : null; - return (loggerClassSpec, parser.Diagnostics.ToImmutableEquatableArray(), hasStringCreate); + return (loggerClassSpec, parser.Diagnostics.ToImmutableArray(), hasStringCreate); }) #if ROSLYN4_4_OR_GREATER .WithTrackingName(StepNames.LoggerMessageTransform) #endif ; - context.RegisterSourceOutput(loggerClasses.Collect(), static (spc, items) => Execute(items, spc)); + // Pipeline 1: Source generation only. + // Uses Select to extract just the model; the Select operator deduplicates by + // comparing model equality, so source generation only re-fires on structural changes. + context.RegisterSourceOutput( + loggerClasses.Select(static (t, _) => (t.LoggerClassSpec, t.HasStringCreate)).Collect(), + static (spc, items) => EmitSource(items, spc)); + + // Pipeline 2: Diagnostics only. + // Diagnostics use raw SourceLocation instances that are pragma-suppressible. + // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) + // without triggering expensive source regeneration. + // See https://github.com/dotnet/runtime/issues/92509 for context. + context.RegisterSourceOutput( + loggerClasses.Collect(), + static (context, items) => + { + // Use HashSet to deduplicate — each attributed method triggers parsing of entire class, + // producing duplicate diagnostics. + var reportedDiagnostics = new HashSet<(string Id, TextSpan? Span, string? FilePath)>(); + foreach (var item in items) + { + foreach (Diagnostic diagnostic in item.Diagnostics) + { + if (reportedDiagnostics.Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) + { + context.ReportDiagnostic(diagnostic); + } + } + } + }); } - private static void Execute(ImmutableArray<(LoggerClassSpec? LoggerClassSpec, ImmutableEquatableArray Diagnostics, bool HasStringCreate)> items, SourceProductionContext context) + private static void EmitSource(ImmutableArray<(LoggerClassSpec? LoggerClassSpec, bool HasStringCreate)> items, SourceProductionContext context) { if (items.IsDefaultOrEmpty) { @@ -110,24 +138,10 @@ private static void Execute(ImmutableArray<(LoggerClassSpec? LoggerClassSpec, Im } bool hasStringCreate = false; - var allLogClasses = new Dictionary(); // Use dictionary to deduplicate by class key - var reportedDiagnostics = new HashSet(); // Track reported diagnostics to avoid duplicates + var allLogClasses = new Dictionary(); // Deduplicate by class key foreach (var item in items) { - // Report diagnostics (note: pragma suppression doesn't work with trimmed locations - known Roslyn limitation) - // Use HashSet to deduplicate - each attributed method triggers parsing of entire class, producing duplicate diagnostics - if (item.Diagnostics is not null) - { - foreach (var diagnostic in item.Diagnostics) - { - if (reportedDiagnostics.Add(diagnostic)) - { - context.ReportDiagnostic(diagnostic.CreateDiagnostic()); - } - } - } - if (item.LoggerClassSpec != null) { hasStringCreate |= item.HasStringCreate; @@ -136,18 +150,15 @@ private static void Execute(ImmutableArray<(LoggerClassSpec? LoggerClassSpec, Im string classKey = BuildClassKey(item.LoggerClassSpec); // Each attributed method in a partial class file produces the same LoggerClassSpec with all methods in that file. - // However, different partial class files (e.g., LevelTestExtensions.cs and LevelTestExtensions.WithDiagnostics.cs) - // produce different LoggerClassSpecs with different methods. Merge them. + // However, different partial class files produce different LoggerClassSpecs with different methods. Merge them. if (!allLogClasses.TryGetValue(classKey, out LoggerClass? existingClass)) { allLogClasses[classKey] = FromSpec(item.LoggerClassSpec); } else { - // Merge methods from different partial class files var newClass = FromSpec(item.LoggerClassSpec); - // Use HashSet for O(1) lookup to avoid O(N×M) complexity var existingMethodKeys = new HashSet<(string Name, int EventId)>(); foreach (var method in existingClass.Methods) { @@ -156,7 +167,6 @@ private static void Execute(ImmutableArray<(LoggerClassSpec? LoggerClassSpec, Im foreach (var method in newClass.Methods) { - // Only add methods that don't already exist (avoid duplicates from same file) if (existingMethodKeys.Add((method.Name, method.EventId))) { existingClass.Methods.Add(method); diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 329a369b4a0135..a865466d5f40b8 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -47,7 +47,7 @@ private sealed class Parser private readonly Dictionary _generatedTypes = new(SymbolEqualityComparer.Default); #pragma warning restore - public List Diagnostics { get; } = new(); + public List Diagnostics { get; } = new(); private Location? _contextClassLocation; public void ReportDiagnostic(DiagnosticDescriptor descriptor, Location? location, params object?[]? messageArgs) @@ -60,7 +60,7 @@ public void ReportDiagnostic(DiagnosticDescriptor descriptor, Location? location location = _contextClassLocation; } - Diagnostics.Add(DiagnosticInfo.Create(descriptor, location, messageArgs)); + Diagnostics.Add(Diagnostic.Create(descriptor, location, messageArgs)); } public Parser(KnownTypeSymbols knownSymbols) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn3.11.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn3.11.cs index 965bf7fc210751..5397715ede9da8 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn3.11.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn3.11.cs @@ -75,9 +75,9 @@ public void Execute(GeneratorExecutionContext executionContext) } // Stage 2. Report any diagnostics gathered by the parser. - foreach (DiagnosticInfo diagnosticInfo in parser.Diagnostics) + foreach (Diagnostic diagnostic in parser.Diagnostics) { - executionContext.ReportDiagnostic(diagnosticInfo.CreateDiagnostic()); + executionContext.ReportDiagnostic(diagnostic); } if (contextGenerationSpecs is null) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs index 75f1f51269e7a6..5a7fc684da515f 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs @@ -10,7 +10,6 @@ #if !ROSLYN4_4_OR_GREATER using Microsoft.CodeAnalysis.DotnetRuntime.Extensions; #endif -using SourceGenerators; namespace System.Text.Json.SourceGeneration { @@ -32,7 +31,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) IncrementalValueProvider knownTypeSymbols = context.CompilationProvider .Select((compilation, _) => new KnownTypeSymbols(compilation)); - IncrementalValuesProvider<(ContextGenerationSpec?, ImmutableEquatableArray)> contextGenerationSpecs = context.SyntaxProvider + IncrementalValuesProvider<(ContextGenerationSpec?, ImmutableArray)> contextGenerationSpecs = context.SyntaxProvider .ForAttributeWithMetadataName( #if !ROSLYN4_4_OR_GREATER context, @@ -54,7 +53,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) #pragma warning restore RS1035 Parser parser = new(tuple.Right); ContextGenerationSpec? contextGenerationSpec = parser.ParseContextGenerationSpec(tuple.Left.ContextClass, tuple.Left.SemanticModel, cancellationToken); - ImmutableEquatableArray diagnostics = parser.Diagnostics.ToImmutableEquatableArray(); + ImmutableArray diagnostics = parser.Diagnostics.ToImmutableArray(); return (contextGenerationSpec, diagnostics); #pragma warning disable RS1035 } @@ -69,23 +68,35 @@ public void Initialize(IncrementalGeneratorInitializationContext context) #endif ; - context.RegisterSourceOutput(contextGenerationSpecs, ReportDiagnosticsAndEmitSource); + // Pipeline 1: Source generation only. + // Uses Select to extract just the spec; the Select operator deduplicates by + // comparing model equality, so source generation only re-fires on structural changes. + context.RegisterSourceOutput(contextGenerationSpecs.Select(static (t, _) => t.Item1), EmitSource); + + // Pipeline 2: Diagnostics only. + // Diagnostics use raw SourceLocation instances that are pragma-suppressible. + // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) + // without triggering expensive source regeneration. + // See https://github.com/dotnet/runtime/issues/92509 for context. + context.RegisterSourceOutput( + contextGenerationSpecs, + static (context, tuple) => + { + foreach (Diagnostic diagnostic in tuple.Item2) + { + context.ReportDiagnostic(diagnostic); + } + }); } - private void ReportDiagnosticsAndEmitSource(SourceProductionContext sourceProductionContext, (ContextGenerationSpec? ContextGenerationSpec, ImmutableEquatableArray Diagnostics) input) + private void EmitSource(SourceProductionContext sourceProductionContext, ContextGenerationSpec? contextGenerationSpec) { - // Report any diagnostics ahead of emitting. - foreach (DiagnosticInfo diagnostic in input.Diagnostics) - { - sourceProductionContext.ReportDiagnostic(diagnostic.CreateDiagnostic()); - } - - if (input.ContextGenerationSpec is null) + if (contextGenerationSpec is null) { return; } - OnSourceEmitting?.Invoke(ImmutableArray.Create(input.ContextGenerationSpec)); + OnSourceEmitting?.Invoke(ImmutableArray.Create(contextGenerationSpec)); // Ensure the source generator emits number literals using invariant culture. // This prevents issues such as locale-specific negative signs (e.g., U+2212 in fi-FI) @@ -96,7 +107,7 @@ private void ReportDiagnosticsAndEmitSource(SourceProductionContext sourceProduc try { Emitter emitter = new(sourceProductionContext); - emitter.Emit(input.ContextGenerationSpec); + emitter.Emit(contextGenerationSpec); } finally { diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorIncrementalTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorIncrementalTests.cs index d08c07c2e741ea..e5099be97692e8 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorIncrementalTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorIncrementalTests.cs @@ -145,7 +145,9 @@ public static void SourceGenModelDoesNotEncapsulateSymbolsOrCompilationData(Func { JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(factory(), disableDiagnosticValidation: true); WalkObjectGraph(result.ContextGenerationSpecs); - WalkObjectGraph(result.Diagnostics); + // NB result.Diagnostics are now produced via a pipeline that combines with CompilationProvider + // and deliberately reference the SyntaxTree for pragma suppression support. + // Cf. https://github.com/dotnet/runtime/issues/92509 static void WalkObjectGraph(object obj) { diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 947d0bc0b049b5..1b61bbeaa740f7 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -20,10 +20,12 @@ public partial class RegexGenerator private const string GeneratedRegexAttributeName = "System.Text.RegularExpressions.GeneratedRegexAttribute"; /// - /// Returns null if nothing to do, if there's an error to report, - /// or if the type was analyzed successfully. + /// Returns a tuple containing: + /// - Model: null if nothing to do or on error; if analyzed successfully. + /// - DiagnosticLocation: the raw for creating diagnostics in later pipeline steps. + /// - Diagnostics: any diagnostics to report for this attributed member. /// - private static object? GetRegexMethodDataOrFailureDiagnostic( + private static (object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics) GetRegexMethodDataOrFailureDiagnostic( GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { if (context.TargetNode is IndexerDeclarationSyntax or AccessorDeclarationSyntax) @@ -32,10 +34,11 @@ public partial class RegexGenerator // of being able to flag invalid use when [GeneratedRegex] is applied incorrectly. // Otherwise, if the ForAttributeWithMetadataName call excluded these, [GeneratedRegex] // could be applied to them and we wouldn't be able to issue a diagnostic. - return new DiagnosticData(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, GetComparableLocation(context.TargetNode)); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, context.TargetNode.GetLocation()))); } var memberSyntax = (MemberDeclarationSyntax)context.TargetNode; + Location memberLocation = memberSyntax.GetLocation(); SemanticModel sm = context.SemanticModel; Compilation compilation = sm.Compilation; @@ -44,37 +47,37 @@ public partial class RegexGenerator if (regexSymbol is null) { // Required types aren't available - return null; + return default; } TypeDeclarationSyntax? typeDec = memberSyntax.Parent as TypeDeclarationSyntax; if (typeDec is null) { - return null; + return default; } ISymbol? regexMemberSymbol = context.TargetSymbol is IMethodSymbol or IPropertySymbol ? context.TargetSymbol : null; if (regexMemberSymbol is null) { - return null; + return default; } ImmutableArray boundAttributes = context.Attributes; if (boundAttributes.Length != 1) { - return new DiagnosticData(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, GetComparableLocation(memberSyntax)); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberLocation))); } AttributeData generatedRegexAttr = boundAttributes[0]; if (generatedRegexAttr.ConstructorArguments.Any(ca => ca.Kind == TypedConstantKind.Error)) { - return new DiagnosticData(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, GetComparableLocation(memberSyntax)); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation))); } ImmutableArray items = generatedRegexAttr.ConstructorArguments; if (items.Length is 0 or > 4) { - return new DiagnosticData(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, GetComparableLocation(memberSyntax)); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation))); } string? pattern = items[0].Value as string; @@ -106,7 +109,7 @@ public partial class RegexGenerator if (pattern is null || cultureName is null) { - return new DiagnosticData(DiagnosticDescriptors.InvalidRegexArguments, GetComparableLocation(memberSyntax), "(null)"); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "(null)"))); } bool nullableRegex; @@ -118,7 +121,7 @@ public partial class RegexGenerator regexMethodSymbol.Arity != 0 || !SymbolEqualityComparer.Default.Equals(regexMethodSymbol.ReturnType, regexSymbol)) { - return new DiagnosticData(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, GetComparableLocation(memberSyntax)); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation))); } nullableRegex = regexMethodSymbol.ReturnNullableAnnotation == NullableAnnotation.Annotated; @@ -132,7 +135,7 @@ public partial class RegexGenerator regexPropertySymbol.SetMethod is not null || !SymbolEqualityComparer.Default.Equals(regexPropertySymbol.Type, regexSymbol)) { - return new DiagnosticData(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, GetComparableLocation(memberSyntax)); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation))); } nullableRegex = regexPropertySymbol.NullableAnnotation == NullableAnnotation.Annotated; @@ -154,7 +157,7 @@ regexPropertySymbol.SetMethod is not null || } catch (Exception e) { - return new DiagnosticData(DiagnosticDescriptors.InvalidRegexArguments, GetComparableLocation(memberSyntax), e.Message); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message))); } if ((regexOptionsWithPatternOptions & RegexOptions.IgnoreCase) != 0 && !string.IsNullOrEmpty(cultureName)) @@ -162,7 +165,7 @@ regexPropertySymbol.SetMethod is not null || if ((regexOptions & RegexOptions.CultureInvariant) != 0) { // User passed in both a culture name and set RegexOptions.CultureInvariant which causes an explicit conflict. - return new DiagnosticData(DiagnosticDescriptors.InvalidRegexArguments, GetComparableLocation(memberSyntax), "cultureName"); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"))); } try @@ -171,7 +174,7 @@ regexPropertySymbol.SetMethod is not null || } catch (CultureNotFoundException) { - return new DiagnosticData(DiagnosticDescriptors.InvalidRegexArguments, GetComparableLocation(memberSyntax), "cultureName"); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"))); } } @@ -189,13 +192,13 @@ regexPropertySymbol.SetMethod is not null || RegexOptions.Singleline; if ((regexOptions & ~SupportedOptions) != 0) { - return new DiagnosticData(DiagnosticDescriptors.InvalidRegexArguments, GetComparableLocation(memberSyntax), "options"); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "options"))); } // Validate the timeout if (matchTimeout is 0 or < -1) { - return new DiagnosticData(DiagnosticDescriptors.InvalidRegexArguments, GetComparableLocation(memberSyntax), "matchTimeout"); + return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "matchTimeout"))); } // Determine the namespace the class is declared in, if any @@ -214,7 +217,6 @@ regexPropertySymbol.SetMethod is not null || var result = new RegexPatternAndSyntax( regexType, IsProperty: regexMemberSymbol is IPropertySymbol, - GetComparableLocation(memberSyntax), regexMemberSymbol.Name, memberSyntax.Modifiers.ToString(), nullableRegex, @@ -238,7 +240,7 @@ regexPropertySymbol.SetMethod is not null || parent = parent.Parent as TypeDeclarationSyntax; } - return result; + return (result, memberLocation, ImmutableArray.Empty); static bool IsAllowedKind(SyntaxKind kind) => kind is SyntaxKind.ClassDeclaration or @@ -246,21 +248,13 @@ SyntaxKind.StructDeclaration or SyntaxKind.RecordDeclaration or SyntaxKind.RecordStructDeclaration or SyntaxKind.InterfaceDeclaration; - - // Get a Location object that doesn't store a reference to the compilation. - // That allows it to compare equally across compilations. - static Location GetComparableLocation(SyntaxNode syntax) - { - var location = syntax.GetLocation(); - return Location.Create(location.SourceTree?.FilePath ?? string.Empty, location.SourceSpan, location.GetLineSpan().Span); - } } /// Data about a regex directly from the GeneratedRegex attribute. - internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); + internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); /// Data about a regex, including a fully parsed RegexTree and subsequent analysis. - internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) + internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) { public string? GeneratedName { get; set; } public bool IsDuplicate { get; set; } diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 98e3f1ac35c036..bf10ca5ae82012 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -8,6 +8,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -44,18 +45,25 @@ internal record struct CompilationData(bool AllowUnsafe, bool CheckOverflow, Lan public void Initialize(IncrementalGeneratorInitializationContext context) { - // Produces one entry per generated regex. This may be: - // - DiagnosticData in the case of a failure that should end the compilation - // - (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers) in the case of valid regex - // - (RegexMethod regexMethod, string reason, DiagnosticData diagnostic) in the case of a limited-support regex - IncrementalValueProvider> results = + // Each entry in the pipeline is a tuple of: + // - Model: the incremental data (no Location/SyntaxTree references) — has value equality for caching. + // - DiagnosticLocation: a raw SourceLocation for creating diagnostics in later steps — NOT part of the model. + // - Diagnostics: accumulated raw Diagnostic objects with SourceLocation — NOT part of the model. + // + // The Model may be: + // - null in the case of a failure (diagnostics explain the error) + // - RegexPatternAndSyntax on initial parse success + // - RegexMethod after regex tree parsing + // - (RegexMethod, string code, Dictionary helpers, CompilationData) for full code generation + // - (RegexMethod, string reason, CompilationData) for limited-support regex + IncrementalValuesProvider<(object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics)> pipeline = context.SyntaxProvider // Find all MethodDeclarationSyntax nodes attributed with GeneratedRegex and gather the required information. // The predicate will be run once for every attributed node in the same file that's being modified. // The transform will be run once for every attributed node in the compilation. // Thus, both should do the minimal amount of work required and get out. This should also have extracted - // everything from the target necessary to do all subsequent analysis and should return an object that's + // everything from the target necessary to do all subsequent analysis and should return a model that's // meaningfully comparable and that doesn't reference anything from the compilation: we want to ensure // that any successful cached results are idempotent for the input such that they don't trigger downstream work // if there are no changes. @@ -64,44 +72,43 @@ public void Initialize(IncrementalGeneratorInitializationContext context) (node, _) => node is MethodDeclarationSyntax or PropertyDeclarationSyntax or IndexerDeclarationSyntax or AccessorDeclarationSyntax, GetRegexMethodDataOrFailureDiagnostic) - // Filter out any parsing errors that resulted in null objects being returned. - .Where(static m => m is not null) + // Filter out entries with no model and no diagnostics. + .Where(static m => m.Model is not null || !m.Diagnostics.IsDefaultOrEmpty) - // The input here will either be a DiagnosticData (in the case of something erroneous detected in GetRegexMethodDataOrFailureDiagnostic) - // or it will be a RegexPatternAndSyntax containing all of the successfully parsed data from the attribute/method. - .Select((methodOrDiagnostic, _) => + // The model here will either be null (failure, diagnostics explain) or a RegexPatternAndSyntax. + // Parse the regex tree and create RegexMethod from successful entries. + .Select(((object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics) item, CancellationToken _) => { - if (methodOrDiagnostic is RegexPatternAndSyntax method) + if (item.Model is RegexPatternAndSyntax method) { try { RegexTree regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it AnalysisResults analysis = RegexTreeAnalyzer.Analyze(regexTree); - return new RegexMethod(method.DeclaringType, method.IsProperty, method.DiagnosticLocation, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); + return (Model: (object?)new RegexMethod(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData), item.DiagnosticLocation, item.Diagnostics); } catch (Exception e) { - return new DiagnosticData(DiagnosticDescriptors.InvalidRegexArguments, method.DiagnosticLocation, e.Message); + return (Model: (object?)null, DiagnosticLocation: (Location?)null, Diagnostics: item.Diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, item.DiagnosticLocation, e.Message))); } } - return methodOrDiagnostic; + return item; }) // Generate the RunnerFactory for each regex, if possible. This is where the bulk of the implementation occurs. - .Select((state, _) => + .Select(((object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics) item, CancellationToken _) => { - if (state is not RegexMethod regexMethod) + if (item.Model is not RegexMethod regexMethod) { - Debug.Assert(state is DiagnosticData); - return state; + return item; } // If we're unable to generate a full implementation for this regex, report a diagnostic. // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) { - return (regexMethod, reason, new DiagnosticData(DiagnosticDescriptors.LimitedSourceGeneration, regexMethod.DiagnosticLocation), regexMethod.CompilationData); + return (Model: (object?)(regexMethod, reason, regexMethod.CompilationData), DiagnosticLocation: (Location?)null, Diagnostics: item.Diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, item.DiagnosticLocation))); } // Generate the core logic for the regex. @@ -112,33 +119,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.WriteLine(); EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, regexMethod.CompilationData.CheckOverflow); writer.Indent -= 2; - return (regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData); - }) + return (Model: (object?)(regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData), DiagnosticLocation: (Location?)null, item.Diagnostics); + }); - // Combine all of the generated text outputs into a single batch. We then generate a single source output from that batch. + // Source pipeline: extract just the model (which has value equality and no Location/SyntaxTree references), + // collect into a single batch, and apply sequence equality for incremental caching. + IncrementalValueProvider> sourceResults = pipeline + .Select(static (t, _) => t.Model!) + .Where(static m => m is not null) .Collect() - - // Apply sequence equality comparison on the result array for incremental caching. .WithComparer(new ObjectImmutableArraySequenceEqualityComparer()); - // When there something to output, take all the generated strings and concatenate them to output, - // and raise all of the created diagnostics. - context.RegisterSourceOutput(results, static (context, results) => + // Pipeline 1: Source generation only. + // Source generation is fully incremental and only re-fires on structural model changes. + context.RegisterSourceOutput(sourceResults, static (context, results) => { - // Report any top-level diagnostics. - bool allFailures = true; - foreach (object result in results) - { - if (result is DiagnosticData d) - { - context.ReportDiagnostic(d.ToDiagnostic()); - } - else - { - allFailures = false; - } - } - if (allFailures) + if (results.IsDefaultOrEmpty) { return; } @@ -168,17 +164,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // pair is the implementation used for the key. var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), RegexMethod>(); - // If we have any (RegexMethod regexMethod, string generatedName, string reason, DiagnosticData diagnostic), these are regexes for which we have - // limited support and need to simply output boilerplate. We need to emit their diagnostics. - // If we have any (RegexMethod regexMethod, string generatedName, string runnerFactoryImplementation, Dictionary requiredHelpers), + // If we have any (RegexMethod regexMethod, string reason, CompilationData), these are regexes for which we have + // limited support and need to simply output boilerplate. + // If we have any (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers, CompilationData), // those are generated implementations to be emitted. We need to gather up their required helpers. Dictionary requiredHelpers = new(); foreach (object? result in results) { RegexMethod? regexMethod = null; - if (result is ValueTuple limitedSupportResult) + if (result is ValueTuple limitedSupportResult) { - context.ReportDiagnostic(limitedSupportResult.Item3.ToDiagnostic()); regexMethod = limitedSupportResult.Item1; } else if (result is ValueTuple, CompilationData> regexImpl) @@ -238,11 +233,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.Indent++; foreach (object? result in results) { - if (result is ValueTuple limitedSupportResult) + if (result is ValueTuple limitedSupportResult) { if (!limitedSupportResult.Item1.IsDuplicate) { - EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item4.LanguageVersion); + EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item3.LanguageVersion); writer.WriteLine(); } } @@ -290,6 +285,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Save out the source context.AddSource("RegexGenerator.g.cs", sw.ToString()); }); + + // Pipeline 2: Diagnostics only. + // Diagnostics use raw SourceLocation instances that are pragma-suppressible. + // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) + // without triggering expensive source regeneration. + // See https://github.com/dotnet/runtime/issues/92509 for context. + context.RegisterSourceOutput(pipeline.Collect(), static (context, items) => + { + foreach (var item in items) + { + foreach (Diagnostic diagnostic in item.Diagnostics) + { + context.ReportDiagnostic(diagnostic); + } + } + }); } /// Determines whether the passed in node supports C# code generation. @@ -351,17 +362,6 @@ static bool HasCaseInsensitiveBackReferences(RegexNode node) } } - /// Stores the data necessary to create a Diagnostic. - /// - /// Diagnostics do not have value equality semantics. Storing them in an object model - /// used in the pipeline can result in unnecessary recompilation. - /// - private sealed record class DiagnosticData(DiagnosticDescriptor descriptor, Location location, object? arg = null) - { - /// Create a from the data. - public Diagnostic ToDiagnostic() => Diagnostic.Create(descriptor, location, arg is null ? [] : [arg]); - } - private sealed class ObjectImmutableArraySequenceEqualityComparer : IEqualityComparer> { public bool Equals(ImmutableArray left, ImmutableArray right) From 650613502a175d27e0e2ccd7e33942a4793a23fc Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Sat, 28 Feb 2026 10:05:00 +0000 Subject: [PATCH 02/29] Add tests verifying diagnostic LocationKind.SourceFile for all generators Verifies that diagnostics from all 4 affected source generators (Regex, JSON, Logger, ConfigBinder) have LocationKind.SourceFile, which is the prerequisite for #pragma warning disable to work. Before this fix, diagnostics had LocationKind.ExternalFile which bypasses Roslyn's pragma suppression checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SourceGenerationTests/GeneratorTests.cs | 25 ++++++++++++ .../LoggerMessageGeneratorParserTests.cs | 16 ++++++++ .../JsonSourceGeneratorDiagnosticsTests.cs | 24 ++++++++++++ .../RegexGeneratorParserTests.cs | 38 +++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs index 7eee48fdeb28ca..b9a9f2f743b541 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -414,5 +414,30 @@ public class AnotherGraphWithUnsupportedMembers Assert.True(result.Diagnostics.Any(diag => diag.Id == Diagnostics.TypeNotSupported.Id)); Assert.True(result.Diagnostics.Any(diag => diag.Id == Diagnostics.PropertyNotSupported.Id)); } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + public async Task Diagnostic_HasPragmaSuppressibleLocation() + { + string source = """ + using System; + using Microsoft.Extensions.Configuration; + + public class Program + { + public static void Main() + { + ConfigurationBuilder configurationBuilder = new(); + IConfigurationRoot config = configurationBuilder.Build(); + + int myInt = 1; + config.Bind(myInt); + } + } + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1103"); + Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); + } } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs index a6cc6db209297d..1fe067b8f6015d 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs @@ -1426,5 +1426,21 @@ private static async Task> RunGenerator( return d; } + + [Fact] + public async Task Diagnostic_HasPragmaSuppressibleLocation() + { + // SYSLIB1017: MissingLogLevel + IReadOnlyList diagnostics = await RunGenerator(@" + partial class C + { + [LoggerMessage(EventId = 0, Message = ""M1"")] + static partial void M1(ILogger logger); + } + "); + + Diagnostic diagnostic = Assert.Single(diagnostics, d => d.Id == "SYSLIB1017"); + Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); + } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index b8e3073a857cf5..eb86e9036ae538 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -755,5 +755,29 @@ public partial class MyContext : JsonSerializerContext CompilationHelper.AssertEqualDiagnosticMessages(expectedDiagnostics, result.Diagnostics); } #endif + + [Fact] + public void Diagnostic_HasPragmaSuppressibleLocation() + { + // SYSLIB1038: JsonInclude attribute on inaccessible member + Compilation compilation = CompilationHelper.CreateCompilation(@" + using System.Text.Json.Serialization; + + namespace Test + { + public class MyClass + { + [JsonInclude] + private int PrivateField; + } + + [JsonSerializable(typeof(MyClass))] + public partial class JsonContext : JsonSerializerContext { } + }"); + + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1038"); + Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); + } } } diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs index b4d46d8274f57a..1b43c872e72ec3 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs @@ -398,6 +398,44 @@ partial class C Assert.Equal("SYSLIB1043", Assert.Single(diagnostics).Id); } + [Theory] + [InlineData("SYSLIB1041")] + [InlineData("SYSLIB1042")] + [InlineData("SYSLIB1043")] + public async Task Diagnostic_HasPragmaSuppressibleLocation(string diagnosticId) + { + string code = diagnosticId switch + { + "SYSLIB1041" => @" + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex(""ab"")] + [GeneratedRegex(""abc"")] + private static partial Regex MultipleAttributes(); + }", + "SYSLIB1042" => @" + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex(""ab[]"")] + private static partial Regex InvalidPattern(); + }", + "SYSLIB1043" => @" + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex(""ab"")] + private static Regex NonPartialProperty => null; + }", + _ => throw new ArgumentException(diagnosticId), + }; + + IReadOnlyList diagnostics = await RegexGeneratorHelper.RunGenerator(code); + Diagnostic diagnostic = Assert.Single(diagnostics, d => d.Id == diagnosticId); + Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); + } + [Fact] public async Task Diagnostic_PropertyMustHaveGetter() { From 4c7bb58a21b2eb9d3544b1b0a8212b4a111e6c90 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Sat, 28 Feb 2026 14:09:46 +0000 Subject: [PATCH 03/29] Add incremental generation tests for RegexGenerator Adds RegexGeneratorIncrementalTests with 4 tests following the patterns established by JsonSourceGeneratorIncrementalTests and ConfigBinder's GeneratorTests.Incremental.cs: - SameInput_DoesNotRegenerate: verifies caching on identical compilations - EquivalentSources_Regenerates: documents that semantically equivalent sources trigger regeneration (pre-existing limitation due to Dictionary in model lacking value equality) - DifferentSources_Regenerates: verifies model changes trigger output - SourceGenModelDoesNotEncapsulateSymbolsOrCompilationData: walks the object graph to ensure no Compilation/ISymbol references leak Also adds SourceGenerationTrackingName constant and WithTrackingName() to the source pipeline to enable step tracking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.cs | 5 +- .../RegexGeneratorIncrementalTests.cs | 260 ++++++++++++++++++ ...ystem.Text.RegularExpressions.Tests.csproj | 1 + 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index bf10ca5ae82012..35c23f1d937bac 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -25,6 +25,8 @@ public partial class RegexGenerator : IIncrementalGenerator private const string HelpersTypeName = "Utilities"; /// Namespace containing all the generated code. private const string GeneratedNamespace = "System.Text.RegularExpressions.Generated"; + /// Tracking name for the source generation step, used by incremental generation tests. + public const string SourceGenerationTrackingName = "SourceGenerationStep"; /// Code for a [GeneratedCode] attribute to put on the top-level generated members. private static readonly string s_generatedCodeAttribute = $"GeneratedCodeAttribute(\"{typeof(RegexGenerator).Assembly.GetName().Name}\", \"{typeof(RegexGenerator).Assembly.GetName().Version}\")"; /// Header comments and usings to include at the top of every generated file. @@ -128,7 +130,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Select(static (t, _) => t.Model!) .Where(static m => m is not null) .Collect() - .WithComparer(new ObjectImmutableArraySequenceEqualityComparer()); + .WithComparer(new ObjectImmutableArraySequenceEqualityComparer()) + .WithTrackingName(SourceGenerationTrackingName); // Pipeline 1: Source generation only. // Source generation is fully incremental and only re-fires on structural model changes. diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs new file mode 100644 index 00000000000000..1ca26efce61d96 --- /dev/null +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs @@ -0,0 +1,260 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions.Generator; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace System.Text.RegularExpressions.Tests +{ + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] + public static class RegexGeneratorIncrementalTests + { + [Fact] + public static async Task SameInput_DoesNotRegenerate() + { + Compilation compilation = await CreateCompilation(@" + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex(""ab"")] + private static partial Regex MyRegex(); + }"); + + GeneratorDriver driver = CreateRegexGeneratorDriver(); + + driver = driver.RunGenerators(compilation); + GeneratorRunResult runResult = driver.GetRunResult().Results[0]; + + Assert.Collection(GetSourceGenRunSteps(runResult), + step => + { + Assert.Collection(step.Inputs, + source => Assert.Equal(IncrementalStepRunReason.New, source.Source.Outputs[source.OutputIndex].Reason)); + Assert.Collection(step.Outputs, + output => Assert.Equal(IncrementalStepRunReason.New, output.Reason)); + }); + + driver = driver.RunGenerators(compilation); + runResult = driver.GetRunResult().Results[0]; + + Assert.Collection(GetSourceGenRunSteps(runResult), + step => + { + Assert.Collection(step.Inputs, + source => Assert.Equal(IncrementalStepRunReason.Cached, source.Source.Outputs[source.OutputIndex].Reason)); + Assert.Collection(step.Outputs, + output => Assert.Equal(IncrementalStepRunReason.Cached, output.Reason)); + }); + } + + [Fact] + public static async Task EquivalentSources_Regenerates() + { + // Unlike STJ, the Regex generator model includes Dictionary + // for helper methods, which doesn't have value equality. Equivalent sources + // therefore produce Modified outputs rather than Unchanged. + string source1 = @" + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex(""ab"")] + private static partial Regex MyRegex(); + }"; + + string source2 = @" + using System.Text.RegularExpressions; + // Changing the comment and location should produce identical SG model. + partial class C + { + [GeneratedRegex(""ab"")] + private static partial Regex MyRegex(); + }"; + + Compilation compilation = await CreateCompilation(source1); + GeneratorDriver driver = CreateRegexGeneratorDriver(); + + driver = driver.RunGenerators(compilation); + GeneratorRunResult runResult = driver.GetRunResult().Results[0]; + Assert.Collection(GetSourceGenRunSteps(runResult), + step => + { + Assert.Collection(step.Inputs, + source => Assert.Equal(IncrementalStepRunReason.New, source.Source.Outputs[source.OutputIndex].Reason)); + Assert.Collection(step.Outputs, + output => Assert.Equal(IncrementalStepRunReason.New, output.Reason)); + }); + + compilation = compilation.ReplaceSyntaxTree( + compilation.SyntaxTrees.First(), + CSharpSyntaxTree.ParseText(SourceText.From(source2, Encoding.UTF8), s_parseOptions)); + driver = driver.RunGenerators(compilation); + runResult = driver.GetRunResult().Results[0]; + Assert.Collection(GetSourceGenRunSteps(runResult), + step => + { + Assert.Collection(step.Inputs, + source => Assert.Equal(IncrementalStepRunReason.Modified, source.Source.Outputs[source.OutputIndex].Reason)); + Assert.Collection(step.Outputs, + output => Assert.Equal(IncrementalStepRunReason.Modified, output.Reason)); + }); + } + + [Fact] + public static async Task DifferentSources_Regenerates() + { + string source1 = @" + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex(""ab"")] + private static partial Regex MyRegex(); + }"; + + string source2 = @" + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex(""cd"")] + private static partial Regex MyRegex(); + }"; + + Compilation compilation = await CreateCompilation(source1); + GeneratorDriver driver = CreateRegexGeneratorDriver(); + + driver = driver.RunGenerators(compilation); + GeneratorRunResult runResult = driver.GetRunResult().Results[0]; + Assert.Collection(GetSourceGenRunSteps(runResult), + step => + { + Assert.Collection(step.Inputs, + source => Assert.Equal(IncrementalStepRunReason.New, source.Source.Outputs[source.OutputIndex].Reason)); + Assert.Collection(step.Outputs, + output => Assert.Equal(IncrementalStepRunReason.New, output.Reason)); + }); + + compilation = compilation.ReplaceSyntaxTree( + compilation.SyntaxTrees.First(), + CSharpSyntaxTree.ParseText(SourceText.From(source2, Encoding.UTF8), s_parseOptions)); + driver = driver.RunGenerators(compilation); + runResult = driver.GetRunResult().Results[0]; + Assert.Collection(GetSourceGenRunSteps(runResult), + step => + { + Assert.Collection(step.Inputs, + source => Assert.Equal(IncrementalStepRunReason.Modified, source.Source.Outputs[source.OutputIndex].Reason)); + Assert.Collection(step.Outputs, + output => Assert.Equal(IncrementalStepRunReason.Modified, output.Reason)); + }); + } + + [Fact] + public static async Task SourceGenModelDoesNotEncapsulateSymbolsOrCompilationData() + { + Compilation compilation = await CreateCompilation(@" + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex(""ab"")] + private static partial Regex MyRegex(); + }"); + + GeneratorDriver driver = CreateRegexGeneratorDriver(); + driver = driver.RunGenerators(compilation); + GeneratorRunResult runResult = driver.GetRunResult().Results[0]; + + IncrementalGeneratorRunStep[] steps = GetSourceGenRunSteps(runResult); + foreach (IncrementalGeneratorRunStep step in steps) + { + foreach ((object Value, IncrementalStepRunReason Reason) output in step.Outputs) + { + WalkObjectGraph(output.Value); + } + } + + static void WalkObjectGraph(object obj) + { + var visited = new HashSet(); + Visit(obj); + + void Visit(object? node) + { + if (node is null || !visited.Add(node)) + { + return; + } + + Assert.False(node is Compilation or ISymbol, $"Model should not contain {node.GetType().Name}"); + + Type type = node.GetType(); + if (type.IsPrimitive || type.IsEnum || type == typeof(string)) + { + return; + } + + if (node is IEnumerable collection and not string) + { + foreach (object? element in collection) + { + Visit(element); + } + + return; + } + + foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + object? fieldValue = field.GetValue(node); + Visit(fieldValue); + } + } + } + } + + private static readonly CSharpParseOptions s_parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + + private static CSharpGeneratorDriver CreateRegexGeneratorDriver() + { + return CSharpGeneratorDriver.Create( + generators: new ISourceGenerator[] { new RegexGenerator().AsSourceGenerator() }, + parseOptions: s_parseOptions, + driverOptions: new GeneratorDriverOptions( + disabledOutputs: IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true)); + } + + private static async Task CreateCompilation(string source) + { + var proj = new AdhocWorkspace() + .AddSolution(SolutionInfo.Create(SolutionId.CreateNewId(), VersionStamp.Create())) + .AddProject("RegexGeneratorTest", "RegexGeneratorTest.dll", "C#") + .WithMetadataReferences(RegexGeneratorHelper.References) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(NullableContextOptions.Enable)) + .WithParseOptions(s_parseOptions) + .AddDocument("Test.cs", SourceText.From(source, Encoding.UTF8)).Project; + + Assert.True(proj.Solution.Workspace.TryApplyChanges(proj.Solution)); + + return (await proj.GetCompilationAsync(CancellationToken.None).ConfigureAwait(false))!; + } + + private static IncrementalGeneratorRunStep[] GetSourceGenRunSteps(GeneratorRunResult runResult) + { + Assert.True( + runResult.TrackedSteps.TryGetValue(RegexGenerator.SourceGenerationTrackingName, out var runSteps), + $"Tracked step '{RegexGenerator.SourceGenerationTrackingName}' not found. Available: {string.Join(", ", runResult.TrackedSteps.Keys)}"); + + return runSteps.ToArray(); + } + } +} diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/System.Text.RegularExpressions.Tests.csproj b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/System.Text.RegularExpressions.Tests.csproj index 62533b4e9c3377..f2df58e9f008ec 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/System.Text.RegularExpressions.Tests.csproj +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/System.Text.RegularExpressions.Tests.csproj @@ -74,6 +74,7 @@ + From 7a2b000672b6e1dc202fb0884d220b51706ac9bb Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Sun, 1 Mar 2026 10:30:42 +0000 Subject: [PATCH 04/29] Refactor diagnostic emission lambdas into named EmitDiagnostics methods Replace inline lambda callbacks in the diagnostic pipelines with named EmitDiagnostics static methods, complementing the existing EmitSource methods in all 4 generators (JSON, Logger, ConfigBinder, Regex). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/ConfigurationBindingGenerator.cs | 18 +++++------ .../gen/LoggerMessageGenerator.Roslyn4.0.cs | 30 +++++++++---------- .../gen/JsonSourceGenerator.Roslyn4.0.cs | 18 +++++------ .../gen/RegexGenerator.cs | 16 +++++----- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs index d9858801f67bc1..f446123d5363fc 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs @@ -77,15 +77,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) // without triggering expensive source regeneration. // See https://github.com/dotnet/runtime/issues/92509 for context. - context.RegisterSourceOutput( - genSpec, - static (context, tuple) => - { - foreach (Diagnostic diagnostic in tuple.Item2) - { - context.ReportDiagnostic(diagnostic); - } - }); + context.RegisterSourceOutput(genSpec, EmitDiagnostics); if (!s_hasInitializedInterceptorVersion) { @@ -156,6 +148,14 @@ internal static int DetermineInterceptableVersion() /// public Action? OnSourceEmitting { get; init; } + private static void EmitDiagnostics(SourceProductionContext context, (SourceGenerationSpec?, ImmutableArray) tuple) + { + foreach (Diagnostic diagnostic in tuple.Item2) + { + context.ReportDiagnostic(diagnostic); + } + } + private void EmitSource(SourceProductionContext sourceProductionContext, SourceGenerationSpec? spec) { if (spec is not null) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs index 45e52fb64592be..66a74621b4743a 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs @@ -110,24 +110,24 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) // without triggering expensive source regeneration. // See https://github.com/dotnet/runtime/issues/92509 for context. - context.RegisterSourceOutput( - loggerClasses.Collect(), - static (context, items) => + context.RegisterSourceOutput(loggerClasses.Collect(), EmitDiagnostics); + } + + private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray<(LoggerClassSpec? LoggerClassSpec, ImmutableArray Diagnostics, bool HasStringCreate)> items) + { + // Use HashSet to deduplicate — each attributed method triggers parsing of entire class, + // producing duplicate diagnostics. + var reportedDiagnostics = new HashSet<(string Id, TextSpan? Span, string? FilePath)>(); + foreach (var item in items) + { + foreach (Diagnostic diagnostic in item.Diagnostics) { - // Use HashSet to deduplicate — each attributed method triggers parsing of entire class, - // producing duplicate diagnostics. - var reportedDiagnostics = new HashSet<(string Id, TextSpan? Span, string? FilePath)>(); - foreach (var item in items) + if (reportedDiagnostics.Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) { - foreach (Diagnostic diagnostic in item.Diagnostics) - { - if (reportedDiagnostics.Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) - { - context.ReportDiagnostic(diagnostic); - } - } + context.ReportDiagnostic(diagnostic); } - }); + } + } } private static void EmitSource(ImmutableArray<(LoggerClassSpec? LoggerClassSpec, bool HasStringCreate)> items, SourceProductionContext context) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs index 5a7fc684da515f..f3f90fa71e7a1e 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs @@ -78,15 +78,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) // without triggering expensive source regeneration. // See https://github.com/dotnet/runtime/issues/92509 for context. - context.RegisterSourceOutput( - contextGenerationSpecs, - static (context, tuple) => - { - foreach (Diagnostic diagnostic in tuple.Item2) - { - context.ReportDiagnostic(diagnostic); - } - }); + context.RegisterSourceOutput(contextGenerationSpecs, EmitDiagnostics); } private void EmitSource(SourceProductionContext sourceProductionContext, ContextGenerationSpec? contextGenerationSpec) @@ -116,6 +108,14 @@ private void EmitSource(SourceProductionContext sourceProductionContext, Context #pragma warning restore RS1035 } + private static void EmitDiagnostics(SourceProductionContext context, (ContextGenerationSpec?, ImmutableArray) tuple) + { + foreach (Diagnostic diagnostic in tuple.Item2) + { + context.ReportDiagnostic(diagnostic); + } + } + /// /// Instrumentation helper for unit tests. /// diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 35c23f1d937bac..e3bb9169554cac 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -294,16 +294,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) // without triggering expensive source regeneration. // See https://github.com/dotnet/runtime/issues/92509 for context. - context.RegisterSourceOutput(pipeline.Collect(), static (context, items) => + context.RegisterSourceOutput(pipeline.Collect(), EmitDiagnostics); + } + + private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray<(object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics)> items) + { + foreach (var item in items) { - foreach (var item in items) + foreach (Diagnostic diagnostic in item.Diagnostics) { - foreach (Diagnostic diagnostic in item.Diagnostics) - { - context.ReportDiagnostic(diagnostic); - } + context.ReportDiagnostic(diagnostic); } - }); + } } /// Determines whether the passed in node supports C# code generation. From a4e500e0b26ebbd8001417b787f09cacdb13411b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 2 Mar 2026 19:10:11 +0200 Subject: [PATCH 05/29] Extract source model projections into explicit IVP bindings Create named IncrementalValueProvider variables for the equatable model projections in all 4 generators, with detailed comments explaining how Roslyn's Select operator uses model equality to guard source production. For the Regex generator, also extract the source emission lambda into a named EmitSource method, complementing EmitDiagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/ConfigurationBindingGenerator.cs | 23 +- .../gen/LoggerMessageGenerator.Roslyn4.0.cs | 25 +- .../gen/JsonSourceGenerator.Roslyn4.0.cs | 21 +- .../gen/RegexGenerator.cs | 271 +++++++++--------- 4 files changed, 175 insertions(+), 165 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs index f446123d5363fc..6079fe1651b2cb 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs @@ -67,16 +67,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }) .WithTrackingName(GenSpecTrackingName); - // Pipeline 1: Source generation only. - // Uses Select to extract just the spec; the Select operator deduplicates by - // comparing model equality, so source generation only re-fires on structural changes. - context.RegisterSourceOutput(genSpec.Select(static (t, _) => t.Item1), EmitSource); - - // Pipeline 2: Diagnostics only. - // Diagnostics use raw SourceLocation instances that are pragma-suppressible. - // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) - // without triggering expensive source regeneration. - // See https://github.com/dotnet/runtime/issues/92509 for context. + // Project the combined pipeline result to just the equatable model, discarding diagnostics. + // SourceGenerationSpec implements value equality, so Roslyn's Select operator will compare + // successive model snapshots and only propagate changes downstream when the model structurally + // differs. This ensures source generation is fully incremental: re-emitting code only when + // the binding spec actually changes, not on every keystroke or positional shift. + IncrementalValueProvider sourceGenerationSpec = + genSpec.Select(static (t, _) => t.Item1); + + context.RegisterSourceOutput(sourceGenerationSpec, EmitSource); + + // Report diagnostics directly from the unprojected pipeline. Diagnostics carry raw + // SourceLocation instances that are pragma-suppressible (cf. https://github.com/dotnet/runtime/issues/92509). + // No source code is generated from this pipeline — it exists solely to report diagnostics. context.RegisterSourceOutput(genSpec, EmitDiagnostics); if (!s_hasInitializedInterceptorVersion) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs index 66a74621b4743a..baff76b31e678d 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs @@ -98,18 +98,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) #endif ; - // Pipeline 1: Source generation only. - // Uses Select to extract just the model; the Select operator deduplicates by - // comparing model equality, so source generation only re-fires on structural changes. - context.RegisterSourceOutput( - loggerClasses.Select(static (t, _) => (t.LoggerClassSpec, t.HasStringCreate)).Collect(), - static (spc, items) => EmitSource(items, spc)); - - // Pipeline 2: Diagnostics only. - // Diagnostics use raw SourceLocation instances that are pragma-suppressible. - // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) - // without triggering expensive source regeneration. - // See https://github.com/dotnet/runtime/issues/92509 for context. + // Project the combined pipeline result to just the equatable model, discarding diagnostics. + // LoggerClassSpec implements value equality, so Roslyn's Select operator will compare + // successive model snapshots and only propagate changes downstream when the model structurally + // differs. This ensures source generation is fully incremental: re-emitting code only when + // the logger spec actually changes, not on every keystroke or positional shift. + IncrementalValueProvider> sourceGenerationSpecs = + loggerClasses.Select(static (t, _) => (t.LoggerClassSpec, t.HasStringCreate)).Collect(); + + context.RegisterSourceOutput(sourceGenerationSpecs, static (spc, items) => EmitSource(items, spc)); + + // Report diagnostics directly from the unprojected pipeline. Diagnostics carry raw + // SourceLocation instances that are pragma-suppressible (cf. https://github.com/dotnet/runtime/issues/92509). + // No source code is generated from this pipeline — it exists solely to report diagnostics. context.RegisterSourceOutput(loggerClasses.Collect(), EmitDiagnostics); } diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs index f3f90fa71e7a1e..336b9bf2f9913e 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs @@ -68,16 +68,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) #endif ; - // Pipeline 1: Source generation only. - // Uses Select to extract just the spec; the Select operator deduplicates by - // comparing model equality, so source generation only re-fires on structural changes. - context.RegisterSourceOutput(contextGenerationSpecs.Select(static (t, _) => t.Item1), EmitSource); + // Project the combined pipeline result to just the equatable model, discarding diagnostics. + // ContextGenerationSpec implements value equality, so Roslyn's Select operator will compare + // successive model snapshots and only propagate changes downstream when the model structurally + // differs. This ensures source generation is fully incremental: re-emitting code only when + // the serialization spec actually changes, not on every keystroke or positional shift. + IncrementalValuesProvider sourceGenerationSpecs = + contextGenerationSpecs.Select(static (t, _) => t.Item1); - // Pipeline 2: Diagnostics only. - // Diagnostics use raw SourceLocation instances that are pragma-suppressible. - // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) - // without triggering expensive source regeneration. - // See https://github.com/dotnet/runtime/issues/92509 for context. + context.RegisterSourceOutput(sourceGenerationSpecs, EmitSource); + + // Report diagnostics directly from the unprojected pipeline. Diagnostics carry raw + // SourceLocation instances that are pragma-suppressible (cf. https://github.com/dotnet/runtime/issues/92509). + // No source code is generated from this pipeline — it exists solely to report diagnostics. context.RegisterSourceOutput(contextGenerationSpecs, EmitDiagnostics); } diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index e3bb9169554cac..16e6ec17d7aa83 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -124,8 +124,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return (Model: (object?)(regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData), DiagnosticLocation: (Location?)null, item.Diagnostics); }); - // Source pipeline: extract just the model (which has value equality and no Location/SyntaxTree references), - // collect into a single batch, and apply sequence equality for incremental caching. + // Project the combined pipeline result to just the equatable model, discarding diagnostics + // and Location references. The model types (RegexPatternAndSyntax, RegexMethod, and their + // containing tuples) implement value equality via records, so Roslyn's Select operator will + // compare successive model snapshots and only propagate changes downstream when the model + // structurally differs. The collected array uses ObjectImmutableArraySequenceEqualityComparer + // for element-wise equality. This ensures source generation is fully incremental: re-emitting + // code only when the regex spec actually changes, not on every keystroke or positional shift. IncrementalValueProvider> sourceResults = pipeline .Select(static (t, _) => t.Model!) .Where(static m => m is not null) @@ -133,168 +138,166 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .WithComparer(new ObjectImmutableArraySequenceEqualityComparer()) .WithTrackingName(SourceGenerationTrackingName); - // Pipeline 1: Source generation only. - // Source generation is fully incremental and only re-fires on structural model changes. - context.RegisterSourceOutput(sourceResults, static (context, results) => + context.RegisterSourceOutput(sourceResults, EmitSource); + + // Report diagnostics directly from the unprojected pipeline. Diagnostics carry raw + // SourceLocation instances that are pragma-suppressible (cf. https://github.com/dotnet/runtime/issues/92509). + // No source code is generated from this pipeline — it exists solely to report diagnostics. + context.RegisterSourceOutput(pipeline.Collect(), EmitDiagnostics); + } + + private static void EmitSource(SourceProductionContext context, ImmutableArray results) + { + if (results.IsDefaultOrEmpty) { - if (results.IsDefaultOrEmpty) - { - return; - } + return; + } - // At this point we'll be emitting code. Create a writer to hold it all. - using StringWriter sw = new(); - using IndentedTextWriter writer = new(sw); + // At this point we'll be emitting code. Create a writer to hold it all. + using StringWriter sw = new(); + using IndentedTextWriter writer = new(sw); - // Add file headers and required usings. - foreach (string header in s_headers) + // Add file headers and required usings. + foreach (string header in s_headers) + { + writer.WriteLine(header); + } + writer.WriteLine(); + + // For every generated type, we give it an incrementally increasing ID, in order to create + // unique type names even in situations where method names were the same, while also keeping + // the type names short. Note that this is why we only generate the RunnerFactory implementations + // earlier in the pipeline... we want to avoid generating code that relies on the class names + // until we're able to iterate through them linearly keeping track of a deterministic ID + // used to name them. The boilerplate code generation that happens here is minimal when compared to + // the work required to generate the actual matching code for the regex. + int id = 0; + + // To minimize generated code in the event of duplicated regexes, we only emit one derived Regex type per unique + // expression/options/timeout. A Dictionary<(expression, options, timeout), RegexMethod> is used to deduplicate, where the value of the + // pair is the implementation used for the key. + var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), RegexMethod>(); + + // If we have any (RegexMethod regexMethod, string reason, CompilationData), these are regexes for which we have + // limited support and need to simply output boilerplate. + // If we have any (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers, CompilationData), + // those are generated implementations to be emitted. We need to gather up their required helpers. + Dictionary requiredHelpers = new(); + foreach (object? result in results) + { + RegexMethod? regexMethod = null; + if (result is ValueTuple limitedSupportResult) { - writer.WriteLine(header); + regexMethod = limitedSupportResult.Item1; } - writer.WriteLine(); - - // For every generated type, we give it an incrementally increasing ID, in order to create - // unique type names even in situations where method names were the same, while also keeping - // the type names short. Note that this is why we only generate the RunnerFactory implementations - // earlier in the pipeline... we want to avoid generating code that relies on the class names - // until we're able to iterate through them linearly keeping track of a deterministic ID - // used to name them. The boilerplate code generation that happens here is minimal when compared to - // the work required to generate the actual matching code for the regex. - int id = 0; - - // To minimize generated code in the event of duplicated regexes, we only emit one derived Regex type per unique - // expression/options/timeout. A Dictionary<(expression, options, timeout), RegexMethod> is used to deduplicate, where the value of the - // pair is the implementation used for the key. - var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), RegexMethod>(); - - // If we have any (RegexMethod regexMethod, string reason, CompilationData), these are regexes for which we have - // limited support and need to simply output boilerplate. - // If we have any (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers, CompilationData), - // those are generated implementations to be emitted. We need to gather up their required helpers. - Dictionary requiredHelpers = new(); - foreach (object? result in results) + else if (result is ValueTuple, CompilationData> regexImpl) { - RegexMethod? regexMethod = null; - if (result is ValueTuple limitedSupportResult) + foreach (KeyValuePair helper in regexImpl.Item3) { - regexMethod = limitedSupportResult.Item1; - } - else if (result is ValueTuple, CompilationData> regexImpl) - { - foreach (KeyValuePair helper in regexImpl.Item3) + if (!requiredHelpers.ContainsKey(helper.Key)) { - if (!requiredHelpers.ContainsKey(helper.Key)) - { - requiredHelpers.Add(helper.Key, helper.Value); - } + requiredHelpers.Add(helper.Key, helper.Value); } - - regexMethod = regexImpl.Item1; } - if (regexMethod is not null) - { - var key = (regexMethod.Pattern, regexMethod.Options, regexMethod.MatchTimeout); - if (emittedExpressions.TryGetValue(key, out RegexMethod? implementation)) - { - regexMethod.IsDuplicate = true; - regexMethod.GeneratedName = implementation.GeneratedName; - } - else - { - regexMethod.IsDuplicate = false; - regexMethod.GeneratedName = $"{regexMethod.MemberName}_{id++}"; - emittedExpressions.Add(key, regexMethod); - } - - EmitRegexPartialMethod(regexMethod, writer); - writer.WriteLine(); - } + regexMethod = regexImpl.Item1; } - // At this point we've emitted all the partial method definitions, but we still need to emit the actual regex-derived implementations. - // These are all emitted inside of our generated class. + if (regexMethod is not null) + { + var key = (regexMethod.Pattern, regexMethod.Options, regexMethod.MatchTimeout); + if (emittedExpressions.TryGetValue(key, out RegexMethod? implementation)) + { + regexMethod.IsDuplicate = true; + regexMethod.GeneratedName = implementation.GeneratedName; + } + else + { + regexMethod.IsDuplicate = false; + regexMethod.GeneratedName = $"{regexMethod.MemberName}_{id++}"; + emittedExpressions.Add(key, regexMethod); + } - writer.WriteLine($"namespace {GeneratedNamespace}"); - writer.WriteLine($"{{"); + EmitRegexPartialMethod(regexMethod, writer); + writer.WriteLine(); + } + } - // We emit usings here now that we're inside of a namespace block and are no longer emitting code into - // a user's partial type. We can now rely on binding rules mapping to these usings and don't need to - // use global-qualified names for the rest of the implementation. - writer.WriteLine($" using System;"); - writer.WriteLine($" using System.Buffers;"); - writer.WriteLine($" using System.CodeDom.Compiler;"); - writer.WriteLine($" using System.Collections;"); - writer.WriteLine($" using System.ComponentModel;"); - writer.WriteLine($" using System.Globalization;"); - writer.WriteLine($" using System.Runtime.CompilerServices;"); - writer.WriteLine($" using System.Text.RegularExpressions;"); - writer.WriteLine($" using System.Threading;"); - writer.WriteLine($""); - - // Emit each Regex-derived type. - writer.Indent++; - foreach (object? result in results) + // At this point we've emitted all the partial method definitions, but we still need to emit the actual regex-derived implementations. + // These are all emitted inside of our generated class. + + writer.WriteLine($"namespace {GeneratedNamespace}"); + writer.WriteLine($"{{"); + + // We emit usings here now that we're inside of a namespace block and are no longer emitting code into + // a user's partial type. We can now rely on binding rules mapping to these usings and don't need to + // use global-qualified names for the rest of the implementation. + writer.WriteLine($" using System;"); + writer.WriteLine($" using System.Buffers;"); + writer.WriteLine($" using System.CodeDom.Compiler;"); + writer.WriteLine($" using System.Collections;"); + writer.WriteLine($" using System.ComponentModel;"); + writer.WriteLine($" using System.Globalization;"); + writer.WriteLine($" using System.Runtime.CompilerServices;"); + writer.WriteLine($" using System.Text.RegularExpressions;"); + writer.WriteLine($" using System.Threading;"); + writer.WriteLine($""); + + // Emit each Regex-derived type. + writer.Indent++; + foreach (object? result in results) + { + if (result is ValueTuple limitedSupportResult) { - if (result is ValueTuple limitedSupportResult) + if (!limitedSupportResult.Item1.IsDuplicate) { - if (!limitedSupportResult.Item1.IsDuplicate) - { - EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item3.LanguageVersion); - writer.WriteLine(); - } + EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item3.LanguageVersion); + writer.WriteLine(); } - else if (result is ValueTuple, CompilationData> regexImpl) + } + else if (result is ValueTuple, CompilationData> regexImpl) + { + if (!regexImpl.Item1.IsDuplicate) { - if (!regexImpl.Item1.IsDuplicate) - { - EmitRegexDerivedImplementation(writer, regexImpl.Item1, regexImpl.Item2, regexImpl.Item4.AllowUnsafe); - writer.WriteLine(); - } + EmitRegexDerivedImplementation(writer, regexImpl.Item1, regexImpl.Item2, regexImpl.Item4.AllowUnsafe); + writer.WriteLine(); } } - writer.Indent--; + } + writer.Indent--; - // If any of the Regex-derived types asked for helper methods, emit those now. - if (requiredHelpers.Count != 0) + // If any of the Regex-derived types asked for helper methods, emit those now. + if (requiredHelpers.Count != 0) + { + writer.Indent++; + writer.WriteLine($"/// Helper methods used by generated -derived implementations."); + writer.WriteLine($"[{s_generatedCodeAttribute}]"); + writer.WriteLine($"file static class {HelpersTypeName}"); + writer.WriteLine($"{{"); + writer.Indent++; + bool sawFirst = false; + foreach (KeyValuePair helper in requiredHelpers.OrderBy(h => h.Key, StringComparer.Ordinal)) { - writer.Indent++; - writer.WriteLine($"/// Helper methods used by generated -derived implementations."); - writer.WriteLine($"[{s_generatedCodeAttribute}]"); - writer.WriteLine($"file static class {HelpersTypeName}"); - writer.WriteLine($"{{"); - writer.Indent++; - bool sawFirst = false; - foreach (KeyValuePair helper in requiredHelpers.OrderBy(h => h.Key, StringComparer.Ordinal)) + if (sawFirst) { - if (sawFirst) - { - writer.WriteLine(); - } - sawFirst = true; + writer.WriteLine(); + } + sawFirst = true; - foreach (string value in helper.Value) - { - writer.WriteLine(value); - } + foreach (string value in helper.Value) + { + writer.WriteLine(value); } - writer.Indent--; - writer.WriteLine($"}}"); - writer.Indent--; } - + writer.Indent--; writer.WriteLine($"}}"); + writer.Indent--; + } - // Save out the source - context.AddSource("RegexGenerator.g.cs", sw.ToString()); - }); + writer.WriteLine($"}}"); - // Pipeline 2: Diagnostics only. - // Diagnostics use raw SourceLocation instances that are pragma-suppressible. - // This pipeline re-fires whenever diagnostics change (e.g. positional shifts) - // without triggering expensive source regeneration. - // See https://github.com/dotnet/runtime/issues/92509 for context. - context.RegisterSourceOutput(pipeline.Collect(), EmitDiagnostics); + // Save out the source + context.AddSource("RegexGenerator.g.cs", sw.ToString()); } private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray<(object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics)> items) From 091625e8738f92a7b06fad84ec23c49d47e2f5cd Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 2 Mar 2026 19:16:00 +0200 Subject: [PATCH 06/29] Extract diagnostic pipelines into explicit IVP projections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create named IncrementalValueProvider variables for the diagnostic projections in all 4 generators, with comments explaining that ImmutableArray uses reference equality in the incremental pipeline — the callback fires on every compilation change by design. This also simplifies the EmitDiagnostics signatures to accept just the projected diagnostics rather than the full model+diagnostics tuple. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/ConfigurationBindingGenerator.cs | 16 +++++++++++----- .../gen/LoggerMessageGenerator.Roslyn4.0.cs | 18 ++++++++++++------ .../gen/JsonSourceGenerator.Roslyn4.0.cs | 16 +++++++++++----- .../gen/RegexGenerator.cs | 18 ++++++++++++------ 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs index 6079fe1651b2cb..2b36dd47675b7c 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs @@ -77,10 +77,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput(sourceGenerationSpec, EmitSource); - // Report diagnostics directly from the unprojected pipeline. Diagnostics carry raw - // SourceLocation instances that are pragma-suppressible (cf. https://github.com/dotnet/runtime/issues/92509). + // Project to just the diagnostics, discarding the model. ImmutableArray does not + // implement value equality, so Roslyn's incremental pipeline uses reference equality for these + // values — the callback fires on every compilation change. This is by design: diagnostic + // emission is cheap, and we need fresh SourceLocation instances that are pragma-suppressible + // (cf. https://github.com/dotnet/runtime/issues/92509). // No source code is generated from this pipeline — it exists solely to report diagnostics. - context.RegisterSourceOutput(genSpec, EmitDiagnostics); + IncrementalValueProvider> diagnostics = + genSpec.Select(static (t, _) => t.Item2); + + context.RegisterSourceOutput(diagnostics, EmitDiagnostics); if (!s_hasInitializedInterceptorVersion) { @@ -151,9 +157,9 @@ internal static int DetermineInterceptableVersion() /// public Action? OnSourceEmitting { get; init; } - private static void EmitDiagnostics(SourceProductionContext context, (SourceGenerationSpec?, ImmutableArray) tuple) + private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray diagnostics) { - foreach (Diagnostic diagnostic in tuple.Item2) + foreach (Diagnostic diagnostic in diagnostics) { context.ReportDiagnostic(diagnostic); } diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs index baff76b31e678d..6b970d2ab25f89 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs @@ -108,20 +108,26 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput(sourceGenerationSpecs, static (spc, items) => EmitSource(items, spc)); - // Report diagnostics directly from the unprojected pipeline. Diagnostics carry raw - // SourceLocation instances that are pragma-suppressible (cf. https://github.com/dotnet/runtime/issues/92509). + // Project to just the diagnostics, discarding the model. ImmutableArray does not + // implement value equality, so Roslyn's incremental pipeline uses reference equality for these + // values — the callback fires on every compilation change. This is by design: diagnostic + // emission is cheap, and we need fresh SourceLocation instances that are pragma-suppressible + // (cf. https://github.com/dotnet/runtime/issues/92509). // No source code is generated from this pipeline — it exists solely to report diagnostics. - context.RegisterSourceOutput(loggerClasses.Collect(), EmitDiagnostics); + IncrementalValueProvider>> diagnostics = + loggerClasses.Select(static (t, _) => t.Diagnostics).Collect(); + + context.RegisterSourceOutput(diagnostics, EmitDiagnostics); } - private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray<(LoggerClassSpec? LoggerClassSpec, ImmutableArray Diagnostics, bool HasStringCreate)> items) + private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray> items) { // Use HashSet to deduplicate — each attributed method triggers parsing of entire class, // producing duplicate diagnostics. var reportedDiagnostics = new HashSet<(string Id, TextSpan? Span, string? FilePath)>(); - foreach (var item in items) + foreach (ImmutableArray diagnosticBatch in items) { - foreach (Diagnostic diagnostic in item.Diagnostics) + foreach (Diagnostic diagnostic in diagnosticBatch) { if (reportedDiagnostics.Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) { diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs index 336b9bf2f9913e..79fab10a1cfc34 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Roslyn4.0.cs @@ -78,10 +78,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput(sourceGenerationSpecs, EmitSource); - // Report diagnostics directly from the unprojected pipeline. Diagnostics carry raw - // SourceLocation instances that are pragma-suppressible (cf. https://github.com/dotnet/runtime/issues/92509). + // Project to just the diagnostics, discarding the model. ImmutableArray does not + // implement value equality, so Roslyn's incremental pipeline uses reference equality for these + // values — the callback fires on every compilation change. This is by design: diagnostic + // emission is cheap, and we need fresh SourceLocation instances that are pragma-suppressible + // (cf. https://github.com/dotnet/runtime/issues/92509). // No source code is generated from this pipeline — it exists solely to report diagnostics. - context.RegisterSourceOutput(contextGenerationSpecs, EmitDiagnostics); + IncrementalValuesProvider> diagnostics = + contextGenerationSpecs.Select(static (t, _) => t.Item2); + + context.RegisterSourceOutput(diagnostics, EmitDiagnostics); } private void EmitSource(SourceProductionContext sourceProductionContext, ContextGenerationSpec? contextGenerationSpec) @@ -111,9 +117,9 @@ private void EmitSource(SourceProductionContext sourceProductionContext, Context #pragma warning restore RS1035 } - private static void EmitDiagnostics(SourceProductionContext context, (ContextGenerationSpec?, ImmutableArray) tuple) + private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray diagnostics) { - foreach (Diagnostic diagnostic in tuple.Item2) + foreach (Diagnostic diagnostic in diagnostics) { context.ReportDiagnostic(diagnostic); } diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 16e6ec17d7aa83..1745259a2480c1 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -140,10 +140,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput(sourceResults, EmitSource); - // Report diagnostics directly from the unprojected pipeline. Diagnostics carry raw - // SourceLocation instances that are pragma-suppressible (cf. https://github.com/dotnet/runtime/issues/92509). + // Project to just the diagnostics, discarding the model. ImmutableArray does not + // implement value equality, so Roslyn's incremental pipeline uses reference equality for these + // values — the callback fires on every compilation change. This is by design: diagnostic + // emission is cheap, and we need fresh SourceLocation instances that are pragma-suppressible + // (cf. https://github.com/dotnet/runtime/issues/92509). // No source code is generated from this pipeline — it exists solely to report diagnostics. - context.RegisterSourceOutput(pipeline.Collect(), EmitDiagnostics); + IncrementalValueProvider>> diagnosticResults = + pipeline.Select(static (t, _) => t.Diagnostics).Collect(); + + context.RegisterSourceOutput(diagnosticResults, EmitDiagnostics); } private static void EmitSource(SourceProductionContext context, ImmutableArray results) @@ -300,11 +306,11 @@ private static void EmitSource(SourceProductionContext context, ImmutableArray Diagnostics)> items) + private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray> items) { - foreach (var item in items) + foreach (ImmutableArray diagnosticBatch in items) { - foreach (Diagnostic diagnostic in item.Diagnostics) + foreach (Diagnostic diagnostic in diagnosticBatch) { context.ReportDiagnostic(diagnostic); } From bdfaf17ade6bebf7780e5f9b77810d5233eb4e6f Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 3 Mar 2026 11:03:18 +0200 Subject: [PATCH 07/29] Restructure Regex generator to use deeply equatable result record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the untyped object?-based pipeline model with a deeply equatable RegexSourceGenerationResult record containing ImmutableEquatableArray fields. This makes the Regex generator fully incremental — equivalent sources now produce Cached outputs instead of re-emitting code on every compilation. Key changes: - Add ImmutableEquatableArray.cs and HashHelpers.cs to the Regex gen csproj - Define RegexMethodEntry, HelperMethod, and RegexSourceGenerationResult records with deep value equality via ImmutableEquatableArray fields - Pre-compute XML expression description and capture metadata during pipeline so the equatable model contains no RegexTree/AnalysisResults references - Collect + aggregate step deduplicates helpers across all regex methods - Remove ObjectImmutableArraySequenceEqualityComparer (no longer needed) - Remove mutable IsDuplicate/GeneratedName from RegexMethod (computed at emit time) - Update EmitRegexPartialMethod/EmitRegexLimitedBoilerplate/EmitRegexDerivedImplementation to accept typed RegexMethodEntry + generatedName instead of RegexMethod - Update incremental tests: EquivalentSources now expects Cached Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.Emitter.cs | 103 ++++---- .../gen/RegexGenerator.Parser.cs | 42 ++- .../gen/RegexGenerator.cs | 250 +++++++++--------- ...m.Text.RegularExpressions.Generator.csproj | 2 + .../RegexGeneratorIncrementalTests.cs | 12 +- 5 files changed, 227 insertions(+), 182 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs index 1fb6aaf78f3fb6..af3f73e048df49 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs @@ -57,10 +57,10 @@ private static string EscapeXmlComment(string text) } /// Emits the definition of the partial method. This method just delegates to the property cache on the generated Regex-derived type. - private static void EmitRegexPartialMethod(RegexMethod regexMethod, IndentedTextWriter writer) + private static void EmitRegexPartialMethod(RegexMethodEntry entry, string generatedName, IndentedTextWriter writer) { // Emit the namespace. - RegexType? parent = regexMethod.DeclaringType; + RegexType? parent = entry.DeclaringType; if (!string.IsNullOrWhiteSpace(parent.Namespace)) { writer.WriteLine($"namespace {parent.Namespace}"); @@ -85,24 +85,32 @@ private static void EmitRegexPartialMethod(RegexMethod regexMethod, IndentedText // Emit the partial method definition. writer.WriteLine($"/// "); writer.WriteLine($"/// Pattern:
"); - writer.WriteLine($"/// {EscapeXmlComment(regexMethod.Pattern)}
"); - if (regexMethod.Options != RegexOptions.None) + writer.WriteLine($"/// {EscapeXmlComment(entry.Pattern)}
"); + if (entry.Options != RegexOptions.None) { writer.WriteLine($"/// Options:
"); - writer.WriteLine($"/// {Literal(regexMethod.Options)}
"); + writer.WriteLine($"/// {Literal(entry.Options)}
"); } writer.WriteLine($"/// Explanation:
"); writer.WriteLine($"/// "); - DescribeExpressionAsXmlComment(writer, regexMethod.Tree.Root.Child(0), regexMethod); // skip implicit root capture + // Emit the pre-computed expression description line by line so IndentedTextWriter adds proper indentation. + using (var reader = new StringReader(entry.ExpressionDescription)) + { + string? line; + while ((line = reader.ReadLine()) is not null) + { + writer.WriteLine(line); + } + } writer.WriteLine($"/// "); writer.WriteLine($"///
"); writer.WriteLine($"[global::System.CodeDom.Compiler.{s_generatedCodeAttribute}]"); - writer.Write($"{regexMethod.Modifiers} global::System.Text.RegularExpressions.Regex{(regexMethod.NullableRegex ? "?" : "")} {regexMethod.MemberName}"); - if (!regexMethod.IsProperty) + writer.Write($"{entry.Modifiers} global::System.Text.RegularExpressions.Regex{(entry.NullableRegex ? "?" : "")} {entry.MemberName}"); + if (!entry.IsProperty) { writer.Write("()"); } - writer.WriteLine($" => global::{GeneratedNamespace}.{regexMethod.GeneratedName}.Instance;"); + writer.WriteLine($" => global::{GeneratedNamespace}.{generatedName}.Instance;"); // Unwind all scopes while (writer.Indent != 0) @@ -114,13 +122,13 @@ private static void EmitRegexPartialMethod(RegexMethod regexMethod, IndentedText /// Emits the Regex-derived type for a method where we're unable to generate custom code. private static void EmitRegexLimitedBoilerplate( - IndentedTextWriter writer, RegexMethod rm, string reason, LanguageVersion langVer) + IndentedTextWriter writer, RegexMethodEntry entry, string generatedName, string reason, LanguageVersion langVer) { string visibility; if (langVer >= LanguageVersion.CSharp11) { visibility = "file"; - writer.WriteLine($"/// Caches a instance for the {rm.MemberName} method."); + writer.WriteLine($"/// Caches a instance for the {entry.MemberName} method."); } else { @@ -129,14 +137,14 @@ private static void EmitRegexLimitedBoilerplate( } writer.WriteLine($"/// A custom Regex-derived type could not be generated because {reason}."); writer.WriteLine($"[{s_generatedCodeAttribute}]"); - writer.WriteLine($"{visibility} sealed class {rm.GeneratedName} : Regex"); + writer.WriteLine($"{visibility} sealed class {generatedName} : Regex"); writer.WriteLine($"{{"); writer.WriteLine($" /// Cached, thread-safe singleton instance."); writer.Write($" internal static readonly Regex Instance = "); writer.WriteLine( - rm.MatchTimeout is not null ? $"new({Literal(rm.Pattern)}, {Literal(rm.Options)}, {GetTimeoutExpression(rm.MatchTimeout.Value)});" : - rm.Options != 0 ? $"new({Literal(rm.Pattern)}, {Literal(rm.Options)});" : - $"new({Literal(rm.Pattern)});"); + entry.MatchTimeout is not null ? $"new({Literal(entry.Pattern)}, {Literal(entry.Options)}, {GetTimeoutExpression(entry.MatchTimeout.Value)});" : + entry.Options != 0 ? $"new({Literal(entry.Pattern)}, {Literal(entry.Options)});" : + $"new({Literal(entry.Pattern)});"); writer.WriteLine($"}}"); } @@ -148,27 +156,27 @@ private static void EmitRegexLimitedBoilerplate( /// Emits the Regex-derived type for a method whose RunnerFactory implementation was generated into . private static void EmitRegexDerivedImplementation( - IndentedTextWriter writer, RegexMethod rm, string runnerFactoryImplementation, bool allowUnsafe) + IndentedTextWriter writer, RegexMethodEntry entry, string generatedName, string runnerFactoryImplementation, bool allowUnsafe) { - writer.WriteLine($"/// Custom -derived type for the {rm.MemberName} method."); + writer.WriteLine($"/// Custom -derived type for the {entry.MemberName} method."); writer.WriteLine($"[{s_generatedCodeAttribute}]"); if (allowUnsafe) { writer.WriteLine($"[SkipLocalsInit]"); } - writer.WriteLine($"file sealed class {rm.GeneratedName} : Regex"); + writer.WriteLine($"file sealed class {generatedName} : Regex"); writer.WriteLine($"{{"); writer.WriteLine($" /// Cached, thread-safe singleton instance."); - writer.WriteLine($" internal static readonly {rm.GeneratedName} Instance = new();"); + writer.WriteLine($" internal static readonly {generatedName} Instance = new();"); writer.WriteLine($""); writer.WriteLine($" /// Initializes the instance."); - writer.WriteLine($" private {rm.GeneratedName}()"); + writer.WriteLine($" private {generatedName}()"); writer.WriteLine($" {{"); - writer.WriteLine($" base.pattern = {Literal(rm.Pattern)};"); - writer.WriteLine($" base.roptions = {Literal(rm.Options)};"); - if (rm.MatchTimeout is not null) + writer.WriteLine($" base.pattern = {Literal(entry.Pattern)};"); + writer.WriteLine($" base.roptions = {Literal(entry.Options)};"); + if (entry.MatchTimeout is not null) { - writer.WriteLine($" base.internalMatchTimeout = {GetTimeoutExpression(rm.MatchTimeout.Value)};"); + writer.WriteLine($" base.internalMatchTimeout = {GetTimeoutExpression(entry.MatchTimeout.Value)};"); } else { @@ -176,23 +184,35 @@ private static void EmitRegexDerivedImplementation( writer.WriteLine($" base.internalMatchTimeout = {HelpersTypeName}.{DefaultTimeoutFieldName};"); } writer.WriteLine($" base.factory = new RunnerFactory();"); - if (rm.Tree.CaptureNumberSparseMapping is not null) + if (entry.CaptureNumberSparseMapping is not null) { writer.Write(" base.Caps = new Hashtable {"); - AppendHashtableContents(writer, rm.Tree.CaptureNumberSparseMapping.Cast().OrderBy(de => de.Key as int?)); + string separator = ""; + foreach ((int key, int value) in entry.CaptureNumberSparseMapping) + { + writer.Write(separator); + separator = ", "; + writer.Write($" {{ {key}, {value} }} "); + } writer.WriteLine($" }};"); } - if (rm.Tree.CaptureNameToNumberMapping is not null) + if (entry.CaptureNameToNumberMapping is not null) { writer.Write(" base.CapNames = new Hashtable {"); - AppendHashtableContents(writer, rm.Tree.CaptureNameToNumberMapping.Cast().OrderBy(de => de.Key as string, StringComparer.Ordinal)); + string separator = ""; + foreach ((string key, int value) in entry.CaptureNameToNumberMapping) + { + writer.Write(separator); + separator = ", "; + writer.Write($" {{ \"{key}\", {value} }} "); + } writer.WriteLine($" }};"); } - if (rm.Tree.CaptureNames is not null) + if (entry.CaptureNames is not null) { writer.Write(" base.capslist = new string[] {"); string separator = ""; - foreach (string s in rm.Tree.CaptureNames) + foreach (string s in entry.CaptureNames) { writer.Write(separator); writer.Write(Literal(s)); @@ -200,31 +220,10 @@ private static void EmitRegexDerivedImplementation( } writer.WriteLine($" }};"); } - writer.WriteLine($" base.capsize = {rm.Tree.CaptureCount};"); + writer.WriteLine($" base.capsize = {entry.CaptureCount};"); writer.WriteLine($" }}"); writer.WriteLine(runnerFactoryImplementation); writer.WriteLine($"}}"); - - static void AppendHashtableContents(IndentedTextWriter writer, IEnumerable contents) - { - string separator = ""; - foreach (DictionaryEntry en in contents) - { - writer.Write(separator); - separator = ", "; - - writer.Write(" { "); - if (en.Key is int key) - { - writer.Write(key); - } - else - { - writer.Write($"\"{en.Key}\""); - } - writer.Write($", {en.Value} }} "); - } - } } /// Emits the code for the RunnerFactory. This is the actual logic for the regular expression. diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 1b61bbeaa740f7..40da109b0a01b1 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Linq; using System.Threading; +using SourceGenerators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -254,16 +255,47 @@ SyntaxKind.RecordStructDeclaration or internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); /// Data about a regex, including a fully parsed RegexTree and subsequent analysis. - internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) - { - public string? GeneratedName { get; set; } - public bool IsDuplicate { get; set; } - } + internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData); /// A type holding a regex method. internal sealed record RegexType(string Keyword, string Namespace, string Name) { public RegexType? Parent { get; set; } } + + /// + /// Per-method data extracted from with all fields deeply equatable. + /// This is the incremental model used for source generation — it contains no references to + /// , , or Roslyn symbols. + /// + internal sealed record RegexMethodEntry( + RegexType DeclaringType, + bool IsProperty, + string MemberName, + string Modifiers, + bool NullableRegex, + string Pattern, + RegexOptions Options, + int? MatchTimeout, + CompilationData CompilationData, + string? GeneratedCode, + string? LimitedSupportReason, + string ExpressionDescription, + ImmutableEquatableArray<(int Key, int Value)>? CaptureNumberSparseMapping, + ImmutableEquatableArray<(string Key, int Value)>? CaptureNameToNumberMapping, + ImmutableEquatableArray? CaptureNames, + int CaptureCount); + + /// A named helper method (e.g. IsWordChar, IsBoundary) shared across regex implementations. + internal sealed record HelperMethod(string Name, ImmutableEquatableArray Lines); + + /// + /// The complete source generation model for all regex methods in a compilation. + /// All fields use for deep value equality, + /// enabling Roslyn's incremental pipeline to skip re-emission when the model is unchanged. + /// + internal sealed record RegexSourceGenerationResult( + ImmutableEquatableArray Methods, + ImmutableEquatableArray Helpers); } } diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 1745259a2480c1..a53d1440ceb026 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -12,6 +12,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using SourceGenerators; [assembly: System.Resources.NeutralResourcesLanguage("en-us")] @@ -56,8 +57,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // - null in the case of a failure (diagnostics explain the error) // - RegexPatternAndSyntax on initial parse success // - RegexMethod after regex tree parsing - // - (RegexMethod, string code, Dictionary helpers, CompilationData) for full code generation - // - (RegexMethod, string reason, CompilationData) for limited-support regex IncrementalValuesProvider<(object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics)> pipeline = context.SyntaxProvider @@ -96,21 +95,46 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } return item; - }) + }); - // Generate the RunnerFactory for each regex, if possible. This is where the bulk of the implementation occurs. - .Select(((object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics) item, CancellationToken _) => + // Generate the RunnerFactory for each regex (if possible), extract all data from the RegexTree + // into deeply equatable types, and discard the tree. After this step, the model contains no + // references to RegexTree, AnalysisResults, or Roslyn symbols. + IncrementalValuesProvider<(RegexMethodEntry? Entry, ImmutableEquatableArray Helpers, ImmutableArray Diagnostics)> typedPipeline = + pipeline.Select(static ((object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics) item, CancellationToken _) => { if (item.Model is not RegexMethod regexMethod) { - return item; + return (Entry: (RegexMethodEntry?)null, Helpers: ImmutableEquatableArray.Empty, item.Diagnostics); } + // Pre-compute the XML expression description from the tree while we still have access to it. + string expressionDescription = PreComputeExpressionDescription(regexMethod); + + // Extract capture metadata from the tree into equatable forms. + ImmutableEquatableArray<(int Key, int Value)>? captureNumberSparseMapping = regexMethod.Tree.CaptureNumberSparseMapping is { } cnsm + ? cnsm.Cast().Select(de => (Key: (int)de.Key, Value: (int)de.Value!)).OrderBy(p => p.Key).ToImmutableEquatableArray() + : null; + ImmutableEquatableArray<(string Key, int Value)>? captureNameToNumberMapping = regexMethod.Tree.CaptureNameToNumberMapping is { } cntnm + ? cntnm.Cast().Select(de => (Key: (string)de.Key, Value: (int)de.Value!)).OrderBy(p => p.Key, StringComparer.Ordinal).ToImmutableEquatableArray() + : null; + ImmutableEquatableArray? captureNames = regexMethod.Tree.CaptureNames?.ToImmutableEquatableArray(); + int captureCount = regexMethod.Tree.CaptureCount; + // If we're unable to generate a full implementation for this regex, report a diagnostic. // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) { - return (Model: (object?)(regexMethod, reason, regexMethod.CompilationData), DiagnosticLocation: (Location?)null, Diagnostics: item.Diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, item.DiagnosticLocation))); + var limitedEntry = new RegexMethodEntry( + regexMethod.DeclaringType, regexMethod.IsProperty, regexMethod.MemberName, + regexMethod.Modifiers, regexMethod.NullableRegex, regexMethod.Pattern, + regexMethod.Options, regexMethod.MatchTimeout, regexMethod.CompilationData, + GeneratedCode: null, LimitedSupportReason: reason, + expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, + captureNames, captureCount); + + return (Entry: (RegexMethodEntry?)limitedEntry, Helpers: ImmutableEquatableArray.Empty, + Diagnostics: item.Diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, item.DiagnosticLocation))); } // Generate the core logic for the regex. @@ -121,21 +145,70 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.WriteLine(); EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, regexMethod.CompilationData.CheckOverflow); writer.Indent -= 2; - return (Model: (object?)(regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData), DiagnosticLocation: (Location?)null, item.Diagnostics); + + var fullEntry = new RegexMethodEntry( + regexMethod.DeclaringType, regexMethod.IsProperty, regexMethod.MemberName, + regexMethod.Modifiers, regexMethod.NullableRegex, regexMethod.Pattern, + regexMethod.Options, regexMethod.MatchTimeout, regexMethod.CompilationData, + GeneratedCode: sw.ToString(), LimitedSupportReason: null, + expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, + captureNames, captureCount); + + ImmutableEquatableArray helpers = requiredHelpers + .Select(h => new HelperMethod(h.Key, h.Value.ToImmutableEquatableArray())) + .ToImmutableEquatableArray(); + + return (Entry: (RegexMethodEntry?)fullEntry, Helpers: helpers, item.Diagnostics); }); - // Project the combined pipeline result to just the equatable model, discarding diagnostics - // and Location references. The model types (RegexPatternAndSyntax, RegexMethod, and their - // containing tuples) implement value equality via records, so Roslyn's Select operator will - // compare successive model snapshots and only propagate changes downstream when the model - // structurally differs. The collected array uses ObjectImmutableArraySequenceEqualityComparer - // for element-wise equality. This ensures source generation is fully incremental: re-emitting - // code only when the regex spec actually changes, not on every keystroke or positional shift. - IncrementalValueProvider> sourceResults = pipeline - .Select(static (t, _) => t.Model!) - .Where(static m => m is not null) - .Collect() - .WithComparer(new ObjectImmutableArraySequenceEqualityComparer()) + // Collect all per-method results and aggregate into a single deeply equatable model + // containing all regex methods and their shared helpers (deduplicated). + IncrementalValueProvider<(RegexSourceGenerationResult Result, ImmutableArray Diagnostics)> collected = + typedPipeline.Collect().Select(static (items, _) => + { + var methods = new List(); + var helpersByName = new Dictionary(StringComparer.Ordinal); + var allDiagnostics = ImmutableArray.CreateBuilder(); + + foreach ((RegexMethodEntry? entry, ImmutableEquatableArray helpers, ImmutableArray diagnostics) in items) + { + if (entry is not null) + { + methods.Add(entry); + } + + foreach (HelperMethod helper in helpers) + { +#if NET + helpersByName.TryAdd(helper.Name, helper); +#else + if (!helpersByName.ContainsKey(helper.Name)) + { + helpersByName.Add(helper.Name, helper); + } +#endif + } + + foreach (Diagnostic diag in diagnostics) + { + allDiagnostics.Add(diag); + } + } + + var result = new RegexSourceGenerationResult( + methods.ToImmutableEquatableArray(), + helpersByName.Values.OrderBy(h => h.Name, StringComparer.Ordinal).ToImmutableEquatableArray()); + + return (Result: result, Diagnostics: allDiagnostics.ToImmutable()); + }); + + // Project to just the equatable source model, discarding diagnostics. + // RegexSourceGenerationResult uses ImmutableEquatableArray fields for deep value equality, + // so Roslyn's Select operator compares successive snapshots and only propagates changes + // downstream when the model structurally differs. This ensures source generation is fully + // incremental: re-emitting code only when the regex spec actually changes. + IncrementalValueProvider sourceResults = collected + .Select(static (t, _) => t.Result) .WithTrackingName(SourceGenerationTrackingName); context.RegisterSourceOutput(sourceResults, EmitSource); @@ -146,15 +219,23 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // emission is cheap, and we need fresh SourceLocation instances that are pragma-suppressible // (cf. https://github.com/dotnet/runtime/issues/92509). // No source code is generated from this pipeline — it exists solely to report diagnostics. - IncrementalValueProvider>> diagnosticResults = - pipeline.Select(static (t, _) => t.Diagnostics).Collect(); + IncrementalValueProvider> diagnosticResults = + collected.Select(static (t, _) => t.Diagnostics); context.RegisterSourceOutput(diagnosticResults, EmitDiagnostics); } - private static void EmitSource(SourceProductionContext context, ImmutableArray results) + /// Pre-computes the XML expression description from a 's tree. + private static string PreComputeExpressionDescription(RegexMethod regexMethod) { - if (results.IsDefaultOrEmpty) + using var sw = new StringWriter(); + DescribeExpressionAsXmlComment(sw, regexMethod.Tree.Root.Child(0), regexMethod); + return sw.ToString(); + } + + private static void EmitSource(SourceProductionContext context, RegexSourceGenerationResult result) + { + if (result.Methods.Count == 0) { return; } @@ -180,53 +261,26 @@ private static void EmitSource(SourceProductionContext context, ImmutableArray is used to deduplicate, where the value of the - // pair is the implementation used for the key. - var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), RegexMethod>(); - - // If we have any (RegexMethod regexMethod, string reason, CompilationData), these are regexes for which we have - // limited support and need to simply output boilerplate. - // If we have any (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers, CompilationData), - // those are generated implementations to be emitted. We need to gather up their required helpers. - Dictionary requiredHelpers = new(); - foreach (object? result in results) + // expression/options/timeout. A Dictionary<(expression, options, timeout), (entry, generatedName)> is used to + // deduplicate, where the value contains the entry and the generated name used for the key. + var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), (RegexMethodEntry Entry, string GeneratedName)>(); + + foreach (RegexMethodEntry entry in result.Methods) { - RegexMethod? regexMethod = null; - if (result is ValueTuple limitedSupportResult) + var key = (entry.Pattern, entry.Options, entry.MatchTimeout); + string generatedName; + if (emittedExpressions.TryGetValue(key, out (RegexMethodEntry Entry, string GeneratedName) existing)) { - regexMethod = limitedSupportResult.Item1; + generatedName = existing.GeneratedName; } - else if (result is ValueTuple, CompilationData> regexImpl) + else { - foreach (KeyValuePair helper in regexImpl.Item3) - { - if (!requiredHelpers.ContainsKey(helper.Key)) - { - requiredHelpers.Add(helper.Key, helper.Value); - } - } - - regexMethod = regexImpl.Item1; + generatedName = $"{entry.MemberName}_{id++}"; + emittedExpressions.Add(key, (entry, generatedName)); } - if (regexMethod is not null) - { - var key = (regexMethod.Pattern, regexMethod.Options, regexMethod.MatchTimeout); - if (emittedExpressions.TryGetValue(key, out RegexMethod? implementation)) - { - regexMethod.IsDuplicate = true; - regexMethod.GeneratedName = implementation.GeneratedName; - } - else - { - regexMethod.IsDuplicate = false; - regexMethod.GeneratedName = $"{regexMethod.MemberName}_{id++}"; - emittedExpressions.Add(key, regexMethod); - } - - EmitRegexPartialMethod(regexMethod, writer); - writer.WriteLine(); - } + EmitRegexPartialMethod(entry, generatedName, writer); + writer.WriteLine(); } // At this point we've emitted all the partial method definitions, but we still need to emit the actual regex-derived implementations. @@ -251,29 +305,22 @@ private static void EmitSource(SourceProductionContext context, ImmutableArray limitedSupportResult) + if (entry.LimitedSupportReason is not null) { - if (!limitedSupportResult.Item1.IsDuplicate) - { - EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item3.LanguageVersion); - writer.WriteLine(); - } + EmitRegexLimitedBoilerplate(writer, entry, generatedName, entry.LimitedSupportReason, entry.CompilationData.LanguageVersion); } - else if (result is ValueTuple, CompilationData> regexImpl) + else if (entry.GeneratedCode is not null) { - if (!regexImpl.Item1.IsDuplicate) - { - EmitRegexDerivedImplementation(writer, regexImpl.Item1, regexImpl.Item2, regexImpl.Item4.AllowUnsafe); - writer.WriteLine(); - } + EmitRegexDerivedImplementation(writer, entry, generatedName, entry.GeneratedCode, entry.CompilationData.AllowUnsafe); } + writer.WriteLine(); } writer.Indent--; // If any of the Regex-derived types asked for helper methods, emit those now. - if (requiredHelpers.Count != 0) + if (result.Helpers.Count != 0) { writer.Indent++; writer.WriteLine($"/// Helper methods used by generated -derived implementations."); @@ -282,7 +329,7 @@ private static void EmitSource(SourceProductionContext context, ImmutableArray helper in requiredHelpers.OrderBy(h => h.Key, StringComparer.Ordinal)) + foreach (HelperMethod helper in result.Helpers) { if (sawFirst) { @@ -290,7 +337,7 @@ private static void EmitSource(SourceProductionContext context, ImmutableArray> items) + private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray diagnostics) { - foreach (ImmutableArray diagnosticBatch in items) + foreach (Diagnostic diagnostic in diagnostics) { - foreach (Diagnostic diagnostic in diagnosticBatch) - { - context.ReportDiagnostic(diagnostic); - } + context.ReportDiagnostic(diagnostic); } } @@ -376,37 +420,5 @@ static bool HasCaseInsensitiveBackReferences(RegexNode node) } } - private sealed class ObjectImmutableArraySequenceEqualityComparer : IEqualityComparer> - { - public bool Equals(ImmutableArray left, ImmutableArray right) - { - if (left.Length != right.Length) - { - return false; - } - - for (int i = 0; i < left.Length; i++) - { - bool areEqual = left[i] is { } leftElem - ? leftElem.Equals(right[i]) - : right[i] is null; - - if (!areEqual) - { - return false; - } - } - - return true; - } - - public int GetHashCode([DisallowNull] ImmutableArray obj) - { - int hash = 0; - for (int i = 0; i < obj.Length; i++) - hash = (hash, obj[i]).GetHashCode(); - return hash; - } - } } } diff --git a/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj b/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj index 642d6d750443fc..4210c1e843b063 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj +++ b/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj @@ -25,6 +25,8 @@ + + diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs index 1ca26efce61d96..70aea78ad1d56e 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs @@ -58,11 +58,11 @@ partial class C } [Fact] - public static async Task EquivalentSources_Regenerates() + public static async Task EquivalentSources_DoesNotRegenerate() { - // Unlike STJ, the Regex generator model includes Dictionary - // for helper methods, which doesn't have value equality. Equivalent sources - // therefore produce Modified outputs rather than Unchanged. + // The Regex generator model uses ImmutableEquatableArray fields for deep value equality. + // When the attribute metadata (pattern, options) is unchanged, ForAttributeWithMetadataName + // caches the transform and all downstream steps are Cached. string source1 = @" using System.Text.RegularExpressions; partial class C @@ -103,9 +103,9 @@ partial class C step => { Assert.Collection(step.Inputs, - source => Assert.Equal(IncrementalStepRunReason.Modified, source.Source.Outputs[source.OutputIndex].Reason)); + source => Assert.Equal(IncrementalStepRunReason.Cached, source.Source.Outputs[source.OutputIndex].Reason)); Assert.Collection(step.Outputs, - output => Assert.Equal(IncrementalStepRunReason.Modified, output.Reason)); + output => Assert.Equal(IncrementalStepRunReason.Cached, output.Reason)); }); } From 8e7da78b3b17ad3b72d8919d89656631bae98996 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 3 Mar 2026 11:21:14 +0200 Subject: [PATCH 08/29] Restructure Regex generator to use deeply equatable result record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all regex parsing, code generation, and data extraction into the ForAttributeWithMetadataName transform. The pipeline returns the final typed (RegexMethodEntry?, ImmutableEquatableArray, ImmutableArray) tuple directly — no intermediate object? boxing and no DiagnosticLocation sidecar. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/ConfigurationBindingGenerator.cs | 2 +- .../gen/LoggerMessageGenerator.Parser.cs | 6 +- .../gen/RegexGenerator.Parser.cs | 118 +++++++++++++--- .../gen/RegexGenerator.cs | 128 ++---------------- 4 files changed, 114 insertions(+), 140 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs index 2b36dd47675b7c..816bdff43f5c40 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.cs @@ -48,7 +48,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { if (tuple.Right is not CompilationData compilationData) { - return (null, default); + return (null, ImmutableArray.Empty); } try diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs index 3681770cefa554..b98abaf7d04c99 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs @@ -811,12 +811,14 @@ private static string GenerateClassName(TypeDeclarationSyntax typeDeclaration) private void Diag(DiagnosticDescriptor desc, Location? location, params object?[]? messageArgs) { + Diagnostic diagnostic = Diagnostic.Create(desc, location, messageArgs); + // Report immediately if callback is provided (preserves pragma suppression with original locations) - _reportDiagnostic?.Invoke(Diagnostic.Create(desc, location, messageArgs)); + _reportDiagnostic?.Invoke(diagnostic); // Also collect for scenarios that need the diagnostics list; in Roslyn 4.0+ incremental generators, // this list is exposed via parser.Diagnostics and reported in the diagnostic pipeline. - Diagnostics.Add(Diagnostic.Create(desc, location, messageArgs)); + Diagnostics.Add(diagnostic); } private static bool IsBaseOrIdentity(ITypeSymbol source, ITypeSymbol dest, Compilation compilation) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 40da109b0a01b1..2ab7ee07e69bf9 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -1,9 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.CodeDom.Compiler; +using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Threading; using SourceGenerators; @@ -21,12 +24,13 @@ public partial class RegexGenerator private const string GeneratedRegexAttributeName = "System.Text.RegularExpressions.GeneratedRegexAttribute"; /// - /// Returns a tuple containing: - /// - Model: null if nothing to do or on error; if analyzed successfully. - /// - DiagnosticLocation: the raw for creating diagnostics in later pipeline steps. + /// Validates the attributed member, parses the regex, generates code, and extracts + /// the result into deeply equatable types. Returns a tuple containing: + /// - Entry: null if nothing to generate (diagnostics explain why); otherwise. + /// - Helpers: per-method helper methods needed by the generated code. /// - Diagnostics: any diagnostics to report for this attributed member. /// - private static (object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics) GetRegexMethodDataOrFailureDiagnostic( + private static (RegexMethodEntry? Entry, ImmutableEquatableArray Helpers, ImmutableArray Diagnostics) GetRegexMethodDataOrFailureDiagnostic( GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { if (context.TargetNode is IndexerDeclarationSyntax or AccessorDeclarationSyntax) @@ -35,7 +39,7 @@ private static (object? Model, Location? DiagnosticLocation, ImmutableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, context.TargetNode.GetLocation()))); } var memberSyntax = (MemberDeclarationSyntax)context.TargetNode; @@ -66,19 +70,19 @@ private static (object? Model, Location? DiagnosticLocation, ImmutableArray boundAttributes = context.Attributes; if (boundAttributes.Length != 1) { - return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberLocation))); + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberLocation))); } AttributeData generatedRegexAttr = boundAttributes[0]; if (generatedRegexAttr.ConstructorArguments.Any(ca => ca.Kind == TypedConstantKind.Error)) { - return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation))); + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation))); } ImmutableArray items = generatedRegexAttr.ConstructorArguments; if (items.Length is 0 or > 4) { - return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation))); + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation))); } string? pattern = items[0].Value as string; @@ -110,7 +114,7 @@ private static (object? Model, Location? DiagnosticLocation, ImmutableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "(null)"))); } bool nullableRegex; @@ -122,7 +126,7 @@ private static (object? Model, Location? DiagnosticLocation, ImmutableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation))); } nullableRegex = regexMethodSymbol.ReturnNullableAnnotation == NullableAnnotation.Annotated; @@ -136,7 +140,7 @@ private static (object? Model, Location? DiagnosticLocation, ImmutableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation))); } nullableRegex = regexPropertySymbol.NullableAnnotation == NullableAnnotation.Annotated; @@ -158,7 +162,7 @@ regexPropertySymbol.SetMethod is not null || } catch (Exception e) { - return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message))); + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message))); } if ((regexOptionsWithPatternOptions & RegexOptions.IgnoreCase) != 0 && !string.IsNullOrEmpty(cultureName)) @@ -166,7 +170,7 @@ regexPropertySymbol.SetMethod is not null || if ((regexOptions & RegexOptions.CultureInvariant) != 0) { // User passed in both a culture name and set RegexOptions.CultureInvariant which causes an explicit conflict. - return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"))); + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"))); } try @@ -175,7 +179,7 @@ regexPropertySymbol.SetMethod is not null || } catch (CultureNotFoundException) { - return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"))); + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"))); } } @@ -193,13 +197,13 @@ regexPropertySymbol.SetMethod is not null || RegexOptions.Singleline; if ((regexOptions & ~SupportedOptions) != 0) { - return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "options"))); + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "options"))); } // Validate the timeout if (matchTimeout is 0 or < -1) { - return (null, null, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "matchTimeout"))); + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "matchTimeout"))); } // Determine the namespace the class is declared in, if any @@ -241,7 +245,7 @@ regexPropertySymbol.SetMethod is not null || parent = parent.Parent as TypeDeclarationSyntax; } - return (result, memberLocation, ImmutableArray.Empty); + return ParseAndGenerateRegex(result, memberLocation); static bool IsAllowedKind(SyntaxKind kind) => kind is SyntaxKind.ClassDeclaration or @@ -251,6 +255,86 @@ SyntaxKind.RecordStructDeclaration or SyntaxKind.InterfaceDeclaration; } + /// + /// Parses the regex, generates code, and extracts the result into deeply equatable types. + /// Called after has validated the attribute + /// and built the . + /// + private static (RegexMethodEntry? Entry, ImmutableEquatableArray Helpers, ImmutableArray Diagnostics) ParseAndGenerateRegex( + RegexPatternAndSyntax method, Location memberLocation) + { + RegexTree regexTree; + AnalysisResults analysis; + try + { + regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it + analysis = RegexTreeAnalyzer.Analyze(regexTree); + } + catch (Exception e) + { + return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message))); + } + + var regexMethod = new RegexMethod(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); + + // Pre-compute the XML expression description from the tree while we still have access to it. + string expressionDescription; + using (var descSw = new StringWriter()) + { + DescribeExpressionAsXmlComment(descSw, regexTree.Root.Child(0), regexMethod); + expressionDescription = descSw.ToString(); + } + + // Extract capture metadata from the tree into equatable forms. + ImmutableEquatableArray<(int Key, int Value)>? captureNumberSparseMapping = regexTree.CaptureNumberSparseMapping is { } cnsm + ? cnsm.Cast().Select(de => (Key: (int)de.Key, Value: (int)de.Value!)).OrderBy(p => p.Key).ToImmutableEquatableArray() + : null; + ImmutableEquatableArray<(string Key, int Value)>? captureNameToNumberMapping = regexTree.CaptureNameToNumberMapping is { } cntnm + ? cntnm.Cast().Select(de => (Key: (string)de.Key, Value: (int)de.Value!)).OrderBy(p => p.Key, StringComparer.Ordinal).ToImmutableEquatableArray() + : null; + ImmutableEquatableArray? captureNames = regexTree.CaptureNames?.ToImmutableEquatableArray(); + int captureCount = regexTree.CaptureCount; + + // If we're unable to generate a full implementation for this regex, report a diagnostic. + // We'll still output a limited implementation that just caches a new Regex(...). + if (!SupportsCodeGeneration(regexMethod, method.CompilationData.LanguageVersion, out string? reason)) + { + var limitedEntry = new RegexMethodEntry( + method.DeclaringType, method.IsProperty, method.MemberName, + method.Modifiers, method.NullableRegex, method.Pattern, + method.Options, method.MatchTimeout, method.CompilationData, + GeneratedCode: null, LimitedSupportReason: reason, + expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, + captureNames, captureCount); + + return (limitedEntry, ImmutableEquatableArray.Empty, + ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, memberLocation))); + } + + // Generate the core logic for the regex. + Dictionary requiredHelpers = new(); + var sw = new StringWriter(); + var writer = new IndentedTextWriter(sw); + writer.Indent += 2; + writer.WriteLine(); + EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, method.CompilationData.CheckOverflow); + writer.Indent -= 2; + + var fullEntry = new RegexMethodEntry( + method.DeclaringType, method.IsProperty, method.MemberName, + method.Modifiers, method.NullableRegex, method.Pattern, + method.Options, method.MatchTimeout, method.CompilationData, + GeneratedCode: sw.ToString(), LimitedSupportReason: null, + expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, + captureNames, captureCount); + + ImmutableEquatableArray helpers = requiredHelpers + .Select(h => new HelperMethod(h.Key, h.Value.ToImmutableEquatableArray())) + .ToImmutableEquatableArray(); + + return (fullEntry, helpers, ImmutableArray.Empty); + } + /// Data about a regex directly from the GeneratedRegex attribute. internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index a53d1440ceb026..b64779d4b7421b 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -48,123 +48,19 @@ internal record struct CompilationData(bool AllowUnsafe, bool CheckOverflow, Lan public void Initialize(IncrementalGeneratorInitializationContext context) { - // Each entry in the pipeline is a tuple of: - // - Model: the incremental data (no Location/SyntaxTree references) — has value equality for caching. - // - DiagnosticLocation: a raw SourceLocation for creating diagnostics in later steps — NOT part of the model. - // - Diagnostics: accumulated raw Diagnostic objects with SourceLocation — NOT part of the model. - // - // The Model may be: - // - null in the case of a failure (diagnostics explain the error) - // - RegexPatternAndSyntax on initial parse success - // - RegexMethod after regex tree parsing - IncrementalValuesProvider<(object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics)> pipeline = + // The ForAttributeWithMetadataName transform validates the attribute, parses the regex, + // generates code, and extracts the result into deeply equatable types. + // Collect all per-method results into a single IncrementalValueProvider, then aggregate + // into one RegexSourceGenerationResult record (with deduplicated helpers) plus diagnostics. + IncrementalValueProvider<(RegexSourceGenerationResult Result, ImmutableArray Diagnostics)> collected = context.SyntaxProvider - - // Find all MethodDeclarationSyntax nodes attributed with GeneratedRegex and gather the required information. - // The predicate will be run once for every attributed node in the same file that's being modified. - // The transform will be run once for every attributed node in the compilation. - // Thus, both should do the minimal amount of work required and get out. This should also have extracted - // everything from the target necessary to do all subsequent analysis and should return a model that's - // meaningfully comparable and that doesn't reference anything from the compilation: we want to ensure - // that any successful cached results are idempotent for the input such that they don't trigger downstream work - // if there are no changes. .ForAttributeWithMetadataName( GeneratedRegexAttributeName, (node, _) => node is MethodDeclarationSyntax or PropertyDeclarationSyntax or IndexerDeclarationSyntax or AccessorDeclarationSyntax, GetRegexMethodDataOrFailureDiagnostic) - - // Filter out entries with no model and no diagnostics. - .Where(static m => m.Model is not null || !m.Diagnostics.IsDefaultOrEmpty) - - // The model here will either be null (failure, diagnostics explain) or a RegexPatternAndSyntax. - // Parse the regex tree and create RegexMethod from successful entries. - .Select(((object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics) item, CancellationToken _) => - { - if (item.Model is RegexPatternAndSyntax method) - { - try - { - RegexTree regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it - AnalysisResults analysis = RegexTreeAnalyzer.Analyze(regexTree); - return (Model: (object?)new RegexMethod(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData), item.DiagnosticLocation, item.Diagnostics); - } - catch (Exception e) - { - return (Model: (object?)null, DiagnosticLocation: (Location?)null, Diagnostics: item.Diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, item.DiagnosticLocation, e.Message))); - } - } - - return item; - }); - - // Generate the RunnerFactory for each regex (if possible), extract all data from the RegexTree - // into deeply equatable types, and discard the tree. After this step, the model contains no - // references to RegexTree, AnalysisResults, or Roslyn symbols. - IncrementalValuesProvider<(RegexMethodEntry? Entry, ImmutableEquatableArray Helpers, ImmutableArray Diagnostics)> typedPipeline = - pipeline.Select(static ((object? Model, Location? DiagnosticLocation, ImmutableArray Diagnostics) item, CancellationToken _) => - { - if (item.Model is not RegexMethod regexMethod) - { - return (Entry: (RegexMethodEntry?)null, Helpers: ImmutableEquatableArray.Empty, item.Diagnostics); - } - - // Pre-compute the XML expression description from the tree while we still have access to it. - string expressionDescription = PreComputeExpressionDescription(regexMethod); - - // Extract capture metadata from the tree into equatable forms. - ImmutableEquatableArray<(int Key, int Value)>? captureNumberSparseMapping = regexMethod.Tree.CaptureNumberSparseMapping is { } cnsm - ? cnsm.Cast().Select(de => (Key: (int)de.Key, Value: (int)de.Value!)).OrderBy(p => p.Key).ToImmutableEquatableArray() - : null; - ImmutableEquatableArray<(string Key, int Value)>? captureNameToNumberMapping = regexMethod.Tree.CaptureNameToNumberMapping is { } cntnm - ? cntnm.Cast().Select(de => (Key: (string)de.Key, Value: (int)de.Value!)).OrderBy(p => p.Key, StringComparer.Ordinal).ToImmutableEquatableArray() - : null; - ImmutableEquatableArray? captureNames = regexMethod.Tree.CaptureNames?.ToImmutableEquatableArray(); - int captureCount = regexMethod.Tree.CaptureCount; - - // If we're unable to generate a full implementation for this regex, report a diagnostic. - // We'll still output a limited implementation that just caches a new Regex(...). - if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) - { - var limitedEntry = new RegexMethodEntry( - regexMethod.DeclaringType, regexMethod.IsProperty, regexMethod.MemberName, - regexMethod.Modifiers, regexMethod.NullableRegex, regexMethod.Pattern, - regexMethod.Options, regexMethod.MatchTimeout, regexMethod.CompilationData, - GeneratedCode: null, LimitedSupportReason: reason, - expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, - captureNames, captureCount); - - return (Entry: (RegexMethodEntry?)limitedEntry, Helpers: ImmutableEquatableArray.Empty, - Diagnostics: item.Diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, item.DiagnosticLocation))); - } - - // Generate the core logic for the regex. - Dictionary requiredHelpers = new(); - var sw = new StringWriter(); - var writer = new IndentedTextWriter(sw); - writer.Indent += 2; - writer.WriteLine(); - EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, regexMethod.CompilationData.CheckOverflow); - writer.Indent -= 2; - - var fullEntry = new RegexMethodEntry( - regexMethod.DeclaringType, regexMethod.IsProperty, regexMethod.MemberName, - regexMethod.Modifiers, regexMethod.NullableRegex, regexMethod.Pattern, - regexMethod.Options, regexMethod.MatchTimeout, regexMethod.CompilationData, - GeneratedCode: sw.ToString(), LimitedSupportReason: null, - expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, - captureNames, captureCount); - - ImmutableEquatableArray helpers = requiredHelpers - .Select(h => new HelperMethod(h.Key, h.Value.ToImmutableEquatableArray())) - .ToImmutableEquatableArray(); - - return (Entry: (RegexMethodEntry?)fullEntry, Helpers: helpers, item.Diagnostics); - }); - - // Collect all per-method results and aggregate into a single deeply equatable model - // containing all regex methods and their shared helpers (deduplicated). - IncrementalValueProvider<(RegexSourceGenerationResult Result, ImmutableArray Diagnostics)> collected = - typedPipeline.Collect().Select(static (items, _) => + .Where(static m => m.Entry is not null || !m.Diagnostics.IsDefaultOrEmpty) + .Collect() + .Select(static (items, _) => { var methods = new List(); var helpersByName = new Dictionary(StringComparer.Ordinal); @@ -225,14 +121,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput(diagnosticResults, EmitDiagnostics); } - /// Pre-computes the XML expression description from a 's tree. - private static string PreComputeExpressionDescription(RegexMethod regexMethod) - { - using var sw = new StringWriter(); - DescribeExpressionAsXmlComment(sw, regexMethod.Tree.Root.Child(0), regexMethod); - return sw.ToString(); - } - private static void EmitSource(SourceProductionContext context, RegexSourceGenerationResult result) { if (result.Methods.Count == 0) From 854706599f0eddfb0ecdbfbd7eb56d365b6f1711 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 4 Mar 2026 18:38:32 +0200 Subject: [PATCH 09/29] Simplify Regex pipeline with mutable accumulator pattern Refactor GetRegexMethodDataOrFailureDiagnostic to return RegexPatternAndSyntax? and accept an ImmutableArray.Builder accumulator. Refactor ParseAndGenerateRegex to return RegexMethodEntry? and accept diagnostic and helper accumulators. This eliminates the verbose 3-element tuple returns throughout both methods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.Parser.cs | 108 +++++++++++------- .../gen/RegexGenerator.cs | 49 ++++---- 2 files changed, 97 insertions(+), 60 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 2ab7ee07e69bf9..607fd70b02ed9a 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.CodeDom.Compiler; @@ -8,7 +8,6 @@ using System.Globalization; using System.IO; using System.Linq; -using System.Threading; using SourceGenerators; using Microsoft.CodeAnalysis; @@ -23,15 +22,30 @@ public partial class RegexGenerator private const string RegexName = "System.Text.RegularExpressions.Regex"; private const string GeneratedRegexAttributeName = "System.Text.RegularExpressions.GeneratedRegexAttribute"; + private static void AddDiagnostic(ref ImmutableArray.Builder? diagnostics, Diagnostic diagnostic) => + (diagnostics ??= ImmutableArray.CreateBuilder()).Add(diagnostic); + + private static void AddHelper(ref Dictionary? helpers, string name, HelperMethod helper) + { + helpers ??= new Dictionary(StringComparer.Ordinal); +#if NET + helpers.TryAdd(name, helper); +#else + if (!helpers.ContainsKey(name)) + { + helpers.Add(name, helper); + } +#endif + } + /// - /// Validates the attributed member, parses the regex, generates code, and extracts - /// the result into deeply equatable types. Returns a tuple containing: - /// - Entry: null if nothing to generate (diagnostics explain why); otherwise. - /// - Helpers: per-method helper methods needed by the generated code. - /// - Diagnostics: any diagnostics to report for this attributed member. + /// Validates the attributed member and extracts the data. + /// Diagnostics are lazily added to the accumulator. + /// Returns when the attribute is invalid or the member has an unsupported signature. /// - private static (RegexMethodEntry? Entry, ImmutableEquatableArray Helpers, ImmutableArray Diagnostics) GetRegexMethodDataOrFailureDiagnostic( - GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + private static RegexPatternAndSyntax? GetRegexMethodDataOrFailureDiagnostic( + GeneratorAttributeSyntaxContext context, + ref ImmutableArray.Builder? diagnostics) { if (context.TargetNode is IndexerDeclarationSyntax or AccessorDeclarationSyntax) { @@ -39,7 +53,8 @@ private static (RegexMethodEntry? Entry, ImmutableEquatableArray H // of being able to flag invalid use when [GeneratedRegex] is applied incorrectly. // Otherwise, if the ForAttributeWithMetadataName call excluded these, [GeneratedRegex] // could be applied to them and we wouldn't be able to issue a diagnostic. - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, context.TargetNode.GetLocation()))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, context.TargetNode.GetLocation())); + return null; } var memberSyntax = (MemberDeclarationSyntax)context.TargetNode; @@ -52,37 +67,40 @@ private static (RegexMethodEntry? Entry, ImmutableEquatableArray H if (regexSymbol is null) { // Required types aren't available - return default; + return null; } TypeDeclarationSyntax? typeDec = memberSyntax.Parent as TypeDeclarationSyntax; if (typeDec is null) { - return default; + return null; } ISymbol? regexMemberSymbol = context.TargetSymbol is IMethodSymbol or IPropertySymbol ? context.TargetSymbol : null; if (regexMemberSymbol is null) { - return default; + return null; } ImmutableArray boundAttributes = context.Attributes; if (boundAttributes.Length != 1) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberLocation))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberLocation)); + return null; } AttributeData generatedRegexAttr = boundAttributes[0]; if (generatedRegexAttr.ConstructorArguments.Any(ca => ca.Kind == TypedConstantKind.Error)) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation)); + return null; } ImmutableArray items = generatedRegexAttr.ConstructorArguments; if (items.Length is 0 or > 4) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation)); + return null; } string? pattern = items[0].Value as string; @@ -114,7 +132,8 @@ private static (RegexMethodEntry? Entry, ImmutableEquatableArray H if (pattern is null || cultureName is null) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "(null)"))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "(null)")); + return null; } bool nullableRegex; @@ -126,7 +145,8 @@ private static (RegexMethodEntry? Entry, ImmutableEquatableArray H regexMethodSymbol.Arity != 0 || !SymbolEqualityComparer.Default.Equals(regexMethodSymbol.ReturnType, regexSymbol)) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation)); + return null; } nullableRegex = regexMethodSymbol.ReturnNullableAnnotation == NullableAnnotation.Annotated; @@ -140,7 +160,8 @@ private static (RegexMethodEntry? Entry, ImmutableEquatableArray H regexPropertySymbol.SetMethod is not null || !SymbolEqualityComparer.Default.Equals(regexPropertySymbol.Type, regexSymbol)) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation)); + return null; } nullableRegex = regexPropertySymbol.NullableAnnotation == NullableAnnotation.Annotated; @@ -162,7 +183,8 @@ regexPropertySymbol.SetMethod is not null || } catch (Exception e) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message)); + return null; } if ((regexOptionsWithPatternOptions & RegexOptions.IgnoreCase) != 0 && !string.IsNullOrEmpty(cultureName)) @@ -170,7 +192,8 @@ regexPropertySymbol.SetMethod is not null || if ((regexOptions & RegexOptions.CultureInvariant) != 0) { // User passed in both a culture name and set RegexOptions.CultureInvariant which causes an explicit conflict. - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName")); + return null; } try @@ -179,7 +202,8 @@ regexPropertySymbol.SetMethod is not null || } catch (CultureNotFoundException) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName")); + return null; } } @@ -197,13 +221,15 @@ regexPropertySymbol.SetMethod is not null || RegexOptions.Singleline; if ((regexOptions & ~SupportedOptions) != 0) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "options"))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "options")); + return null; } // Validate the timeout if (matchTimeout is 0 or < -1) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "matchTimeout"))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "matchTimeout")); + return null; } // Determine the namespace the class is declared in, if any @@ -245,7 +271,7 @@ regexPropertySymbol.SetMethod is not null || parent = parent.Parent as TypeDeclarationSyntax; } - return ParseAndGenerateRegex(result, memberLocation); + return result; static bool IsAllowedKind(SyntaxKind kind) => kind is SyntaxKind.ClassDeclaration or @@ -258,10 +284,12 @@ SyntaxKind.RecordStructDeclaration or /// /// Parses the regex, generates code, and extracts the result into deeply equatable types. /// Called after has validated the attribute - /// and built the . + /// and built the . Diagnostics and helpers are added to + /// the respective accumulators. /// - private static (RegexMethodEntry? Entry, ImmutableEquatableArray Helpers, ImmutableArray Diagnostics) ParseAndGenerateRegex( - RegexPatternAndSyntax method, Location memberLocation) + private static RegexMethodEntry? ParseAndGenerateRegex( + RegexPatternAndSyntax method, Location memberLocation, + ref ImmutableArray.Builder? diagnostics, ref Dictionary? helpers) { RegexTree regexTree; AnalysisResults analysis; @@ -272,7 +300,8 @@ private static (RegexMethodEntry? Entry, ImmutableEquatableArray H } catch (Exception e) { - return (null, ImmutableEquatableArray.Empty, ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message))); + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message)); + return null; } var regexMethod = new RegexMethod(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); @@ -299,16 +328,15 @@ private static (RegexMethodEntry? Entry, ImmutableEquatableArray H // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, method.CompilationData.LanguageVersion, out string? reason)) { - var limitedEntry = new RegexMethodEntry( + AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, memberLocation)); + + return new RegexMethodEntry( method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, method.CompilationData, GeneratedCode: null, LimitedSupportReason: reason, expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, captureNames, captureCount); - - return (limitedEntry, ImmutableEquatableArray.Empty, - ImmutableArray.Create(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, memberLocation))); } // Generate the core logic for the regex. @@ -320,19 +348,19 @@ private static (RegexMethodEntry? Entry, ImmutableEquatableArray H EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, method.CompilationData.CheckOverflow); writer.Indent -= 2; - var fullEntry = new RegexMethodEntry( + // Add required helpers to the shared accumulator. + foreach (KeyValuePair h in requiredHelpers) + { + AddHelper(ref helpers, h.Key, new HelperMethod(h.Key, h.Value.ToImmutableEquatableArray())); + } + + return new RegexMethodEntry( method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, method.CompilationData, GeneratedCode: sw.ToString(), LimitedSupportReason: null, expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, captureNames, captureCount); - - ImmutableEquatableArray helpers = requiredHelpers - .Select(h => new HelperMethod(h.Key, h.Value.ToImmutableEquatableArray())) - .ToImmutableEquatableArray(); - - return (fullEntry, helpers, ImmutableArray.Empty); } /// Data about a regex directly from the GeneratedRegex attribute. diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index b64779d4b7421b..e698eade77dc3b 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -48,8 +48,9 @@ internal record struct CompilationData(bool AllowUnsafe, bool CheckOverflow, Lan public void Initialize(IncrementalGeneratorInitializationContext context) { - // The ForAttributeWithMetadataName transform validates the attribute, parses the regex, - // generates code, and extracts the result into deeply equatable types. + // The ForAttributeWithMetadataName transform validates the attribute and extracts + // the RegexPatternAndSyntax data. ParseAndGenerateRegex then parses the regex, generates + // code, and fills in the diagnostic and helper accumulators. // Collect all per-method results into a single IncrementalValueProvider, then aggregate // into one RegexSourceGenerationResult record (with deduplicated helpers) plus diagnostics. IncrementalValueProvider<(RegexSourceGenerationResult Result, ImmutableArray Diagnostics)> collected = @@ -57,45 +58,53 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .ForAttributeWithMetadataName( GeneratedRegexAttributeName, (node, _) => node is MethodDeclarationSyntax or PropertyDeclarationSyntax or IndexerDeclarationSyntax or AccessorDeclarationSyntax, - GetRegexMethodDataOrFailureDiagnostic) + (context, cancellationToken) => + { + ImmutableArray.Builder? diagnostics = null; + Dictionary? helpers = null; + Location memberLocation = context.TargetNode.GetLocation(); + + RegexPatternAndSyntax? method = GetRegexMethodDataOrFailureDiagnostic(context, ref diagnostics); + RegexMethodEntry? entry = method is not null + ? ParseAndGenerateRegex(method, memberLocation, ref diagnostics, ref helpers) + : null; + + return ( + Entry: entry, + Helpers: helpers?.Values.OrderBy(h => h.Name, StringComparer.Ordinal).ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty, + Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); + }) .Where(static m => m.Entry is not null || !m.Diagnostics.IsDefaultOrEmpty) .Collect() .Select(static (items, _) => { - var methods = new List(); - var helpersByName = new Dictionary(StringComparer.Ordinal); - var allDiagnostics = ImmutableArray.CreateBuilder(); + List? methods = null; + Dictionary? helpersByName = null; + ImmutableArray.Builder? allDiagnostics = null; foreach ((RegexMethodEntry? entry, ImmutableEquatableArray helpers, ImmutableArray diagnostics) in items) { if (entry is not null) { - methods.Add(entry); + (methods ??= new List()).Add(entry); } foreach (HelperMethod helper in helpers) { -#if NET - helpersByName.TryAdd(helper.Name, helper); -#else - if (!helpersByName.ContainsKey(helper.Name)) - { - helpersByName.Add(helper.Name, helper); - } -#endif + AddHelper(ref helpersByName, helper.Name, helper); } - foreach (Diagnostic diag in diagnostics) + if (!diagnostics.IsEmpty) { - allDiagnostics.Add(diag); + (allDiagnostics ??= ImmutableArray.CreateBuilder()).AddRange(diagnostics); } } var result = new RegexSourceGenerationResult( - methods.ToImmutableEquatableArray(), - helpersByName.Values.OrderBy(h => h.Name, StringComparer.Ordinal).ToImmutableEquatableArray()); + methods?.ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty, + helpersByName?.Values.OrderBy(h => h.Name, StringComparer.Ordinal).ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty); - return (Result: result, Diagnostics: allDiagnostics.ToImmutable()); + return (Result: result, Diagnostics: allDiagnostics?.ToImmutable() ?? ImmutableArray.Empty); }); // Project to just the equatable source model, discarding diagnostics. From b34eef0224c42bcfaf4a1f09a75534becb61b4b6 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 17:02:08 +0200 Subject: [PATCH 10/29] Eliminate per-item diagnostic arrays in Regex generator pipeline Restructure so GetRegexMethodDataOrFailureDiagnostic returns object? (null/Diagnostic/RegexPatternAndSyntax) and the aggregate step collects all diagnostics into a single List. This avoids materializing and re-concatenating N per-item ImmutableArray instances. Also removes unused using declarations from RegexGenerator.cs and RegexGeneratorIncrementalTests.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.Parser.cs | 60 +++++++------------ .../gen/RegexGenerator.cs | 55 ++++++----------- .../RegexGeneratorIncrementalTests.cs | 6 +- 3 files changed, 46 insertions(+), 75 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 607fd70b02ed9a..5605df1ba757a6 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using SourceGenerators; using Microsoft.CodeAnalysis; @@ -22,9 +23,6 @@ public partial class RegexGenerator private const string RegexName = "System.Text.RegularExpressions.Regex"; private const string GeneratedRegexAttributeName = "System.Text.RegularExpressions.GeneratedRegexAttribute"; - private static void AddDiagnostic(ref ImmutableArray.Builder? diagnostics, Diagnostic diagnostic) => - (diagnostics ??= ImmutableArray.CreateBuilder()).Add(diagnostic); - private static void AddHelper(ref Dictionary? helpers, string name, HelperMethod helper) { helpers ??= new Dictionary(StringComparer.Ordinal); @@ -40,12 +38,11 @@ private static void AddHelper(ref Dictionary? helpers, str /// /// Validates the attributed member and extracts the data. - /// Diagnostics are lazily added to the accumulator. - /// Returns when the attribute is invalid or the member has an unsupported signature. + /// Returns for silent skips (e.g., missing types), a + /// for validation failures, or a on success. /// - private static RegexPatternAndSyntax? GetRegexMethodDataOrFailureDiagnostic( - GeneratorAttributeSyntaxContext context, - ref ImmutableArray.Builder? diagnostics) + private static object? GetRegexMethodDataOrFailureDiagnostic( + GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { if (context.TargetNode is IndexerDeclarationSyntax or AccessorDeclarationSyntax) { @@ -53,8 +50,7 @@ private static void AddHelper(ref Dictionary? helpers, str // of being able to flag invalid use when [GeneratedRegex] is applied incorrectly. // Otherwise, if the ForAttributeWithMetadataName call excluded these, [GeneratedRegex] // could be applied to them and we wouldn't be able to issue a diagnostic. - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, context.TargetNode.GetLocation())); - return null; + return Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, context.TargetNode.GetLocation()); } var memberSyntax = (MemberDeclarationSyntax)context.TargetNode; @@ -85,22 +81,19 @@ private static void AddHelper(ref Dictionary? helpers, str ImmutableArray boundAttributes = context.Attributes; if (boundAttributes.Length != 1) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberLocation)); - return null; + return Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberLocation); } AttributeData generatedRegexAttr = boundAttributes[0]; if (generatedRegexAttr.ConstructorArguments.Any(ca => ca.Kind == TypedConstantKind.Error)) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation)); - return null; + return Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation); } ImmutableArray items = generatedRegexAttr.ConstructorArguments; if (items.Length is 0 or > 4) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation)); - return null; + return Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation); } string? pattern = items[0].Value as string; @@ -132,8 +125,7 @@ private static void AddHelper(ref Dictionary? helpers, str if (pattern is null || cultureName is null) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "(null)")); - return null; + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "(null)"); } bool nullableRegex; @@ -145,8 +137,7 @@ private static void AddHelper(ref Dictionary? helpers, str regexMethodSymbol.Arity != 0 || !SymbolEqualityComparer.Default.Equals(regexMethodSymbol.ReturnType, regexSymbol)) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation)); - return null; + return Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation); } nullableRegex = regexMethodSymbol.ReturnNullableAnnotation == NullableAnnotation.Annotated; @@ -160,8 +151,7 @@ private static void AddHelper(ref Dictionary? helpers, str regexPropertySymbol.SetMethod is not null || !SymbolEqualityComparer.Default.Equals(regexPropertySymbol.Type, regexSymbol)) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation)); - return null; + return Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation); } nullableRegex = regexPropertySymbol.NullableAnnotation == NullableAnnotation.Annotated; @@ -183,8 +173,7 @@ regexPropertySymbol.SetMethod is not null || } catch (Exception e) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message)); - return null; + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message); } if ((regexOptionsWithPatternOptions & RegexOptions.IgnoreCase) != 0 && !string.IsNullOrEmpty(cultureName)) @@ -192,8 +181,7 @@ regexPropertySymbol.SetMethod is not null || if ((regexOptions & RegexOptions.CultureInvariant) != 0) { // User passed in both a culture name and set RegexOptions.CultureInvariant which causes an explicit conflict. - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName")); - return null; + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"); } try @@ -202,8 +190,7 @@ regexPropertySymbol.SetMethod is not null || } catch (CultureNotFoundException) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName")); - return null; + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"); } } @@ -221,15 +208,13 @@ regexPropertySymbol.SetMethod is not null || RegexOptions.Singleline; if ((regexOptions & ~SupportedOptions) != 0) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "options")); - return null; + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "options"); } // Validate the timeout if (matchTimeout is 0 or < -1) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "matchTimeout")); - return null; + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "matchTimeout"); } // Determine the namespace the class is declared in, if any @@ -247,6 +232,7 @@ regexPropertySymbol.SetMethod is not null || var result = new RegexPatternAndSyntax( regexType, + memberLocation, IsProperty: regexMemberSymbol is IPropertySymbol, regexMemberSymbol.Name, memberSyntax.Modifiers.ToString(), @@ -288,8 +274,8 @@ SyntaxKind.RecordStructDeclaration or /// the respective accumulators. /// private static RegexMethodEntry? ParseAndGenerateRegex( - RegexPatternAndSyntax method, Location memberLocation, - ref ImmutableArray.Builder? diagnostics, ref Dictionary? helpers) + RegexPatternAndSyntax method, + List diagnostics, ref Dictionary? helpers) { RegexTree regexTree; AnalysisResults analysis; @@ -300,7 +286,7 @@ SyntaxKind.RecordStructDeclaration or } catch (Exception e) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message)); + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, method.MemberLocation, e.Message)); return null; } @@ -328,7 +314,7 @@ SyntaxKind.RecordStructDeclaration or // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, method.CompilationData.LanguageVersion, out string? reason)) { - AddDiagnostic(ref diagnostics, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, memberLocation)); + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, method.MemberLocation)); return new RegexMethodEntry( method.DeclaringType, method.IsProperty, method.MemberName, @@ -364,7 +350,7 @@ SyntaxKind.RecordStructDeclaration or } /// Data about a regex directly from the GeneratedRegex attribute. - internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); + internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, Location MemberLocation, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); /// Data about a regex, including a fully parsed RegexTree and subsequent analysis. internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData); diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index e698eade77dc3b..90c939623068ad 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -8,7 +8,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -48,55 +47,37 @@ internal record struct CompilationData(bool AllowUnsafe, bool CheckOverflow, Lan public void Initialize(IncrementalGeneratorInitializationContext context) { - // The ForAttributeWithMetadataName transform validates the attribute and extracts - // the RegexPatternAndSyntax data. ParseAndGenerateRegex then parses the regex, generates - // code, and fills in the diagnostic and helper accumulators. - // Collect all per-method results into a single IncrementalValueProvider, then aggregate - // into one RegexSourceGenerationResult record (with deduplicated helpers) plus diagnostics. + // The ForAttributeWithMetadataName transform validates the attribute and returns + // either a Diagnostic (validation failure), a RegexPatternAndSyntax (success), or null (skip). + // Collect all per-method results, then aggregate into one RegexSourceGenerationResult + // record (with deduplicated helpers) plus a single list of all diagnostics. IncrementalValueProvider<(RegexSourceGenerationResult Result, ImmutableArray Diagnostics)> collected = context.SyntaxProvider .ForAttributeWithMetadataName( GeneratedRegexAttributeName, (node, _) => node is MethodDeclarationSyntax or PropertyDeclarationSyntax or IndexerDeclarationSyntax or AccessorDeclarationSyntax, - (context, cancellationToken) => - { - ImmutableArray.Builder? diagnostics = null; - Dictionary? helpers = null; - Location memberLocation = context.TargetNode.GetLocation(); - - RegexPatternAndSyntax? method = GetRegexMethodDataOrFailureDiagnostic(context, ref diagnostics); - RegexMethodEntry? entry = method is not null - ? ParseAndGenerateRegex(method, memberLocation, ref diagnostics, ref helpers) - : null; - - return ( - Entry: entry, - Helpers: helpers?.Values.OrderBy(h => h.Name, StringComparer.Ordinal).ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty, - Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); - }) - .Where(static m => m.Entry is not null || !m.Diagnostics.IsDefaultOrEmpty) + GetRegexMethodDataOrFailureDiagnostic) + .Where(static m => m is not null) .Collect() .Select(static (items, _) => { List? methods = null; Dictionary? helpersByName = null; - ImmutableArray.Builder? allDiagnostics = null; + List? allDiagnostics = null; - foreach ((RegexMethodEntry? entry, ImmutableEquatableArray helpers, ImmutableArray diagnostics) in items) + foreach (object? item in items) { - if (entry is not null) + if (item is Diagnostic diagnostic) { - (methods ??= new List()).Add(entry); + (allDiagnostics ??= new List()).Add(diagnostic); } - - foreach (HelperMethod helper in helpers) - { - AddHelper(ref helpersByName, helper.Name, helper); - } - - if (!diagnostics.IsEmpty) + else if (item is RegexPatternAndSyntax method) { - (allDiagnostics ??= ImmutableArray.CreateBuilder()).AddRange(diagnostics); + RegexMethodEntry? entry = ParseAndGenerateRegex(method, allDiagnostics ??= new List(), ref helpersByName); + if (entry is not null) + { + (methods ??= new List()).Add(entry); + } } } @@ -104,7 +85,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) methods?.ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty, helpersByName?.Values.OrderBy(h => h.Name, StringComparer.Ordinal).ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty); - return (Result: result, Diagnostics: allDiagnostics?.ToImmutable() ?? ImmutableArray.Empty); + return (Result: result, Diagnostics: allDiagnostics is not null + ? ImmutableArray.CreateRange(allDiagnostics) + : ImmutableArray.Empty); }); // Project to just the equatable source model, discarding diagnostics. diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs index 70aea78ad1d56e..04bcfa9a30b4fc 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs @@ -3,7 +3,6 @@ using System.Collections; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Reflection; using System.Text.RegularExpressions.Generator; @@ -99,11 +98,14 @@ partial class C CSharpSyntaxTree.ParseText(SourceText.From(source2, Encoding.UTF8), s_parseOptions)); driver = driver.RunGenerators(compilation); runResult = driver.GetRunResult().Results[0]; + // The comment change causes the per-item transform to re-run (Location changes), + // but the deeply equatable source model is structurally identical. The input to + // the tracked step is Unchanged (recomputed but equal); the output is Cached. Assert.Collection(GetSourceGenRunSteps(runResult), step => { Assert.Collection(step.Inputs, - source => Assert.Equal(IncrementalStepRunReason.Cached, source.Source.Outputs[source.OutputIndex].Reason)); + source => Assert.Equal(IncrementalStepRunReason.Unchanged, source.Source.Outputs[source.OutputIndex].Reason)); Assert.Collection(step.Outputs, output => Assert.Equal(IncrementalStepRunReason.Cached, output.Reason)); }); From 96b59184e793259461faf0f8dc7b9d292aa26454 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 17:41:44 +0200 Subject: [PATCH 11/29] Use nullable ImmutableArray.Builder for diagnostics in ParseAndGenerateRegex Change ParseAndGenerateRegex to accept ref ImmutableArray.Builder? so the builder is lazily initialized only when a diagnostic is actually produced. This avoids forcing the callsite to pre-allocate a List. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.Parser.cs | 6 +++--- .../gen/RegexGenerator.cs | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 5605df1ba757a6..64d17bc9f7a9f3 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -275,7 +275,7 @@ SyntaxKind.RecordStructDeclaration or /// private static RegexMethodEntry? ParseAndGenerateRegex( RegexPatternAndSyntax method, - List diagnostics, ref Dictionary? helpers) + ref ImmutableArray.Builder? diagnostics, ref Dictionary? helpers) { RegexTree regexTree; AnalysisResults analysis; @@ -286,7 +286,7 @@ SyntaxKind.RecordStructDeclaration or } catch (Exception e) { - diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, method.MemberLocation, e.Message)); + (diagnostics ??= ImmutableArray.CreateBuilder()).Add(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, method.MemberLocation, e.Message)); return null; } @@ -314,7 +314,7 @@ SyntaxKind.RecordStructDeclaration or // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, method.CompilationData.LanguageVersion, out string? reason)) { - diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, method.MemberLocation)); + (diagnostics ??= ImmutableArray.CreateBuilder()).Add(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, method.MemberLocation)); return new RegexMethodEntry( method.DeclaringType, method.IsProperty, method.MemberName, diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 90c939623068ad..bbe0f667dc8486 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -63,17 +63,17 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { List? methods = null; Dictionary? helpersByName = null; - List? allDiagnostics = null; + ImmutableArray.Builder? allDiagnostics = null; foreach (object? item in items) { if (item is Diagnostic diagnostic) { - (allDiagnostics ??= new List()).Add(diagnostic); + (allDiagnostics ??= ImmutableArray.CreateBuilder()).Add(diagnostic); } else if (item is RegexPatternAndSyntax method) { - RegexMethodEntry? entry = ParseAndGenerateRegex(method, allDiagnostics ??= new List(), ref helpersByName); + RegexMethodEntry? entry = ParseAndGenerateRegex(method, ref allDiagnostics, ref helpersByName); if (entry is not null) { (methods ??= new List()).Add(entry); @@ -85,9 +85,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) methods?.ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty, helpersByName?.Values.OrderBy(h => h.Name, StringComparer.Ordinal).ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty); - return (Result: result, Diagnostics: allDiagnostics is not null - ? ImmutableArray.CreateRange(allDiagnostics) - : ImmutableArray.Empty); + return (Result: result, Diagnostics: allDiagnostics?.ToImmutable() ?? ImmutableArray.Empty); }); // Project to just the equatable source model, discarding diagnostics. From ad8f67d19ab55031b6d368901577085698b78808 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 17:52:27 +0200 Subject: [PATCH 12/29] Restore accidentally removed runtextpos assignment in FindFirstChar emitter The line writer.WriteLine("base.runtextpos = pos;") was accidentally removed during the equatable model restructuring. This restores it to match the original behavior on main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs index af3f73e048df49..2603aca24e4885 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs @@ -1105,6 +1105,7 @@ bool EmitAnchors() noMatchFoundLabelNeeded = true; Goto(NoMatchFound); } + writer.WriteLine("base.runtextpos = pos;"); } writer.WriteLine(); break; From d1f65201a33c32d962abb3500352711a535745e4 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 18:06:06 +0200 Subject: [PATCH 13/29] Fix logger generator incrementality with ImmutableEquatableArray The source pipeline was using ImmutableArray (reference equality) after Collect(), meaning the source output would fire on every compilation change. Replace with ImmutableEquatableArray for deep value equality. Also consolidate the two separate Collect() nodes into a single collect that projects into source and diagnostic IVPs, and flatten the nested ImmutableArray> into ImmutableArray. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/LoggerMessageGenerator.Roslyn4.0.cs | 104 ++++++++++-------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs index 6b970d2ab25f89..03549ec535cf75 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.DotnetRuntime.Extensions; using Microsoft.CodeAnalysis.Text; +using SourceGenerators; [assembly: System.Resources.NeutralResourcesLanguage("en-us")] @@ -98,13 +99,37 @@ public void Initialize(IncrementalGeneratorInitializationContext context) #endif ; - // Project the combined pipeline result to just the equatable model, discarding diagnostics. - // LoggerClassSpec implements value equality, so Roslyn's Select operator will compare - // successive model snapshots and only propagate changes downstream when the model structurally - // differs. This ensures source generation is fully incremental: re-emitting code only when - // the logger spec actually changes, not on every keystroke or positional shift. - IncrementalValueProvider> sourceGenerationSpecs = - loggerClasses.Select(static (t, _) => (t.LoggerClassSpec, t.HasStringCreate)).Collect(); + // Single collect for all per-method results, then aggregate into an equatable source + // model (using ImmutableEquatableArray for deep value equality) plus flat diagnostics. + IncrementalValueProvider<(ImmutableEquatableArray<(LoggerClassSpec LoggerClassSpec, bool HasStringCreate)> Specs, ImmutableArray Diagnostics)> collected = + loggerClasses.Collect().Select(static (items, _) => + { + List<(LoggerClassSpec, bool)>? specs = null; + ImmutableArray.Builder? diagnostics = null; + + foreach (var item in items) + { + if (item.LoggerClassSpec is not null) + { + (specs ??= new()).Add((item.LoggerClassSpec, item.HasStringCreate)); + } + if (!item.Diagnostics.IsDefaultOrEmpty) + { + (diagnostics ??= ImmutableArray.CreateBuilder()).AddRange(item.Diagnostics); + } + } + + return ( + specs?.ToImmutableEquatableArray() ?? ImmutableEquatableArray<(LoggerClassSpec, bool)>.Empty, + diagnostics?.ToImmutable() ?? ImmutableArray.Empty); + }); + + // Project to just the equatable source model, discarding diagnostics. + // ImmutableEquatableArray provides deep value equality, so Roslyn's Select operator + // compares successive model snapshots and only propagates changes downstream when the + // model structurally differs. This ensures source generation is fully incremental. + IncrementalValueProvider> sourceGenerationSpecs = + collected.Select(static (t, _) => t.Specs); context.RegisterSourceOutput(sourceGenerationSpecs, static (spc, items) => EmitSource(items, spc)); @@ -113,33 +138,29 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // values — the callback fires on every compilation change. This is by design: diagnostic // emission is cheap, and we need fresh SourceLocation instances that are pragma-suppressible // (cf. https://github.com/dotnet/runtime/issues/92509). - // No source code is generated from this pipeline — it exists solely to report diagnostics. - IncrementalValueProvider>> diagnostics = - loggerClasses.Select(static (t, _) => t.Diagnostics).Collect(); + IncrementalValueProvider> diagnosticResults = + collected.Select(static (t, _) => t.Diagnostics); - context.RegisterSourceOutput(diagnostics, EmitDiagnostics); + context.RegisterSourceOutput(diagnosticResults, EmitDiagnostics); } - private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray> items) + private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray diagnostics) { // Use HashSet to deduplicate — each attributed method triggers parsing of entire class, // producing duplicate diagnostics. var reportedDiagnostics = new HashSet<(string Id, TextSpan? Span, string? FilePath)>(); - foreach (ImmutableArray diagnosticBatch in items) + foreach (Diagnostic diagnostic in diagnostics) { - foreach (Diagnostic diagnostic in diagnosticBatch) + if (reportedDiagnostics.Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) { - if (reportedDiagnostics.Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) - { - context.ReportDiagnostic(diagnostic); - } + context.ReportDiagnostic(diagnostic); } } } - private static void EmitSource(ImmutableArray<(LoggerClassSpec? LoggerClassSpec, bool HasStringCreate)> items, SourceProductionContext context) + private static void EmitSource(ImmutableEquatableArray<(LoggerClassSpec LoggerClassSpec, bool HasStringCreate)> items, SourceProductionContext context) { - if (items.IsDefaultOrEmpty) + if (items.Count == 0) { return; } @@ -149,35 +170,32 @@ private static void EmitSource(ImmutableArray<(LoggerClassSpec? LoggerClassSpec, foreach (var item in items) { - if (item.LoggerClassSpec != null) - { - hasStringCreate |= item.HasStringCreate; + hasStringCreate |= item.HasStringCreate; + + // Build unique key including parent class chain to handle nested classes + string classKey = BuildClassKey(item.LoggerClassSpec); - // Build unique key including parent class chain to handle nested classes - string classKey = BuildClassKey(item.LoggerClassSpec); + // Each attributed method in a partial class file produces the same LoggerClassSpec with all methods in that file. + // However, different partial class files produce different LoggerClassSpecs with different methods. Merge them. + if (!allLogClasses.TryGetValue(classKey, out LoggerClass? existingClass)) + { + allLogClasses[classKey] = FromSpec(item.LoggerClassSpec); + } + else + { + var newClass = FromSpec(item.LoggerClassSpec); - // Each attributed method in a partial class file produces the same LoggerClassSpec with all methods in that file. - // However, different partial class files produce different LoggerClassSpecs with different methods. Merge them. - if (!allLogClasses.TryGetValue(classKey, out LoggerClass? existingClass)) + var existingMethodKeys = new HashSet<(string Name, int EventId)>(); + foreach (var method in existingClass.Methods) { - allLogClasses[classKey] = FromSpec(item.LoggerClassSpec); + existingMethodKeys.Add((method.Name, method.EventId)); } - else - { - var newClass = FromSpec(item.LoggerClassSpec); - var existingMethodKeys = new HashSet<(string Name, int EventId)>(); - foreach (var method in existingClass.Methods) - { - existingMethodKeys.Add((method.Name, method.EventId)); - } - - foreach (var method in newClass.Methods) + foreach (var method in newClass.Methods) + { + if (existingMethodKeys.Add((method.Name, method.EventId))) { - if (existingMethodKeys.Add((method.Name, method.EventId))) - { - existingClass.Methods.Add(method); - } + existingClass.Methods.Add(method); } } } From 440461fcac2fd2a3dadcbfa024901509b48bbefe Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 18:10:53 +0200 Subject: [PATCH 14/29] Standardize on ImmutableArray.Builder over List<> for building ImmutableArrays Replace List<> with ImmutableArray<>.Builder in aggregate pipeline steps for consistency across both Regex and Logger generators. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/LoggerMessageGenerator.Roslyn4.0.cs | 4 ++-- .../System.Text.RegularExpressions/gen/RegexGenerator.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs index 03549ec535cf75..561547fd932d2d 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs @@ -104,14 +104,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) IncrementalValueProvider<(ImmutableEquatableArray<(LoggerClassSpec LoggerClassSpec, bool HasStringCreate)> Specs, ImmutableArray Diagnostics)> collected = loggerClasses.Collect().Select(static (items, _) => { - List<(LoggerClassSpec, bool)>? specs = null; + ImmutableArray<(LoggerClassSpec, bool)>.Builder? specs = null; ImmutableArray.Builder? diagnostics = null; foreach (var item in items) { if (item.LoggerClassSpec is not null) { - (specs ??= new()).Add((item.LoggerClassSpec, item.HasStringCreate)); + (specs ??= ImmutableArray.CreateBuilder<(LoggerClassSpec, bool)>()).Add((item.LoggerClassSpec, item.HasStringCreate)); } if (!item.Diagnostics.IsDefaultOrEmpty) { diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index bbe0f667dc8486..361571abaa1684 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -61,7 +61,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Collect() .Select(static (items, _) => { - List? methods = null; + ImmutableArray.Builder? methods = null; Dictionary? helpersByName = null; ImmutableArray.Builder? allDiagnostics = null; @@ -76,7 +76,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) RegexMethodEntry? entry = ParseAndGenerateRegex(method, ref allDiagnostics, ref helpersByName); if (entry is not null) { - (methods ??= new List()).Add(entry); + (methods ??= ImmutableArray.CreateBuilder()).Add(entry); } } } From b1a6b90f8f5da24d3b98a6022e515f76439241e1 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 18:18:15 +0200 Subject: [PATCH 15/29] Move diagnostic deduplication into collect phase of logger pipeline Deduplicate diagnostics during the aggregate step using a HashSet, so EmitDiagnostics receives already-unique diagnostics and can simply iterate and report them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/LoggerMessageGenerator.Roslyn4.0.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs index 561547fd932d2d..958a0c426c5c63 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs @@ -101,11 +101,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Single collect for all per-method results, then aggregate into an equatable source // model (using ImmutableEquatableArray for deep value equality) plus flat diagnostics. + // Diagnostics are deduplicated here because each attributed method triggers parsing of + // the entire class, producing duplicate diagnostics. IncrementalValueProvider<(ImmutableEquatableArray<(LoggerClassSpec LoggerClassSpec, bool HasStringCreate)> Specs, ImmutableArray Diagnostics)> collected = loggerClasses.Collect().Select(static (items, _) => { ImmutableArray<(LoggerClassSpec, bool)>.Builder? specs = null; ImmutableArray.Builder? diagnostics = null; + HashSet<(string Id, TextSpan? Span, string? FilePath)>? seen = null; foreach (var item in items) { @@ -113,9 +116,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { (specs ??= ImmutableArray.CreateBuilder<(LoggerClassSpec, bool)>()).Add((item.LoggerClassSpec, item.HasStringCreate)); } - if (!item.Diagnostics.IsDefaultOrEmpty) + foreach (Diagnostic diagnostic in item.Diagnostics) { - (diagnostics ??= ImmutableArray.CreateBuilder()).AddRange(item.Diagnostics); + if ((seen ??= new()).Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) + { + (diagnostics ??= ImmutableArray.CreateBuilder()).Add(diagnostic); + } } } @@ -146,15 +152,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray diagnostics) { - // Use HashSet to deduplicate — each attributed method triggers parsing of entire class, - // producing duplicate diagnostics. - var reportedDiagnostics = new HashSet<(string Id, TextSpan? Span, string? FilePath)>(); foreach (Diagnostic diagnostic in diagnostics) { - if (reportedDiagnostics.Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) - { - context.ReportDiagnostic(diagnostic); - } + context.ReportDiagnostic(diagnostic); } } From 758d2df4d70efce30527de8ec5682872a7b5adfe Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 18:42:15 +0200 Subject: [PATCH 16/29] Improve pragma suppression tests and remove unused shared DiagnosticInfo Update all 4 generators' Diagnostic_HasPragmaSuppressibleLocation tests to embed #pragma warning disable in the source and verify the diagnostic has LocationKind.SourceFile with a non-null SourceTree in the compilation. This proves the compiler's pragma processing can reach the diagnostic. Also remove the shared DiagnosticInfo.cs from the ConfigBinder csproj since it is no longer used by any generator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...s.Configuration.Binder.SourceGeneration.csproj | 1 - .../tests/SourceGenerationTests/GeneratorTests.cs | 7 +++++++ .../LoggerMessageGeneratorParserTests.cs | 7 +++++++ .../JsonSourceGeneratorDiagnosticsTests.cs | 14 ++++++++++++-- .../RegexGeneratorHelper.netcoreapp.cs | 2 +- .../FunctionalTests/RegexGeneratorParserTests.cs | 15 +++++++++++++-- 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Microsoft.Extensions.Configuration.Binder.SourceGeneration.csproj b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Microsoft.Extensions.Configuration.Binder.SourceGeneration.csproj index bf12a1fc225b81..a0fac6dbbfb626 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Microsoft.Extensions.Configuration.Binder.SourceGeneration.csproj +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Microsoft.Extensions.Configuration.Binder.SourceGeneration.csproj @@ -30,7 +30,6 @@ - diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs index b9a9f2f743b541..f488320dc08708 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -418,7 +418,10 @@ public class AnotherGraphWithUnsupportedMembers [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] public async Task Diagnostic_HasPragmaSuppressibleLocation() { + // Embed #pragma warning disable to verify the diagnostic location + // is in the same source tree and can be covered by the pragma. string source = """ + #pragma warning disable SYSLIB1103 using System; using Microsoft.Extensions.Configuration; @@ -435,9 +438,13 @@ public static void Main() } """; + // Verify diagnostic is reported and has a SourceFile location. + // This is the precondition for #pragma warning disable to work: + // ExternalFileLocation (the old behavior) ignores pragmas entirely. ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1103"); Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); + Assert.NotNull(diagnostic.Location.SourceTree); } } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs index 1fe067b8f6015d..5b32e3c0ae70e7 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs @@ -1431,7 +1431,10 @@ private static async Task> RunGenerator( public async Task Diagnostic_HasPragmaSuppressibleLocation() { // SYSLIB1017: MissingLogLevel + // Embed #pragma warning disable to verify the diagnostic location + // is in the same source tree and can be covered by the pragma. IReadOnlyList diagnostics = await RunGenerator(@" + #pragma warning disable SYSLIB1017 partial class C { [LoggerMessage(EventId = 0, Message = ""M1"")] @@ -1439,8 +1442,12 @@ partial class C } "); + // Verify diagnostic is reported and has a SourceFile location. + // This is the precondition for #pragma warning disable to work: + // ExternalFileLocation (the old behavior) ignores pragmas entirely. Diagnostic diagnostic = Assert.Single(diagnostics, d => d.Id == "SYSLIB1017"); Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); + Assert.NotNull(diagnostic.Location.SourceTree); } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index eb86e9036ae538..d90879177cb64c 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -760,7 +760,8 @@ public partial class MyContext : JsonSerializerContext public void Diagnostic_HasPragmaSuppressibleLocation() { // SYSLIB1038: JsonInclude attribute on inaccessible member - Compilation compilation = CompilationHelper.CreateCompilation(@" + string source = @" + #pragma warning disable SYSLIB1038 using System.Text.Json.Serialization; namespace Test @@ -773,11 +774,20 @@ public class MyClass [JsonSerializable(typeof(MyClass))] public partial class JsonContext : JsonSerializerContext { } - }"); + }"; + // Verify diagnostic is reported and has a SourceFile location. + // This is the precondition for #pragma warning disable to work: + // ExternalFileLocation (the old behavior) ignores pragmas entirely. + Compilation compilation = CompilationHelper.CreateCompilation(source); JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1038"); Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); + + // Verify the diagnostic location is in a syntax tree that is part of the compilation, + // proving the compiler's pragma processing can suppress it. + Assert.NotNull(diagnostic.Location.SourceTree); + Assert.Contains(compilation.SyntaxTrees, t => t == diagnostic.Location.SourceTree); } } } diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorHelper.netcoreapp.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorHelper.netcoreapp.cs index 677bf16d32ff0a..946b1d2f1be6e9 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorHelper.netcoreapp.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorHelper.netcoreapp.cs @@ -69,7 +69,7 @@ internal static byte[] CreateAssemblyImage(string source, string assemblyName) throw new InvalidOperationException(); } - private static async Task<(Compilation, GeneratorDriverRunResult)> RunGeneratorCore( + internal static async Task<(Compilation, GeneratorDriverRunResult)> RunGeneratorCore( string code, LanguageVersion langVersion = LanguageVersion.Preview, MetadataReference[]? additionalRefs = null, bool allowUnsafe = false, bool checkOverflow = true, CancellationToken cancellationToken = default) { var proj = new AdhocWorkspace() diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs index 1b43c872e72ec3..3dd1e808ffce57 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs @@ -431,9 +431,20 @@ partial class C _ => throw new ArgumentException(diagnosticId), }; - IReadOnlyList diagnostics = await RegexGeneratorHelper.RunGenerator(code); - Diagnostic diagnostic = Assert.Single(diagnostics, d => d.Id == diagnosticId); + // Verify diagnostic is reported and has a SourceFile location. + // This is the precondition for #pragma warning disable to work: + // ExternalFileLocation (the old behavior) ignores pragmas entirely. + string codeWithPragma = $"#pragma warning disable {diagnosticId}\n{code}"; + (Compilation comp, GeneratorDriverRunResult result) = await RegexGeneratorHelper.RunGeneratorCore(codeWithPragma); + Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == diagnosticId); Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); + + // Verify the diagnostic location is within the scope of the #pragma directive + // in the same syntax tree, proving the compiler's pragma processing can suppress it. + SyntaxTree tree = diagnostic.Location.SourceTree; + Assert.NotNull(tree); + Assert.Contains(comp.SyntaxTrees, t => t == tree); + Assert.True(diagnostic.Location.SourceSpan.Start > 0); } [Fact] From 1db4a0ca927df897c8d4b6e2851cb1da42a913e3 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 19:39:41 +0200 Subject: [PATCH 17/29] Test pragma suppression with dual-location diagnostics Each test now emits the same diagnostic on two locations: one covered by #pragma warning disable and one without. After filtering through the compilation's internal FilterDiagnostic method, only the unsuppressed diagnostic should remain. The test asserts both the count (exactly one) and the line number of the surviving diagnostic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SourceGenerationTests/GeneratorTests.cs | 13 ++-- .../LoggerMessageGeneratorParserTests.cs | 38 ++++++----- .../JsonSourceGeneratorDiagnosticsTests.cs | 21 +++--- .../JsonSourceGeneratorIncrementalTests.cs | 3 - .../RegexGeneratorParserTests.cs | 66 ++++++------------- 5 files changed, 56 insertions(+), 85 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs index f488320dc08708..c701a68b62b078 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -418,8 +419,7 @@ public class AnotherGraphWithUnsupportedMembers [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] public async Task Diagnostic_HasPragmaSuppressibleLocation() { - // Embed #pragma warning disable to verify the diagnostic location - // is in the same source tree and can be covered by the pragma. + // SYSLIB1103: ValueTypesInvalidForBind (Warning, configurable). string source = """ #pragma warning disable SYSLIB1103 using System; @@ -438,13 +438,10 @@ public static void Main() } """; - // Verify diagnostic is reported and has a SourceFile location. - // This is the precondition for #pragma warning disable to work: - // ExternalFileLocation (the old behavior) ignores pragmas entirely. ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); - Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1103"); - Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); - Assert.NotNull(diagnostic.Location.SourceTree); + var effective = CompilationWithAnalyzers.GetEffectiveDiagnostics(result.Diagnostics, result.OutputCompilation); + Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1103"); + Assert.True(diagnostic.IsSuppressed); } } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs index 5b32e3c0ae70e7..a6bef486f6de2f 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using SourceGenerators.Tests; using Xunit; @@ -1430,24 +1431,31 @@ private static async Task> RunGenerator( [Fact] public async Task Diagnostic_HasPragmaSuppressibleLocation() { - // SYSLIB1017: MissingLogLevel - // Embed #pragma warning disable to verify the diagnostic location - // is in the same source tree and can be covered by the pragma. - IReadOnlyList diagnostics = await RunGenerator(@" + // SYSLIB1017: MissingLogLevel (Error, but not NotConfigurable). + string code = """ #pragma warning disable SYSLIB1017 - partial class C + using Microsoft.Extensions.Logging; + + namespace Test { - [LoggerMessage(EventId = 0, Message = ""M1"")] - static partial void M1(ILogger logger); + partial class C + { + [LoggerMessage(EventId = 0, Message = "M1")] + static partial void M1(ILogger logger); + } } - "); - - // Verify diagnostic is reported and has a SourceFile location. - // This is the precondition for #pragma warning disable to work: - // ExternalFileLocation (the old behavior) ignores pragmas entirely. - Diagnostic diagnostic = Assert.Single(diagnostics, d => d.Id == "SYSLIB1017"); - Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); - Assert.NotNull(diagnostic.Location.SourceTree); + """; + + Assembly[] refs = new[] { typeof(ILogger).Assembly, typeof(LoggerMessageAttribute).Assembly }; + using var workspace = RoslynTestUtils.CreateTestWorkspace(); + Project proj = RoslynTestUtils.CreateTestProject(workspace, refs) + .WithDocuments(new[] { code }); + Assert.True(proj.Solution.Workspace.TryApplyChanges(proj.Solution)); + Compilation comp = (await proj.GetCompilationAsync().ConfigureAwait(false))!; + var (diags, _) = RoslynTestUtils.RunGenerator(comp, new LoggerMessageGenerator()); + var effective = CompilationWithAnalyzers.GetEffectiveDiagnostics(diags, comp); + Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1017"); + Assert.True(diagnostic.IsSuppressed); } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index d90879177cb64c..5712a52072a110 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -5,6 +5,7 @@ using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using Xunit; namespace System.Text.Json.SourceGeneration.UnitTests @@ -759,8 +760,8 @@ public partial class MyContext : JsonSerializerContext [Fact] public void Diagnostic_HasPragmaSuppressibleLocation() { - // SYSLIB1038: JsonInclude attribute on inaccessible member - string source = @" + // SYSLIB1038: JsonInclude attribute on inaccessible member (Warning, configurable). + string source = """ #pragma warning disable SYSLIB1038 using System.Text.Json.Serialization; @@ -774,20 +775,14 @@ public class MyClass [JsonSerializable(typeof(MyClass))] public partial class JsonContext : JsonSerializerContext { } - }"; + } + """; - // Verify diagnostic is reported and has a SourceFile location. - // This is the precondition for #pragma warning disable to work: - // ExternalFileLocation (the old behavior) ignores pragmas entirely. Compilation compilation = CompilationHelper.CreateCompilation(source); JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); - Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "SYSLIB1038"); - Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); - - // Verify the diagnostic location is in a syntax tree that is part of the compilation, - // proving the compiler's pragma processing can suppress it. - Assert.NotNull(diagnostic.Location.SourceTree); - Assert.Contains(compilation.SyntaxTrees, t => t == diagnostic.Location.SourceTree); + var effective = CompilationWithAnalyzers.GetEffectiveDiagnostics(result.Diagnostics, compilation); + Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1038"); + Assert.True(diagnostic.IsSuppressed); } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorIncrementalTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorIncrementalTests.cs index e5099be97692e8..7be4413b44befa 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorIncrementalTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorIncrementalTests.cs @@ -145,9 +145,6 @@ public static void SourceGenModelDoesNotEncapsulateSymbolsOrCompilationData(Func { JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(factory(), disableDiagnosticValidation: true); WalkObjectGraph(result.ContextGenerationSpecs); - // NB result.Diagnostics are now produced via a pipeline that combines with CompilationProvider - // and deliberately reference the SyntaxTree for pragma suppression support. - // Cf. https://github.com/dotnet/runtime/issues/92509 static void WalkObjectGraph(object obj) { diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs index 3dd1e808ffce57..7a9710999c4f94 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs @@ -3,8 +3,10 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.DotNet.RemoteExecutor; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Globalization; @@ -398,53 +400,25 @@ partial class C Assert.Equal("SYSLIB1043", Assert.Single(diagnostics).Id); } - [Theory] - [InlineData("SYSLIB1041")] - [InlineData("SYSLIB1042")] - [InlineData("SYSLIB1043")] - public async Task Diagnostic_HasPragmaSuppressibleLocation(string diagnosticId) + [Fact] + public async Task Diagnostic_HasPragmaSuppressibleLocation() { - string code = diagnosticId switch - { - "SYSLIB1041" => @" - using System.Text.RegularExpressions; - partial class C - { - [GeneratedRegex(""ab"")] - [GeneratedRegex(""abc"")] - private static partial Regex MultipleAttributes(); - }", - "SYSLIB1042" => @" - using System.Text.RegularExpressions; - partial class C - { - [GeneratedRegex(""ab[]"")] - private static partial Regex InvalidPattern(); - }", - "SYSLIB1043" => @" - using System.Text.RegularExpressions; - partial class C - { - [GeneratedRegex(""ab"")] - private static Regex NonPartialProperty => null; - }", - _ => throw new ArgumentException(diagnosticId), - }; - - // Verify diagnostic is reported and has a SourceFile location. - // This is the precondition for #pragma warning disable to work: - // ExternalFileLocation (the old behavior) ignores pragmas entirely. - string codeWithPragma = $"#pragma warning disable {diagnosticId}\n{code}"; - (Compilation comp, GeneratorDriverRunResult result) = await RegexGeneratorHelper.RunGeneratorCore(codeWithPragma); - Diagnostic diagnostic = Assert.Single(result.Diagnostics, d => d.Id == diagnosticId); - Assert.Equal(LocationKind.SourceFile, diagnostic.Location.Kind); - - // Verify the diagnostic location is within the scope of the #pragma directive - // in the same syntax tree, proving the compiler's pragma processing can suppress it. - SyntaxTree tree = diagnostic.Location.SourceTree; - Assert.NotNull(tree); - Assert.Contains(comp.SyntaxTrees, t => t == tree); - Assert.True(diagnostic.Location.SourceSpan.Start > 0); + // SYSLIB1044 (LimitedSourceGeneration) is emitted for case-insensitive backreferences. + // It is NOT tagged NotConfigurable, so #pragma warning disable can suppress it. + string code = """ + #pragma warning disable SYSLIB1044 + using System.Text.RegularExpressions; + partial class C + { + [GeneratedRegex("(a)\\1", RegexOptions.IgnoreCase)] + private static partial Regex Method(); + } + """; + + (Compilation comp, GeneratorDriverRunResult result) = await RegexGeneratorHelper.RunGeneratorCore(code); + var effective = CompilationWithAnalyzers.GetEffectiveDiagnostics(result.Diagnostics, comp); + Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1044"); + Assert.True(diagnostic.IsSuppressed); } [Fact] From 8f9bbcdee0cdfe5a46efbc55e672f6faccdbfdc0 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 20:30:48 +0200 Subject: [PATCH 18/29] Minimize Regex generator diff: segregate diagnostics from source output Revert the deeply equatable model restructuring and instead make the minimal change needed: replace DiagnosticData with raw Diagnostic objects (using SyntaxNode.GetLocation() instead of GetComparableLocation) and split the single RegisterSourceOutput into separate source and diagnostic callbacks. This preserves pragma suppressibility while keeping the existing pipeline structure, emitter signatures, and model types intact. Remove incremental generation tests (not part of this fix scope) and add a pragma suppression test for SYSLIB1044. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.Emitter.cs | 103 ++--- .../gen/RegexGenerator.Parser.cs | 172 +------- .../gen/RegexGenerator.cs | 368 +++++++++++------- ...m.Text.RegularExpressions.Generator.csproj | 2 - .../RegexGeneratorIncrementalTests.cs | 262 ------------- .../RegexGeneratorParserTests.cs | 1 - ...ystem.Text.RegularExpressions.Tests.csproj | 1 - 7 files changed, 289 insertions(+), 620 deletions(-) delete mode 100644 src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs index 2603aca24e4885..69b3c1a6c9d47f 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs @@ -57,10 +57,10 @@ private static string EscapeXmlComment(string text) } /// Emits the definition of the partial method. This method just delegates to the property cache on the generated Regex-derived type. - private static void EmitRegexPartialMethod(RegexMethodEntry entry, string generatedName, IndentedTextWriter writer) + private static void EmitRegexPartialMethod(RegexMethod regexMethod, IndentedTextWriter writer) { // Emit the namespace. - RegexType? parent = entry.DeclaringType; + RegexType? parent = regexMethod.DeclaringType; if (!string.IsNullOrWhiteSpace(parent.Namespace)) { writer.WriteLine($"namespace {parent.Namespace}"); @@ -85,32 +85,24 @@ private static void EmitRegexPartialMethod(RegexMethodEntry entry, string genera // Emit the partial method definition. writer.WriteLine($"/// "); writer.WriteLine($"/// Pattern:
"); - writer.WriteLine($"/// {EscapeXmlComment(entry.Pattern)}
"); - if (entry.Options != RegexOptions.None) + writer.WriteLine($"/// {EscapeXmlComment(regexMethod.Pattern)}
"); + if (regexMethod.Options != RegexOptions.None) { writer.WriteLine($"/// Options:
"); - writer.WriteLine($"/// {Literal(entry.Options)}
"); + writer.WriteLine($"/// {Literal(regexMethod.Options)}
"); } writer.WriteLine($"/// Explanation:
"); writer.WriteLine($"/// "); - // Emit the pre-computed expression description line by line so IndentedTextWriter adds proper indentation. - using (var reader = new StringReader(entry.ExpressionDescription)) - { - string? line; - while ((line = reader.ReadLine()) is not null) - { - writer.WriteLine(line); - } - } + DescribeExpressionAsXmlComment(writer, regexMethod.Tree.Root.Child(0), regexMethod); // skip implicit root capture writer.WriteLine($"/// "); writer.WriteLine($"///
"); writer.WriteLine($"[global::System.CodeDom.Compiler.{s_generatedCodeAttribute}]"); - writer.Write($"{entry.Modifiers} global::System.Text.RegularExpressions.Regex{(entry.NullableRegex ? "?" : "")} {entry.MemberName}"); - if (!entry.IsProperty) + writer.Write($"{regexMethod.Modifiers} global::System.Text.RegularExpressions.Regex{(regexMethod.NullableRegex ? "?" : "")} {regexMethod.MemberName}"); + if (!regexMethod.IsProperty) { writer.Write("()"); } - writer.WriteLine($" => global::{GeneratedNamespace}.{generatedName}.Instance;"); + writer.WriteLine($" => global::{GeneratedNamespace}.{regexMethod.GeneratedName}.Instance;"); // Unwind all scopes while (writer.Indent != 0) @@ -122,13 +114,13 @@ private static void EmitRegexPartialMethod(RegexMethodEntry entry, string genera /// Emits the Regex-derived type for a method where we're unable to generate custom code. private static void EmitRegexLimitedBoilerplate( - IndentedTextWriter writer, RegexMethodEntry entry, string generatedName, string reason, LanguageVersion langVer) + IndentedTextWriter writer, RegexMethod rm, string reason, LanguageVersion langVer) { string visibility; if (langVer >= LanguageVersion.CSharp11) { visibility = "file"; - writer.WriteLine($"/// Caches a instance for the {entry.MemberName} method."); + writer.WriteLine($"/// Caches a instance for the {rm.MemberName} method."); } else { @@ -137,14 +129,14 @@ private static void EmitRegexLimitedBoilerplate( } writer.WriteLine($"/// A custom Regex-derived type could not be generated because {reason}."); writer.WriteLine($"[{s_generatedCodeAttribute}]"); - writer.WriteLine($"{visibility} sealed class {generatedName} : Regex"); + writer.WriteLine($"{visibility} sealed class {rm.GeneratedName} : Regex"); writer.WriteLine($"{{"); writer.WriteLine($" /// Cached, thread-safe singleton instance."); writer.Write($" internal static readonly Regex Instance = "); writer.WriteLine( - entry.MatchTimeout is not null ? $"new({Literal(entry.Pattern)}, {Literal(entry.Options)}, {GetTimeoutExpression(entry.MatchTimeout.Value)});" : - entry.Options != 0 ? $"new({Literal(entry.Pattern)}, {Literal(entry.Options)});" : - $"new({Literal(entry.Pattern)});"); + rm.MatchTimeout is not null ? $"new({Literal(rm.Pattern)}, {Literal(rm.Options)}, {GetTimeoutExpression(rm.MatchTimeout.Value)});" : + rm.Options != 0 ? $"new({Literal(rm.Pattern)}, {Literal(rm.Options)});" : + $"new({Literal(rm.Pattern)});"); writer.WriteLine($"}}"); } @@ -156,27 +148,27 @@ private static void EmitRegexLimitedBoilerplate( /// Emits the Regex-derived type for a method whose RunnerFactory implementation was generated into . private static void EmitRegexDerivedImplementation( - IndentedTextWriter writer, RegexMethodEntry entry, string generatedName, string runnerFactoryImplementation, bool allowUnsafe) + IndentedTextWriter writer, RegexMethod rm, string runnerFactoryImplementation, bool allowUnsafe) { - writer.WriteLine($"/// Custom -derived type for the {entry.MemberName} method."); + writer.WriteLine($"/// Custom -derived type for the {rm.MemberName} method."); writer.WriteLine($"[{s_generatedCodeAttribute}]"); if (allowUnsafe) { writer.WriteLine($"[SkipLocalsInit]"); } - writer.WriteLine($"file sealed class {generatedName} : Regex"); + writer.WriteLine($"file sealed class {rm.GeneratedName} : Regex"); writer.WriteLine($"{{"); writer.WriteLine($" /// Cached, thread-safe singleton instance."); - writer.WriteLine($" internal static readonly {generatedName} Instance = new();"); + writer.WriteLine($" internal static readonly {rm.GeneratedName} Instance = new();"); writer.WriteLine($""); writer.WriteLine($" /// Initializes the instance."); - writer.WriteLine($" private {generatedName}()"); + writer.WriteLine($" private {rm.GeneratedName}()"); writer.WriteLine($" {{"); - writer.WriteLine($" base.pattern = {Literal(entry.Pattern)};"); - writer.WriteLine($" base.roptions = {Literal(entry.Options)};"); - if (entry.MatchTimeout is not null) + writer.WriteLine($" base.pattern = {Literal(rm.Pattern)};"); + writer.WriteLine($" base.roptions = {Literal(rm.Options)};"); + if (rm.MatchTimeout is not null) { - writer.WriteLine($" base.internalMatchTimeout = {GetTimeoutExpression(entry.MatchTimeout.Value)};"); + writer.WriteLine($" base.internalMatchTimeout = {GetTimeoutExpression(rm.MatchTimeout.Value)};"); } else { @@ -184,35 +176,23 @@ private static void EmitRegexDerivedImplementation( writer.WriteLine($" base.internalMatchTimeout = {HelpersTypeName}.{DefaultTimeoutFieldName};"); } writer.WriteLine($" base.factory = new RunnerFactory();"); - if (entry.CaptureNumberSparseMapping is not null) + if (rm.Tree.CaptureNumberSparseMapping is not null) { writer.Write(" base.Caps = new Hashtable {"); - string separator = ""; - foreach ((int key, int value) in entry.CaptureNumberSparseMapping) - { - writer.Write(separator); - separator = ", "; - writer.Write($" {{ {key}, {value} }} "); - } + AppendHashtableContents(writer, rm.Tree.CaptureNumberSparseMapping.Cast().OrderBy(de => de.Key as int?)); writer.WriteLine($" }};"); } - if (entry.CaptureNameToNumberMapping is not null) + if (rm.Tree.CaptureNameToNumberMapping is not null) { writer.Write(" base.CapNames = new Hashtable {"); - string separator = ""; - foreach ((string key, int value) in entry.CaptureNameToNumberMapping) - { - writer.Write(separator); - separator = ", "; - writer.Write($" {{ \"{key}\", {value} }} "); - } + AppendHashtableContents(writer, rm.Tree.CaptureNameToNumberMapping.Cast().OrderBy(de => de.Key as string, StringComparer.Ordinal)); writer.WriteLine($" }};"); } - if (entry.CaptureNames is not null) + if (rm.Tree.CaptureNames is not null) { writer.Write(" base.capslist = new string[] {"); string separator = ""; - foreach (string s in entry.CaptureNames) + foreach (string s in rm.Tree.CaptureNames) { writer.Write(separator); writer.Write(Literal(s)); @@ -220,10 +200,31 @@ private static void EmitRegexDerivedImplementation( } writer.WriteLine($" }};"); } - writer.WriteLine($" base.capsize = {entry.CaptureCount};"); + writer.WriteLine($" base.capsize = {rm.Tree.CaptureCount};"); writer.WriteLine($" }}"); writer.WriteLine(runnerFactoryImplementation); writer.WriteLine($"}}"); + + static void AppendHashtableContents(IndentedTextWriter writer, IEnumerable contents) + { + string separator = ""; + foreach (DictionaryEntry en in contents) + { + writer.Write(separator); + separator = ", "; + + writer.Write(" { "); + if (en.Key is int key) + { + writer.Write(key); + } + else + { + writer.Write($"\"{en.Key}\""); + } + writer.Write($", {en.Value} }} "); + } + } } /// Emits the code for the RunnerFactory. This is the actual logic for the regular expression. diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 64d17bc9f7a9f3..4614018778e4e5 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -1,15 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.CodeDom.Compiler; -using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; -using System.IO; using System.Linq; using System.Threading; -using SourceGenerators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -23,23 +19,9 @@ public partial class RegexGenerator private const string RegexName = "System.Text.RegularExpressions.Regex"; private const string GeneratedRegexAttributeName = "System.Text.RegularExpressions.GeneratedRegexAttribute"; - private static void AddHelper(ref Dictionary? helpers, string name, HelperMethod helper) - { - helpers ??= new Dictionary(StringComparer.Ordinal); -#if NET - helpers.TryAdd(name, helper); -#else - if (!helpers.ContainsKey(name)) - { - helpers.Add(name, helper); - } -#endif - } - /// - /// Validates the attributed member and extracts the data. - /// Returns for silent skips (e.g., missing types), a - /// for validation failures, or a on success. + /// Returns null if nothing to do, a if there's an error to report, + /// or if the type was analyzed successfully. /// private static object? GetRegexMethodDataOrFailureDiagnostic( GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) @@ -54,7 +36,6 @@ private static void AddHelper(ref Dictionary? helpers, str } var memberSyntax = (MemberDeclarationSyntax)context.TargetNode; - Location memberLocation = memberSyntax.GetLocation(); SemanticModel sm = context.SemanticModel; Compilation compilation = sm.Compilation; @@ -81,19 +62,19 @@ private static void AddHelper(ref Dictionary? helpers, str ImmutableArray boundAttributes = context.Attributes; if (boundAttributes.Length != 1) { - return Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberLocation); + return Diagnostic.Create(DiagnosticDescriptors.MultipleGeneratedRegexAttributes, memberSyntax.GetLocation()); } AttributeData generatedRegexAttr = boundAttributes[0]; if (generatedRegexAttr.ConstructorArguments.Any(ca => ca.Kind == TypedConstantKind.Error)) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation); + return Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberSyntax.GetLocation()); } ImmutableArray items = generatedRegexAttr.ConstructorArguments; if (items.Length is 0 or > 4) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberLocation); + return Diagnostic.Create(DiagnosticDescriptors.InvalidGeneratedRegexAttribute, memberSyntax.GetLocation()); } string? pattern = items[0].Value as string; @@ -125,7 +106,7 @@ private static void AddHelper(ref Dictionary? helpers, str if (pattern is null || cultureName is null) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "(null)"); + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberSyntax.GetLocation(), "(null)"); } bool nullableRegex; @@ -137,7 +118,7 @@ private static void AddHelper(ref Dictionary? helpers, str regexMethodSymbol.Arity != 0 || !SymbolEqualityComparer.Default.Equals(regexMethodSymbol.ReturnType, regexSymbol)) { - return Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation); + return Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberSyntax.GetLocation()); } nullableRegex = regexMethodSymbol.ReturnNullableAnnotation == NullableAnnotation.Annotated; @@ -151,7 +132,7 @@ private static void AddHelper(ref Dictionary? helpers, str regexPropertySymbol.SetMethod is not null || !SymbolEqualityComparer.Default.Equals(regexPropertySymbol.Type, regexSymbol)) { - return Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberLocation); + return Diagnostic.Create(DiagnosticDescriptors.RegexMemberMustHaveValidSignature, memberSyntax.GetLocation()); } nullableRegex = regexPropertySymbol.NullableAnnotation == NullableAnnotation.Annotated; @@ -173,7 +154,7 @@ regexPropertySymbol.SetMethod is not null || } catch (Exception e) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, e.Message); + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberSyntax.GetLocation(), e.Message); } if ((regexOptionsWithPatternOptions & RegexOptions.IgnoreCase) != 0 && !string.IsNullOrEmpty(cultureName)) @@ -181,7 +162,7 @@ regexPropertySymbol.SetMethod is not null || if ((regexOptions & RegexOptions.CultureInvariant) != 0) { // User passed in both a culture name and set RegexOptions.CultureInvariant which causes an explicit conflict. - return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"); + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberSyntax.GetLocation(), "cultureName"); } try @@ -190,7 +171,7 @@ regexPropertySymbol.SetMethod is not null || } catch (CultureNotFoundException) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "cultureName"); + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberSyntax.GetLocation(), "cultureName"); } } @@ -208,13 +189,13 @@ regexPropertySymbol.SetMethod is not null || RegexOptions.Singleline; if ((regexOptions & ~SupportedOptions) != 0) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "options"); + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberSyntax.GetLocation(), "options"); } // Validate the timeout if (matchTimeout is 0 or < -1) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberLocation, "matchTimeout"); + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, memberSyntax.GetLocation(), "matchTimeout"); } // Determine the namespace the class is declared in, if any @@ -232,8 +213,8 @@ regexPropertySymbol.SetMethod is not null || var result = new RegexPatternAndSyntax( regexType, - memberLocation, IsProperty: regexMemberSymbol is IPropertySymbol, + memberSyntax.GetLocation(), regexMemberSymbol.Name, memberSyntax.Modifiers.ToString(), nullableRegex, @@ -267,133 +248,20 @@ SyntaxKind.RecordStructDeclaration or SyntaxKind.InterfaceDeclaration; } - /// - /// Parses the regex, generates code, and extracts the result into deeply equatable types. - /// Called after has validated the attribute - /// and built the . Diagnostics and helpers are added to - /// the respective accumulators. - /// - private static RegexMethodEntry? ParseAndGenerateRegex( - RegexPatternAndSyntax method, - ref ImmutableArray.Builder? diagnostics, ref Dictionary? helpers) - { - RegexTree regexTree; - AnalysisResults analysis; - try - { - regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it - analysis = RegexTreeAnalyzer.Analyze(regexTree); - } - catch (Exception e) - { - (diagnostics ??= ImmutableArray.CreateBuilder()).Add(Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, method.MemberLocation, e.Message)); - return null; - } - - var regexMethod = new RegexMethod(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); - - // Pre-compute the XML expression description from the tree while we still have access to it. - string expressionDescription; - using (var descSw = new StringWriter()) - { - DescribeExpressionAsXmlComment(descSw, regexTree.Root.Child(0), regexMethod); - expressionDescription = descSw.ToString(); - } - - // Extract capture metadata from the tree into equatable forms. - ImmutableEquatableArray<(int Key, int Value)>? captureNumberSparseMapping = regexTree.CaptureNumberSparseMapping is { } cnsm - ? cnsm.Cast().Select(de => (Key: (int)de.Key, Value: (int)de.Value!)).OrderBy(p => p.Key).ToImmutableEquatableArray() - : null; - ImmutableEquatableArray<(string Key, int Value)>? captureNameToNumberMapping = regexTree.CaptureNameToNumberMapping is { } cntnm - ? cntnm.Cast().Select(de => (Key: (string)de.Key, Value: (int)de.Value!)).OrderBy(p => p.Key, StringComparer.Ordinal).ToImmutableEquatableArray() - : null; - ImmutableEquatableArray? captureNames = regexTree.CaptureNames?.ToImmutableEquatableArray(); - int captureCount = regexTree.CaptureCount; - - // If we're unable to generate a full implementation for this regex, report a diagnostic. - // We'll still output a limited implementation that just caches a new Regex(...). - if (!SupportsCodeGeneration(regexMethod, method.CompilationData.LanguageVersion, out string? reason)) - { - (diagnostics ??= ImmutableArray.CreateBuilder()).Add(Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, method.MemberLocation)); - - return new RegexMethodEntry( - method.DeclaringType, method.IsProperty, method.MemberName, - method.Modifiers, method.NullableRegex, method.Pattern, - method.Options, method.MatchTimeout, method.CompilationData, - GeneratedCode: null, LimitedSupportReason: reason, - expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, - captureNames, captureCount); - } - - // Generate the core logic for the regex. - Dictionary requiredHelpers = new(); - var sw = new StringWriter(); - var writer = new IndentedTextWriter(sw); - writer.Indent += 2; - writer.WriteLine(); - EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, method.CompilationData.CheckOverflow); - writer.Indent -= 2; - - // Add required helpers to the shared accumulator. - foreach (KeyValuePair h in requiredHelpers) - { - AddHelper(ref helpers, h.Key, new HelperMethod(h.Key, h.Value.ToImmutableEquatableArray())); - } - - return new RegexMethodEntry( - method.DeclaringType, method.IsProperty, method.MemberName, - method.Modifiers, method.NullableRegex, method.Pattern, - method.Options, method.MatchTimeout, method.CompilationData, - GeneratedCode: sw.ToString(), LimitedSupportReason: null, - expressionDescription, captureNumberSparseMapping, captureNameToNumberMapping, - captureNames, captureCount); - } - /// Data about a regex directly from the GeneratedRegex attribute. - internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, Location MemberLocation, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); + internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); /// Data about a regex, including a fully parsed RegexTree and subsequent analysis. - internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData); + internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) + { + public string? GeneratedName { get; set; } + public bool IsDuplicate { get; set; } + } /// A type holding a regex method. internal sealed record RegexType(string Keyword, string Namespace, string Name) { public RegexType? Parent { get; set; } } - - /// - /// Per-method data extracted from with all fields deeply equatable. - /// This is the incremental model used for source generation — it contains no references to - /// , , or Roslyn symbols. - /// - internal sealed record RegexMethodEntry( - RegexType DeclaringType, - bool IsProperty, - string MemberName, - string Modifiers, - bool NullableRegex, - string Pattern, - RegexOptions Options, - int? MatchTimeout, - CompilationData CompilationData, - string? GeneratedCode, - string? LimitedSupportReason, - string ExpressionDescription, - ImmutableEquatableArray<(int Key, int Value)>? CaptureNumberSparseMapping, - ImmutableEquatableArray<(string Key, int Value)>? CaptureNameToNumberMapping, - ImmutableEquatableArray? CaptureNames, - int CaptureCount); - - /// A named helper method (e.g. IsWordChar, IsBoundary) shared across regex implementations. - internal sealed record HelperMethod(string Name, ImmutableEquatableArray Lines); - - /// - /// The complete source generation model for all regex methods in a compilation. - /// All fields use for deep value equality, - /// enabling Roslyn's incremental pipeline to skip re-emission when the model is unchanged. - /// - internal sealed record RegexSourceGenerationResult( - ImmutableEquatableArray Methods, - ImmutableEquatableArray Helpers); } } diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 361571abaa1684..d1daa25d540497 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -11,7 +11,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using SourceGenerators; [assembly: System.Resources.NeutralResourcesLanguage("en-us")] @@ -25,8 +24,6 @@ public partial class RegexGenerator : IIncrementalGenerator private const string HelpersTypeName = "Utilities"; /// Namespace containing all the generated code. private const string GeneratedNamespace = "System.Text.RegularExpressions.Generated"; - /// Tracking name for the source generation step, used by incremental generation tests. - public const string SourceGenerationTrackingName = "SourceGenerationStep"; /// Code for a [GeneratedCode] attribute to put on the top-level generated members. private static readonly string s_generatedCodeAttribute = $"GeneratedCodeAttribute(\"{typeof(RegexGenerator).Assembly.GetName().Name}\", \"{typeof(RegexGenerator).Assembly.GetName().Version}\")"; /// Header comments and usings to include at the top of every generated file. @@ -47,196 +44,266 @@ internal record struct CompilationData(bool AllowUnsafe, bool CheckOverflow, Lan public void Initialize(IncrementalGeneratorInitializationContext context) { - // The ForAttributeWithMetadataName transform validates the attribute and returns - // either a Diagnostic (validation failure), a RegexPatternAndSyntax (success), or null (skip). - // Collect all per-method results, then aggregate into one RegexSourceGenerationResult - // record (with deduplicated helpers) plus a single list of all diagnostics. - IncrementalValueProvider<(RegexSourceGenerationResult Result, ImmutableArray Diagnostics)> collected = + // Produces one entry per generated regex. This may be: + // - Diagnostic in the case of a failure that should end the compilation + // - (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers) in the case of valid regex + // - (RegexMethod regexMethod, string reason, Diagnostic diagnostic) in the case of a limited-support regex + var collected = context.SyntaxProvider + + // Find all MethodDeclarationSyntax nodes attributed with GeneratedRegex and gather the required information. + // The predicate will be run once for every attributed node in the same file that's being modified. + // The transform will be run once for every attributed node in the compilation. + // Thus, both should do the minimal amount of work required and get out. This should also have extracted + // everything from the target necessary to do all subsequent analysis and should return an object that's + // meaningfully comparable and that doesn't reference anything from the compilation: we want to ensure + // that any successful cached results are idempotent for the input such that they don't trigger downstream work + // if there are no changes. .ForAttributeWithMetadataName( GeneratedRegexAttributeName, (node, _) => node is MethodDeclarationSyntax or PropertyDeclarationSyntax or IndexerDeclarationSyntax or AccessorDeclarationSyntax, GetRegexMethodDataOrFailureDiagnostic) + + // Filter out any parsing errors that resulted in null objects being returned. .Where(static m => m is not null) - .Collect() - .Select(static (items, _) => - { - ImmutableArray.Builder? methods = null; - Dictionary? helpersByName = null; - ImmutableArray.Builder? allDiagnostics = null; - foreach (object? item in items) + // The input here will either be a Diagnostic (in the case of something erroneous detected in GetRegexMethodDataOrFailureDiagnostic) + // or it will be a RegexPatternAndSyntax containing all of the successfully parsed data from the attribute/method. + .Select((methodOrDiagnostic, _) => + { + if (methodOrDiagnostic is RegexPatternAndSyntax method) { - if (item is Diagnostic diagnostic) + try { - (allDiagnostics ??= ImmutableArray.CreateBuilder()).Add(diagnostic); + RegexTree regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it + AnalysisResults analysis = RegexTreeAnalyzer.Analyze(regexTree); + return new RegexMethod(method.DeclaringType, method.IsProperty, method.DiagnosticLocation, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); } - else if (item is RegexPatternAndSyntax method) + catch (Exception e) { - RegexMethodEntry? entry = ParseAndGenerateRegex(method, ref allDiagnostics, ref helpersByName); - if (entry is not null) - { - (methods ??= ImmutableArray.CreateBuilder()).Add(entry); - } + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, method.DiagnosticLocation, e.Message); } } - var result = new RegexSourceGenerationResult( - methods?.ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty, - helpersByName?.Values.OrderBy(h => h.Name, StringComparer.Ordinal).ToImmutableEquatableArray() ?? ImmutableEquatableArray.Empty); + return methodOrDiagnostic; + }) - return (Result: result, Diagnostics: allDiagnostics?.ToImmutable() ?? ImmutableArray.Empty); - }); + // Generate the RunnerFactory for each regex, if possible. This is where the bulk of the implementation occurs. + .Select((state, _) => + { + if (state is not RegexMethod regexMethod) + { + Debug.Assert(state is Diagnostic); + return state; + } - // Project to just the equatable source model, discarding diagnostics. - // RegexSourceGenerationResult uses ImmutableEquatableArray fields for deep value equality, - // so Roslyn's Select operator compares successive snapshots and only propagates changes - // downstream when the model structurally differs. This ensures source generation is fully - // incremental: re-emitting code only when the regex spec actually changes. - IncrementalValueProvider sourceResults = collected - .Select(static (t, _) => t.Result) - .WithTrackingName(SourceGenerationTrackingName); - - context.RegisterSourceOutput(sourceResults, EmitSource); - - // Project to just the diagnostics, discarding the model. ImmutableArray does not - // implement value equality, so Roslyn's incremental pipeline uses reference equality for these - // values — the callback fires on every compilation change. This is by design: diagnostic - // emission is cheap, and we need fresh SourceLocation instances that are pragma-suppressible - // (cf. https://github.com/dotnet/runtime/issues/92509). - // No source code is generated from this pipeline — it exists solely to report diagnostics. - IncrementalValueProvider> diagnosticResults = - collected.Select(static (t, _) => t.Diagnostics); + // If we're unable to generate a full implementation for this regex, report a diagnostic. + // We'll still output a limited implementation that just caches a new Regex(...). + if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) + { + return (regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, regexMethod.DiagnosticLocation), regexMethod.CompilationData); + } - context.RegisterSourceOutput(diagnosticResults, EmitDiagnostics); - } + // Generate the core logic for the regex. + Dictionary requiredHelpers = new(); + var sw = new StringWriter(); + var writer = new IndentedTextWriter(sw); + writer.Indent += 2; + writer.WriteLine(); + EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, regexMethod.CompilationData.CheckOverflow); + writer.Indent -= 2; + return (regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData); + }) + + // Combine all of the generated text outputs into a single batch, then split + // the source model from diagnostics so they can be emitted independently. + .Collect() + .Select(static (results, _) => + { + ImmutableArray.Builder? diagnostics = null; - private static void EmitSource(SourceProductionContext context, RegexSourceGenerationResult result) - { - if (result.Methods.Count == 0) - { - return; - } + foreach (object result in results) + { + if (result is Diagnostic d) + { + (diagnostics ??= ImmutableArray.CreateBuilder()).Add(d); + } + else if (result is ValueTuple limitedSupportResult) + { + (diagnostics ??= ImmutableArray.CreateBuilder()).Add(limitedSupportResult.Item3); + } + } - // At this point we'll be emitting code. Create a writer to hold it all. - using StringWriter sw = new(); - using IndentedTextWriter writer = new(sw); + return (Results: results, Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); + }); - // Add file headers and required usings. - foreach (string header in s_headers) + // Project to just the source model for code generation. + context.RegisterSourceOutput(collected.Select(static (t, _) => t.Results), static (context, results) => { - writer.WriteLine(header); - } - writer.WriteLine(); - - // For every generated type, we give it an incrementally increasing ID, in order to create - // unique type names even in situations where method names were the same, while also keeping - // the type names short. Note that this is why we only generate the RunnerFactory implementations - // earlier in the pipeline... we want to avoid generating code that relies on the class names - // until we're able to iterate through them linearly keeping track of a deterministic ID - // used to name them. The boilerplate code generation that happens here is minimal when compared to - // the work required to generate the actual matching code for the regex. - int id = 0; - - // To minimize generated code in the event of duplicated regexes, we only emit one derived Regex type per unique - // expression/options/timeout. A Dictionary<(expression, options, timeout), (entry, generatedName)> is used to - // deduplicate, where the value contains the entry and the generated name used for the key. - var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), (RegexMethodEntry Entry, string GeneratedName)>(); - - foreach (RegexMethodEntry entry in result.Methods) - { - var key = (entry.Pattern, entry.Options, entry.MatchTimeout); - string generatedName; - if (emittedExpressions.TryGetValue(key, out (RegexMethodEntry Entry, string GeneratedName) existing)) - { - generatedName = existing.GeneratedName; - } - else + if (results.All(static r => r is Diagnostic)) { - generatedName = $"{entry.MemberName}_{id++}"; - emittedExpressions.Add(key, (entry, generatedName)); + return; } - EmitRegexPartialMethod(entry, generatedName, writer); - writer.WriteLine(); - } + // At this point we'll be emitting code. Create a writer to hold it all. + using StringWriter sw = new(); + using IndentedTextWriter writer = new(sw); - // At this point we've emitted all the partial method definitions, but we still need to emit the actual regex-derived implementations. - // These are all emitted inside of our generated class. - - writer.WriteLine($"namespace {GeneratedNamespace}"); - writer.WriteLine($"{{"); - - // We emit usings here now that we're inside of a namespace block and are no longer emitting code into - // a user's partial type. We can now rely on binding rules mapping to these usings and don't need to - // use global-qualified names for the rest of the implementation. - writer.WriteLine($" using System;"); - writer.WriteLine($" using System.Buffers;"); - writer.WriteLine($" using System.CodeDom.Compiler;"); - writer.WriteLine($" using System.Collections;"); - writer.WriteLine($" using System.ComponentModel;"); - writer.WriteLine($" using System.Globalization;"); - writer.WriteLine($" using System.Runtime.CompilerServices;"); - writer.WriteLine($" using System.Text.RegularExpressions;"); - writer.WriteLine($" using System.Threading;"); - writer.WriteLine($""); - - // Emit each Regex-derived type. - writer.Indent++; - foreach ((RegexMethodEntry entry, string generatedName) in emittedExpressions.Values) - { - if (entry.LimitedSupportReason is not null) + // Add file headers and required usings. + foreach (string header in s_headers) { - EmitRegexLimitedBoilerplate(writer, entry, generatedName, entry.LimitedSupportReason, entry.CompilationData.LanguageVersion); + writer.WriteLine(header); } - else if (entry.GeneratedCode is not null) + writer.WriteLine(); + + // For every generated type, we give it an incrementally increasing ID, in order to create + // unique type names even in situations where method names were the same, while also keeping + // the type names short. Note that this is why we only generate the RunnerFactory implementations + // earlier in the pipeline... we want to avoid generating code that relies on the class names + // until we're able to iterate through them linearly keeping track of a deterministic ID + // used to name them. The boilerplate code generation that happens here is minimal when compared to + // the work required to generate the actual matching code for the regex. + int id = 0; + + // To minimize generated code in the event of duplicated regexes, we only emit one derived Regex type per unique + // expression/options/timeout. A Dictionary<(expression, options, timeout), RegexMethod> is used to deduplicate, where the value of the + // pair is the implementation used for the key. + var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), RegexMethod>(); + + // If we have any (RegexMethod regexMethod, string generatedName, string reason, Diagnostic diagnostic), these are regexes for which we have + // limited support and need to simply output boilerplate. + // If we have any (RegexMethod regexMethod, string generatedName, string runnerFactoryImplementation, Dictionary requiredHelpers), + // those are generated implementations to be emitted. We need to gather up their required helpers. + Dictionary requiredHelpers = new(); + foreach (object? result in results) { - EmitRegexDerivedImplementation(writer, entry, generatedName, entry.GeneratedCode, entry.CompilationData.AllowUnsafe); + RegexMethod? regexMethod = null; + if (result is ValueTuple limitedSupportResult) + { + regexMethod = limitedSupportResult.Item1; + } + else if (result is ValueTuple, CompilationData> regexImpl) + { + foreach (KeyValuePair helper in regexImpl.Item3) + { + if (!requiredHelpers.ContainsKey(helper.Key)) + { + requiredHelpers.Add(helper.Key, helper.Value); + } + } + + regexMethod = regexImpl.Item1; + } + + if (regexMethod is not null) + { + var key = (regexMethod.Pattern, regexMethod.Options, regexMethod.MatchTimeout); + if (emittedExpressions.TryGetValue(key, out RegexMethod? implementation)) + { + regexMethod.IsDuplicate = true; + regexMethod.GeneratedName = implementation.GeneratedName; + } + else + { + regexMethod.IsDuplicate = false; + regexMethod.GeneratedName = $"{regexMethod.MemberName}_{id++}"; + emittedExpressions.Add(key, regexMethod); + } + + EmitRegexPartialMethod(regexMethod, writer); + writer.WriteLine(); + } } - writer.WriteLine(); - } - writer.Indent--; - // If any of the Regex-derived types asked for helper methods, emit those now. - if (result.Helpers.Count != 0) - { - writer.Indent++; - writer.WriteLine($"/// Helper methods used by generated -derived implementations."); - writer.WriteLine($"[{s_generatedCodeAttribute}]"); - writer.WriteLine($"file static class {HelpersTypeName}"); + // At this point we've emitted all the partial method definitions, but we still need to emit the actual regex-derived implementations. + // These are all emitted inside of our generated class. + + writer.WriteLine($"namespace {GeneratedNamespace}"); writer.WriteLine($"{{"); + + // We emit usings here now that we're inside of a namespace block and are no longer emitting code into + // a user's partial type. We can now rely on binding rules mapping to these usings and don't need to + // use global-qualified names for the rest of the implementation. + writer.WriteLine($" using System;"); + writer.WriteLine($" using System.Buffers;"); + writer.WriteLine($" using System.CodeDom.Compiler;"); + writer.WriteLine($" using System.Collections;"); + writer.WriteLine($" using System.ComponentModel;"); + writer.WriteLine($" using System.Globalization;"); + writer.WriteLine($" using System.Runtime.CompilerServices;"); + writer.WriteLine($" using System.Text.RegularExpressions;"); + writer.WriteLine($" using System.Threading;"); + writer.WriteLine($""); + + // Emit each Regex-derived type. writer.Indent++; - bool sawFirst = false; - foreach (HelperMethod helper in result.Helpers) + foreach (object? result in results) { - if (sawFirst) + if (result is ValueTuple limitedSupportResult) { - writer.WriteLine(); + if (!limitedSupportResult.Item1.IsDuplicate) + { + EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item4.LanguageVersion); + writer.WriteLine(); + } } - sawFirst = true; - - foreach (string value in helper.Lines) + else if (result is ValueTuple, CompilationData> regexImpl) { - writer.WriteLine(value); + if (!regexImpl.Item1.IsDuplicate) + { + EmitRegexDerivedImplementation(writer, regexImpl.Item1, regexImpl.Item2, regexImpl.Item4.AllowUnsafe); + writer.WriteLine(); + } } } writer.Indent--; - writer.WriteLine($"}}"); - writer.Indent--; - } - writer.WriteLine($"}}"); + // If any of the Regex-derived types asked for helper methods, emit those now. + if (requiredHelpers.Count != 0) + { + writer.Indent++; + writer.WriteLine($"/// Helper methods used by generated -derived implementations."); + writer.WriteLine($"[{s_generatedCodeAttribute}]"); + writer.WriteLine($"file static class {HelpersTypeName}"); + writer.WriteLine($"{{"); + writer.Indent++; + bool sawFirst = false; + foreach (KeyValuePair helper in requiredHelpers.OrderBy(h => h.Key, StringComparer.Ordinal)) + { + if (sawFirst) + { + writer.WriteLine(); + } + sawFirst = true; - // Save out the source - context.AddSource("RegexGenerator.g.cs", sw.ToString()); - } + foreach (string value in helper.Value) + { + writer.WriteLine(value); + } + } + writer.Indent--; + writer.WriteLine($"}}"); + writer.Indent--; + } - private static void EmitDiagnostics(SourceProductionContext context, ImmutableArray diagnostics) - { - foreach (Diagnostic diagnostic in diagnostics) + writer.WriteLine($"}}"); + + // Save out the source + context.AddSource("RegexGenerator.g.cs", sw.ToString()); + }); + + // Project to just the diagnostics. ImmutableArray does not implement value + // equality, so Roslyn's incremental pipeline uses reference equality — the callback fires + // on every compilation change. This is by design: diagnostic emission is cheap, and we + // need fresh SourceLocation instances that support #pragma warning disable + // (cf. https://github.com/dotnet/runtime/issues/92509). + context.RegisterSourceOutput(collected.Select(static (t, _) => t.Diagnostics), static (context, diagnostics) => { - context.ReportDiagnostic(diagnostic); - } + foreach (Diagnostic diagnostic in diagnostics) + { + context.ReportDiagnostic(diagnostic); + } + }); } /// Determines whether the passed in node supports C# code generation. @@ -297,6 +364,5 @@ static bool HasCaseInsensitiveBackReferences(RegexNode node) return false; } } - } } diff --git a/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj b/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj index 4210c1e843b063..642d6d750443fc 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj +++ b/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj @@ -25,8 +25,6 @@ - - diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs deleted file mode 100644 index 04bcfa9a30b4fc..00000000000000 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorIncrementalTests.cs +++ /dev/null @@ -1,262 +0,0 @@ -// 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; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text.RegularExpressions.Generator; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; -using Xunit; - -namespace System.Text.RegularExpressions.Tests -{ - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] - public static class RegexGeneratorIncrementalTests - { - [Fact] - public static async Task SameInput_DoesNotRegenerate() - { - Compilation compilation = await CreateCompilation(@" - using System.Text.RegularExpressions; - partial class C - { - [GeneratedRegex(""ab"")] - private static partial Regex MyRegex(); - }"); - - GeneratorDriver driver = CreateRegexGeneratorDriver(); - - driver = driver.RunGenerators(compilation); - GeneratorRunResult runResult = driver.GetRunResult().Results[0]; - - Assert.Collection(GetSourceGenRunSteps(runResult), - step => - { - Assert.Collection(step.Inputs, - source => Assert.Equal(IncrementalStepRunReason.New, source.Source.Outputs[source.OutputIndex].Reason)); - Assert.Collection(step.Outputs, - output => Assert.Equal(IncrementalStepRunReason.New, output.Reason)); - }); - - driver = driver.RunGenerators(compilation); - runResult = driver.GetRunResult().Results[0]; - - Assert.Collection(GetSourceGenRunSteps(runResult), - step => - { - Assert.Collection(step.Inputs, - source => Assert.Equal(IncrementalStepRunReason.Cached, source.Source.Outputs[source.OutputIndex].Reason)); - Assert.Collection(step.Outputs, - output => Assert.Equal(IncrementalStepRunReason.Cached, output.Reason)); - }); - } - - [Fact] - public static async Task EquivalentSources_DoesNotRegenerate() - { - // The Regex generator model uses ImmutableEquatableArray fields for deep value equality. - // When the attribute metadata (pattern, options) is unchanged, ForAttributeWithMetadataName - // caches the transform and all downstream steps are Cached. - string source1 = @" - using System.Text.RegularExpressions; - partial class C - { - [GeneratedRegex(""ab"")] - private static partial Regex MyRegex(); - }"; - - string source2 = @" - using System.Text.RegularExpressions; - // Changing the comment and location should produce identical SG model. - partial class C - { - [GeneratedRegex(""ab"")] - private static partial Regex MyRegex(); - }"; - - Compilation compilation = await CreateCompilation(source1); - GeneratorDriver driver = CreateRegexGeneratorDriver(); - - driver = driver.RunGenerators(compilation); - GeneratorRunResult runResult = driver.GetRunResult().Results[0]; - Assert.Collection(GetSourceGenRunSteps(runResult), - step => - { - Assert.Collection(step.Inputs, - source => Assert.Equal(IncrementalStepRunReason.New, source.Source.Outputs[source.OutputIndex].Reason)); - Assert.Collection(step.Outputs, - output => Assert.Equal(IncrementalStepRunReason.New, output.Reason)); - }); - - compilation = compilation.ReplaceSyntaxTree( - compilation.SyntaxTrees.First(), - CSharpSyntaxTree.ParseText(SourceText.From(source2, Encoding.UTF8), s_parseOptions)); - driver = driver.RunGenerators(compilation); - runResult = driver.GetRunResult().Results[0]; - // The comment change causes the per-item transform to re-run (Location changes), - // but the deeply equatable source model is structurally identical. The input to - // the tracked step is Unchanged (recomputed but equal); the output is Cached. - Assert.Collection(GetSourceGenRunSteps(runResult), - step => - { - Assert.Collection(step.Inputs, - source => Assert.Equal(IncrementalStepRunReason.Unchanged, source.Source.Outputs[source.OutputIndex].Reason)); - Assert.Collection(step.Outputs, - output => Assert.Equal(IncrementalStepRunReason.Cached, output.Reason)); - }); - } - - [Fact] - public static async Task DifferentSources_Regenerates() - { - string source1 = @" - using System.Text.RegularExpressions; - partial class C - { - [GeneratedRegex(""ab"")] - private static partial Regex MyRegex(); - }"; - - string source2 = @" - using System.Text.RegularExpressions; - partial class C - { - [GeneratedRegex(""cd"")] - private static partial Regex MyRegex(); - }"; - - Compilation compilation = await CreateCompilation(source1); - GeneratorDriver driver = CreateRegexGeneratorDriver(); - - driver = driver.RunGenerators(compilation); - GeneratorRunResult runResult = driver.GetRunResult().Results[0]; - Assert.Collection(GetSourceGenRunSteps(runResult), - step => - { - Assert.Collection(step.Inputs, - source => Assert.Equal(IncrementalStepRunReason.New, source.Source.Outputs[source.OutputIndex].Reason)); - Assert.Collection(step.Outputs, - output => Assert.Equal(IncrementalStepRunReason.New, output.Reason)); - }); - - compilation = compilation.ReplaceSyntaxTree( - compilation.SyntaxTrees.First(), - CSharpSyntaxTree.ParseText(SourceText.From(source2, Encoding.UTF8), s_parseOptions)); - driver = driver.RunGenerators(compilation); - runResult = driver.GetRunResult().Results[0]; - Assert.Collection(GetSourceGenRunSteps(runResult), - step => - { - Assert.Collection(step.Inputs, - source => Assert.Equal(IncrementalStepRunReason.Modified, source.Source.Outputs[source.OutputIndex].Reason)); - Assert.Collection(step.Outputs, - output => Assert.Equal(IncrementalStepRunReason.Modified, output.Reason)); - }); - } - - [Fact] - public static async Task SourceGenModelDoesNotEncapsulateSymbolsOrCompilationData() - { - Compilation compilation = await CreateCompilation(@" - using System.Text.RegularExpressions; - partial class C - { - [GeneratedRegex(""ab"")] - private static partial Regex MyRegex(); - }"); - - GeneratorDriver driver = CreateRegexGeneratorDriver(); - driver = driver.RunGenerators(compilation); - GeneratorRunResult runResult = driver.GetRunResult().Results[0]; - - IncrementalGeneratorRunStep[] steps = GetSourceGenRunSteps(runResult); - foreach (IncrementalGeneratorRunStep step in steps) - { - foreach ((object Value, IncrementalStepRunReason Reason) output in step.Outputs) - { - WalkObjectGraph(output.Value); - } - } - - static void WalkObjectGraph(object obj) - { - var visited = new HashSet(); - Visit(obj); - - void Visit(object? node) - { - if (node is null || !visited.Add(node)) - { - return; - } - - Assert.False(node is Compilation or ISymbol, $"Model should not contain {node.GetType().Name}"); - - Type type = node.GetType(); - if (type.IsPrimitive || type.IsEnum || type == typeof(string)) - { - return; - } - - if (node is IEnumerable collection and not string) - { - foreach (object? element in collection) - { - Visit(element); - } - - return; - } - - foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) - { - object? fieldValue = field.GetValue(node); - Visit(fieldValue); - } - } - } - } - - private static readonly CSharpParseOptions s_parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); - - private static CSharpGeneratorDriver CreateRegexGeneratorDriver() - { - return CSharpGeneratorDriver.Create( - generators: new ISourceGenerator[] { new RegexGenerator().AsSourceGenerator() }, - parseOptions: s_parseOptions, - driverOptions: new GeneratorDriverOptions( - disabledOutputs: IncrementalGeneratorOutputKind.None, - trackIncrementalGeneratorSteps: true)); - } - - private static async Task CreateCompilation(string source) - { - var proj = new AdhocWorkspace() - .AddSolution(SolutionInfo.Create(SolutionId.CreateNewId(), VersionStamp.Create())) - .AddProject("RegexGeneratorTest", "RegexGeneratorTest.dll", "C#") - .WithMetadataReferences(RegexGeneratorHelper.References) - .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) - .WithNullableContextOptions(NullableContextOptions.Enable)) - .WithParseOptions(s_parseOptions) - .AddDocument("Test.cs", SourceText.From(source, Encoding.UTF8)).Project; - - Assert.True(proj.Solution.Workspace.TryApplyChanges(proj.Solution)); - - return (await proj.GetCompilationAsync(CancellationToken.None).ConfigureAwait(false))!; - } - - private static IncrementalGeneratorRunStep[] GetSourceGenRunSteps(GeneratorRunResult runResult) - { - Assert.True( - runResult.TrackedSteps.TryGetValue(RegexGenerator.SourceGenerationTrackingName, out var runSteps), - $"Tracked step '{RegexGenerator.SourceGenerationTrackingName}' not found. Available: {string.Join(", ", runResult.TrackedSteps.Keys)}"); - - return runSteps.ToArray(); - } - } -} diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs index 7a9710999c4f94..3ab5a2923ccf90 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.cs @@ -404,7 +404,6 @@ partial class C public async Task Diagnostic_HasPragmaSuppressibleLocation() { // SYSLIB1044 (LimitedSourceGeneration) is emitted for case-insensitive backreferences. - // It is NOT tagged NotConfigurable, so #pragma warning disable can suppress it. string code = """ #pragma warning disable SYSLIB1044 using System.Text.RegularExpressions; diff --git a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/System.Text.RegularExpressions.Tests.csproj b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/System.Text.RegularExpressions.Tests.csproj index f2df58e9f008ec..62533b4e9c3377 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/System.Text.RegularExpressions.Tests.csproj +++ b/src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/System.Text.RegularExpressions.Tests.csproj @@ -74,7 +74,6 @@ - From 850a8ea53d83a6a40fd1bb2646ee6ac75c9ab915 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 21:34:04 +0200 Subject: [PATCH 19/29] Refine Regex generator Results to ImmutableEquatableArray without diagnostics/locations - Remove unrelated base.runtextpos change from RegexGenerator.Emitter.cs - Move DiagnosticLocation, Tree, Analysis out of RegexMethod positional params so they don't participate in record equality - Convert helpers Dictionary to ImmutableEquatableArray for value equality - Build ImmutableEquatableArray> in Collect().Select() that contains only data needed for source emission (no Diagnostic or Location) - Add ImmutableEquatableArray.cs and HashHelpers.cs to generator csproj Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.Emitter.cs | 1 - .../gen/RegexGenerator.Parser.cs | 18 ++- .../gen/RegexGenerator.cs | 112 ++++++++++-------- ...m.Text.RegularExpressions.Generator.csproj | 2 + 4 files changed, 77 insertions(+), 56 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs index 69b3c1a6c9d47f..1fb6aaf78f3fb6 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs @@ -1106,7 +1106,6 @@ bool EmitAnchors() noMatchFoundLabelNeeded = true; Goto(NoMatchFound); } - writer.WriteLine("base.runtextpos = pos;"); } writer.WriteLine(); break; diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 4614018778e4e5..84810d969034d4 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -214,7 +214,6 @@ regexPropertySymbol.SetMethod is not null || var result = new RegexPatternAndSyntax( regexType, IsProperty: regexMemberSymbol is IPropertySymbol, - memberSyntax.GetLocation(), regexMemberSymbol.Name, memberSyntax.Modifiers.ToString(), nullableRegex, @@ -222,7 +221,8 @@ regexPropertySymbol.SetMethod is not null || regexOptions, matchTimeout, culture, - compilationData); + compilationData) + { DiagnosticLocation = memberSyntax.GetLocation() }; RegexType current = regexType; var parent = typeDec.Parent as TypeDeclarationSyntax; @@ -249,11 +249,21 @@ SyntaxKind.RecordStructDeclaration or } /// Data about a regex directly from the GeneratedRegex attribute. - internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); + internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData) + { + /// Location for diagnostic reporting. Not part of record equality. + public Location DiagnosticLocation { get; init; } = Location.None; + } /// Data about a regex, including a fully parsed RegexTree and subsequent analysis. - internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) + internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CompilationData CompilationData) { + /// Location for diagnostic reporting. Not part of record equality. + public Location DiagnosticLocation { get; init; } = Location.None; + /// Parsed regex tree. Not part of record equality. + public RegexTree Tree { get; init; } = null!; + /// Analysis results from the regex tree. Not part of record equality. + public AnalysisResults Analysis { get; init; } = null!; public string? GeneratedName { get; set; } public bool IsDuplicate { get; set; } } diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index d1daa25d540497..f0b95e67360ac2 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -11,6 +11,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using SourceGenerators; [assembly: System.Resources.NeutralResourcesLanguage("en-us")] @@ -77,7 +78,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { RegexTree regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it AnalysisResults analysis = RegexTreeAnalyzer.Analyze(regexTree); - return new RegexMethod(method.DeclaringType, method.IsProperty, method.DiagnosticLocation, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); + return new RegexMethod(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, method.CompilationData) + { + DiagnosticLocation = method.DiagnosticLocation, + Tree = regexTree, + Analysis = analysis, + }; } catch (Exception e) { @@ -101,7 +107,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) { - return (regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, regexMethod.DiagnosticLocation), regexMethod.CompilationData); + return (object)( + regexMethod, + (string?)null, + (string?)reason, + ImmutableEquatableArray<(string, ImmutableEquatableArray)>.Empty, + (Diagnostic?)Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, regexMethod.DiagnosticLocation)); } // Generate the core logic for the regex. @@ -112,15 +123,25 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.WriteLine(); EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, regexMethod.CompilationData.CheckOverflow); writer.Indent -= 2; - return (regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData); + + // Convert helpers to an equatable form for the incremental pipeline. + var equatableHelpers = requiredHelpers + .OrderBy(h => h.Key, StringComparer.Ordinal) + .Select(h => (h.Key, h.Value.ToImmutableEquatableArray())) + .ToImmutableEquatableArray(); + + return (object)(regexMethod, (string?)sw.ToString(), (string?)null, equatableHelpers, (Diagnostic?)null); }) // Combine all of the generated text outputs into a single batch, then split // the source model from diagnostics so they can be emitted independently. + // The Results use ImmutableEquatableArray for deep value equality, ensuring the + // source output callback only fires when emitted code would actually change. .Collect() .Select(static (results, _) => { ImmutableArray.Builder? diagnostics = null; + var methods = new List<(RegexMethod Method, string? RunnerFactory, string? LimitedReason, ImmutableEquatableArray<(string Key, ImmutableEquatableArray Lines)> Helpers)>(); foreach (object result in results) { @@ -128,19 +149,25 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { (diagnostics ??= ImmutableArray.CreateBuilder()).Add(d); } - else if (result is ValueTuple limitedSupportResult) + else if (result is ValueTuple)>, Diagnostic> entry) { - (diagnostics ??= ImmutableArray.CreateBuilder()).Add(limitedSupportResult.Item3); + methods.Add((entry.Item1, entry.Item2, entry.Item3, entry.Item4)); + if (entry.Item5 is Diagnostic diag) + { + (diagnostics ??= ImmutableArray.CreateBuilder()).Add(diag); + } } } - return (Results: results, Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); + return ( + Results: methods.ToImmutableEquatableArray(), + Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); }); - // Project to just the source model for code generation. + // Project to just the equatable source model for code generation. context.RegisterSourceOutput(collected.Select(static (t, _) => t.Results), static (context, results) => { - if (results.All(static r => r is Diagnostic)) + if (results.Count == 0) { return; } @@ -170,48 +197,34 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // pair is the implementation used for the key. var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), RegexMethod>(); - // If we have any (RegexMethod regexMethod, string generatedName, string reason, Diagnostic diagnostic), these are regexes for which we have - // limited support and need to simply output boilerplate. - // If we have any (RegexMethod regexMethod, string generatedName, string runnerFactoryImplementation, Dictionary requiredHelpers), - // those are generated implementations to be emitted. We need to gather up their required helpers. - Dictionary requiredHelpers = new(); - foreach (object? result in results) + // Gather required helpers from all regex methods with full implementations. + var requiredHelpers = new Dictionary>(); + foreach ((RegexMethod method, string? runnerFactory, string? limitedReason, ImmutableEquatableArray<(string Key, ImmutableEquatableArray Lines)> helpers) in results) { - RegexMethod? regexMethod = null; - if (result is ValueTuple limitedSupportResult) + var key = (method.Pattern, method.Options, method.MatchTimeout); + if (emittedExpressions.TryGetValue(key, out RegexMethod? implementation)) { - regexMethod = limitedSupportResult.Item1; + method.IsDuplicate = true; + method.GeneratedName = implementation.GeneratedName; } - else if (result is ValueTuple, CompilationData> regexImpl) + else { - foreach (KeyValuePair helper in regexImpl.Item3) - { - if (!requiredHelpers.ContainsKey(helper.Key)) - { - requiredHelpers.Add(helper.Key, helper.Value); - } - } - - regexMethod = regexImpl.Item1; + method.IsDuplicate = false; + method.GeneratedName = $"{method.MemberName}_{id++}"; + emittedExpressions.Add(key, method); } - if (regexMethod is not null) + EmitRegexPartialMethod(method, writer); + writer.WriteLine(); + + foreach ((string helperKey, ImmutableEquatableArray helperLines) in helpers) { - var key = (regexMethod.Pattern, regexMethod.Options, regexMethod.MatchTimeout); - if (emittedExpressions.TryGetValue(key, out RegexMethod? implementation)) - { - regexMethod.IsDuplicate = true; - regexMethod.GeneratedName = implementation.GeneratedName; - } - else +#pragma warning disable CA1864 // Prefer Dictionary.TryAdd -- not available on netstandard2.0 + if (!requiredHelpers.ContainsKey(helperKey)) { - regexMethod.IsDuplicate = false; - regexMethod.GeneratedName = $"{regexMethod.MemberName}_{id++}"; - emittedExpressions.Add(key, regexMethod); + requiredHelpers.Add(helperKey, helperLines); } - - EmitRegexPartialMethod(regexMethod, writer); - writer.WriteLine(); +#pragma warning restore CA1864 } } @@ -237,21 +250,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Emit each Regex-derived type. writer.Indent++; - foreach (object? result in results) + foreach ((RegexMethod method, string? runnerFactory, string? limitedReason, _) in results) { - if (result is ValueTuple limitedSupportResult) + if (!method.IsDuplicate) { - if (!limitedSupportResult.Item1.IsDuplicate) + if (limitedReason is not null) { - EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item4.LanguageVersion); + EmitRegexLimitedBoilerplate(writer, method, limitedReason, method.CompilationData.LanguageVersion); writer.WriteLine(); } - } - else if (result is ValueTuple, CompilationData> regexImpl) - { - if (!regexImpl.Item1.IsDuplicate) + else if (runnerFactory is not null) { - EmitRegexDerivedImplementation(writer, regexImpl.Item1, regexImpl.Item2, regexImpl.Item4.AllowUnsafe); + EmitRegexDerivedImplementation(writer, method, runnerFactory, method.CompilationData.AllowUnsafe); writer.WriteLine(); } } @@ -268,7 +278,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.WriteLine($"{{"); writer.Indent++; bool sawFirst = false; - foreach (KeyValuePair helper in requiredHelpers.OrderBy(h => h.Key, StringComparer.Ordinal)) + foreach (KeyValuePair> helper in requiredHelpers.OrderBy(h => h.Key, StringComparer.Ordinal)) { if (sawFirst) { diff --git a/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj b/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj index 642d6d750443fc..ee13927fe42887 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj +++ b/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj @@ -26,6 +26,8 @@ + + From cb51e3b5f9d266b3cb3369409eed3d4cd17b13b2 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 21:42:06 +0200 Subject: [PATCH 20/29] Revert "Refine Regex generator Results to ImmutableEquatableArray without diagnostics/locations" This reverts commit 850a8ea53d83a6a40fd1bb2646ee6ac75c9ab915. --- .../gen/RegexGenerator.Emitter.cs | 1 + .../gen/RegexGenerator.Parser.cs | 18 +-- .../gen/RegexGenerator.cs | 112 ++++++++---------- ...m.Text.RegularExpressions.Generator.csproj | 2 - 4 files changed, 56 insertions(+), 77 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs index 1fb6aaf78f3fb6..69b3c1a6c9d47f 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs @@ -1106,6 +1106,7 @@ bool EmitAnchors() noMatchFoundLabelNeeded = true; Goto(NoMatchFound); } + writer.WriteLine("base.runtextpos = pos;"); } writer.WriteLine(); break; diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 84810d969034d4..4614018778e4e5 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -214,6 +214,7 @@ regexPropertySymbol.SetMethod is not null || var result = new RegexPatternAndSyntax( regexType, IsProperty: regexMemberSymbol is IPropertySymbol, + memberSyntax.GetLocation(), regexMemberSymbol.Name, memberSyntax.Modifiers.ToString(), nullableRegex, @@ -221,8 +222,7 @@ regexPropertySymbol.SetMethod is not null || regexOptions, matchTimeout, culture, - compilationData) - { DiagnosticLocation = memberSyntax.GetLocation() }; + compilationData); RegexType current = regexType; var parent = typeDec.Parent as TypeDeclarationSyntax; @@ -249,21 +249,11 @@ SyntaxKind.RecordStructDeclaration or } /// Data about a regex directly from the GeneratedRegex attribute. - internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData) - { - /// Location for diagnostic reporting. Not part of record equality. - public Location DiagnosticLocation { get; init; } = Location.None; - } + internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); /// Data about a regex, including a fully parsed RegexTree and subsequent analysis. - internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CompilationData CompilationData) + internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) { - /// Location for diagnostic reporting. Not part of record equality. - public Location DiagnosticLocation { get; init; } = Location.None; - /// Parsed regex tree. Not part of record equality. - public RegexTree Tree { get; init; } = null!; - /// Analysis results from the regex tree. Not part of record equality. - public AnalysisResults Analysis { get; init; } = null!; public string? GeneratedName { get; set; } public bool IsDuplicate { get; set; } } diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index f0b95e67360ac2..d1daa25d540497 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -11,7 +11,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using SourceGenerators; [assembly: System.Resources.NeutralResourcesLanguage("en-us")] @@ -78,12 +77,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { RegexTree regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it AnalysisResults analysis = RegexTreeAnalyzer.Analyze(regexTree); - return new RegexMethod(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, method.CompilationData) - { - DiagnosticLocation = method.DiagnosticLocation, - Tree = regexTree, - Analysis = analysis, - }; + return new RegexMethod(method.DeclaringType, method.IsProperty, method.DiagnosticLocation, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); } catch (Exception e) { @@ -107,12 +101,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) { - return (object)( - regexMethod, - (string?)null, - (string?)reason, - ImmutableEquatableArray<(string, ImmutableEquatableArray)>.Empty, - (Diagnostic?)Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, regexMethod.DiagnosticLocation)); + return (regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, regexMethod.DiagnosticLocation), regexMethod.CompilationData); } // Generate the core logic for the regex. @@ -123,25 +112,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.WriteLine(); EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, regexMethod.CompilationData.CheckOverflow); writer.Indent -= 2; - - // Convert helpers to an equatable form for the incremental pipeline. - var equatableHelpers = requiredHelpers - .OrderBy(h => h.Key, StringComparer.Ordinal) - .Select(h => (h.Key, h.Value.ToImmutableEquatableArray())) - .ToImmutableEquatableArray(); - - return (object)(regexMethod, (string?)sw.ToString(), (string?)null, equatableHelpers, (Diagnostic?)null); + return (regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData); }) // Combine all of the generated text outputs into a single batch, then split // the source model from diagnostics so they can be emitted independently. - // The Results use ImmutableEquatableArray for deep value equality, ensuring the - // source output callback only fires when emitted code would actually change. .Collect() .Select(static (results, _) => { ImmutableArray.Builder? diagnostics = null; - var methods = new List<(RegexMethod Method, string? RunnerFactory, string? LimitedReason, ImmutableEquatableArray<(string Key, ImmutableEquatableArray Lines)> Helpers)>(); foreach (object result in results) { @@ -149,25 +128,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { (diagnostics ??= ImmutableArray.CreateBuilder()).Add(d); } - else if (result is ValueTuple)>, Diagnostic> entry) + else if (result is ValueTuple limitedSupportResult) { - methods.Add((entry.Item1, entry.Item2, entry.Item3, entry.Item4)); - if (entry.Item5 is Diagnostic diag) - { - (diagnostics ??= ImmutableArray.CreateBuilder()).Add(diag); - } + (diagnostics ??= ImmutableArray.CreateBuilder()).Add(limitedSupportResult.Item3); } } - return ( - Results: methods.ToImmutableEquatableArray(), - Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); + return (Results: results, Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); }); - // Project to just the equatable source model for code generation. + // Project to just the source model for code generation. context.RegisterSourceOutput(collected.Select(static (t, _) => t.Results), static (context, results) => { - if (results.Count == 0) + if (results.All(static r => r is Diagnostic)) { return; } @@ -197,34 +170,48 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // pair is the implementation used for the key. var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), RegexMethod>(); - // Gather required helpers from all regex methods with full implementations. - var requiredHelpers = new Dictionary>(); - foreach ((RegexMethod method, string? runnerFactory, string? limitedReason, ImmutableEquatableArray<(string Key, ImmutableEquatableArray Lines)> helpers) in results) + // If we have any (RegexMethod regexMethod, string generatedName, string reason, Diagnostic diagnostic), these are regexes for which we have + // limited support and need to simply output boilerplate. + // If we have any (RegexMethod regexMethod, string generatedName, string runnerFactoryImplementation, Dictionary requiredHelpers), + // those are generated implementations to be emitted. We need to gather up their required helpers. + Dictionary requiredHelpers = new(); + foreach (object? result in results) { - var key = (method.Pattern, method.Options, method.MatchTimeout); - if (emittedExpressions.TryGetValue(key, out RegexMethod? implementation)) + RegexMethod? regexMethod = null; + if (result is ValueTuple limitedSupportResult) { - method.IsDuplicate = true; - method.GeneratedName = implementation.GeneratedName; + regexMethod = limitedSupportResult.Item1; } - else + else if (result is ValueTuple, CompilationData> regexImpl) { - method.IsDuplicate = false; - method.GeneratedName = $"{method.MemberName}_{id++}"; - emittedExpressions.Add(key, method); - } + foreach (KeyValuePair helper in regexImpl.Item3) + { + if (!requiredHelpers.ContainsKey(helper.Key)) + { + requiredHelpers.Add(helper.Key, helper.Value); + } + } - EmitRegexPartialMethod(method, writer); - writer.WriteLine(); + regexMethod = regexImpl.Item1; + } - foreach ((string helperKey, ImmutableEquatableArray helperLines) in helpers) + if (regexMethod is not null) { -#pragma warning disable CA1864 // Prefer Dictionary.TryAdd -- not available on netstandard2.0 - if (!requiredHelpers.ContainsKey(helperKey)) + var key = (regexMethod.Pattern, regexMethod.Options, regexMethod.MatchTimeout); + if (emittedExpressions.TryGetValue(key, out RegexMethod? implementation)) + { + regexMethod.IsDuplicate = true; + regexMethod.GeneratedName = implementation.GeneratedName; + } + else { - requiredHelpers.Add(helperKey, helperLines); + regexMethod.IsDuplicate = false; + regexMethod.GeneratedName = $"{regexMethod.MemberName}_{id++}"; + emittedExpressions.Add(key, regexMethod); } -#pragma warning restore CA1864 + + EmitRegexPartialMethod(regexMethod, writer); + writer.WriteLine(); } } @@ -250,18 +237,21 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Emit each Regex-derived type. writer.Indent++; - foreach ((RegexMethod method, string? runnerFactory, string? limitedReason, _) in results) + foreach (object? result in results) { - if (!method.IsDuplicate) + if (result is ValueTuple limitedSupportResult) { - if (limitedReason is not null) + if (!limitedSupportResult.Item1.IsDuplicate) { - EmitRegexLimitedBoilerplate(writer, method, limitedReason, method.CompilationData.LanguageVersion); + EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item4.LanguageVersion); writer.WriteLine(); } - else if (runnerFactory is not null) + } + else if (result is ValueTuple, CompilationData> regexImpl) + { + if (!regexImpl.Item1.IsDuplicate) { - EmitRegexDerivedImplementation(writer, method, runnerFactory, method.CompilationData.AllowUnsafe); + EmitRegexDerivedImplementation(writer, regexImpl.Item1, regexImpl.Item2, regexImpl.Item4.AllowUnsafe); writer.WriteLine(); } } @@ -278,7 +268,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.WriteLine($"{{"); writer.Indent++; bool sawFirst = false; - foreach (KeyValuePair> helper in requiredHelpers.OrderBy(h => h.Key, StringComparer.Ordinal)) + foreach (KeyValuePair helper in requiredHelpers.OrderBy(h => h.Key, StringComparer.Ordinal)) { if (sawFirst) { diff --git a/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj b/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj index ee13927fe42887..642d6d750443fc 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj +++ b/src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj @@ -26,8 +26,6 @@ - - From e0708f2ee2c7311ce3f1ca06ce6b51cbf1a7f32b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 21:55:23 +0200 Subject: [PATCH 21/29] Remove redundant line. --- .../System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs index 69b3c1a6c9d47f..1fb6aaf78f3fb6 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs @@ -1106,7 +1106,6 @@ bool EmitAnchors() noMatchFoundLabelNeeded = true; Goto(NoMatchFound); } - writer.WriteLine("base.runtextpos = pos;"); } writer.WriteLine(); break; From 0acbfb2bc3ef8d95ead1a96f3d77bc7e28fbb7c2 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 22:39:07 +0200 Subject: [PATCH 22/29] Refine Regex generator Results to filtered ImmutableArray without diagnostics - Build filtered ImmutableArray in Collect().Select() excluding pure Diagnostic entries and stripping Diagnostic from limited-support tuples - Reinstate ObjectImmutableArraySequenceEqualityComparer for element-wise equality on the source model projection - Lift source model IVP into named variable with explanatory comments - Update downstream tuple patterns and item indices accordingly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.cs | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index d1daa25d540497..b80743093babb9 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -121,6 +121,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Select(static (results, _) => { ImmutableArray.Builder? diagnostics = null; + ImmutableArray.Builder? filteredResults = null; foreach (object result in results) { @@ -131,16 +132,30 @@ public void Initialize(IncrementalGeneratorInitializationContext context) else if (result is ValueTuple limitedSupportResult) { (diagnostics ??= ImmutableArray.CreateBuilder()).Add(limitedSupportResult.Item3); + (filteredResults ??= ImmutableArray.CreateBuilder()).Add( + (limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item4)); + } + else + { + (filteredResults ??= ImmutableArray.CreateBuilder()).Add(result); } } - return (Results: results, Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); + return ( + Results: filteredResults?.ToImmutable() ?? ImmutableArray.Empty, + Diagnostics: diagnostics?.ToImmutable() ?? ImmutableArray.Empty); }); - // Project to just the source model for code generation. - context.RegisterSourceOutput(collected.Select(static (t, _) => t.Results), static (context, results) => + // Project to just the source model, discarding diagnostics. + // ObjectImmutableArraySequenceEqualityComparer applies element-wise equality over + // the heterogeneous result array, enabling Roslyn's incremental pipeline to skip + // re-emitting source when the model has not changed. + IncrementalValueProvider> sourceModel = + collected.Select(static (t, _) => t.Results).WithComparer(new ObjectImmutableArraySequenceEqualityComparer()); + + context.RegisterSourceOutput(sourceModel, static (context, results) => { - if (results.All(static r => r is Diagnostic)) + if (results.IsEmpty) { return; } @@ -170,15 +185,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // pair is the implementation used for the key. var emittedExpressions = new Dictionary<(string Pattern, RegexOptions Options, int? Timeout), RegexMethod>(); - // If we have any (RegexMethod regexMethod, string generatedName, string reason, Diagnostic diagnostic), these are regexes for which we have + // If we have any (RegexMethod regexMethod, string reason, CompilationData compilationData), these are regexes for which we have // limited support and need to simply output boilerplate. - // If we have any (RegexMethod regexMethod, string generatedName, string runnerFactoryImplementation, Dictionary requiredHelpers), + // If we have any (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers, CompilationData compilationData), // those are generated implementations to be emitted. We need to gather up their required helpers. Dictionary requiredHelpers = new(); foreach (object? result in results) { RegexMethod? regexMethod = null; - if (result is ValueTuple limitedSupportResult) + if (result is ValueTuple limitedSupportResult) { regexMethod = limitedSupportResult.Item1; } @@ -239,11 +254,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.Indent++; foreach (object? result in results) { - if (result is ValueTuple limitedSupportResult) + if (result is ValueTuple limitedSupportResult) { if (!limitedSupportResult.Item1.IsDuplicate) { - EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item4.LanguageVersion); + EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item2, limitedSupportResult.Item3.LanguageVersion); writer.WriteLine(); } } @@ -364,5 +379,38 @@ static bool HasCaseInsensitiveBackReferences(RegexNode node) return false; } } + + private sealed class ObjectImmutableArraySequenceEqualityComparer : IEqualityComparer> + { + public bool Equals(ImmutableArray left, ImmutableArray right) + { + if (left.Length != right.Length) + { + return false; + } + + for (int i = 0; i < left.Length; i++) + { + bool areEqual = left[i] is { } leftElem + ? leftElem.Equals(right[i]) + : right[i] is null; + + if (!areEqual) + { + return false; + } + } + + return true; + } + + public int GetHashCode([DisallowNull] ImmutableArray obj) + { + int hash = 0; + for (int i = 0; i < obj.Length; i++) + hash = (hash, obj[i]).GetHashCode(); + return hash; + } + } } } From 8f2157f8775b0d197c04bcc63e314165ca914c8f Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 22:41:09 +0200 Subject: [PATCH 23/29] Lift diagnostics IVP projection into named variable binding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index b80743093babb9..60f736a52d0eca 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -307,12 +307,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.AddSource("RegexGenerator.g.cs", sw.ToString()); }); - // Project to just the diagnostics. ImmutableArray does not implement value - // equality, so Roslyn's incremental pipeline uses reference equality — the callback fires - // on every compilation change. This is by design: diagnostic emission is cheap, and we - // need fresh SourceLocation instances that support #pragma warning disable + // Project to just the diagnostics, discarding the model. ImmutableArray does not + // implement value equality, so Roslyn's incremental pipeline uses reference equality — + // the callback fires on every compilation change. This is by design: diagnostic emission + // is cheap, and we need fresh SourceLocation instances that are pragma-suppressible // (cf. https://github.com/dotnet/runtime/issues/92509). - context.RegisterSourceOutput(collected.Select(static (t, _) => t.Diagnostics), static (context, diagnostics) => + IncrementalValueProvider> diagnosticResults = + collected.Select(static (t, _) => t.Diagnostics); + + context.RegisterSourceOutput(diagnosticResults, static (context, diagnostics) => { foreach (Diagnostic diagnostic in diagnostics) { From baf2d63a21a6025498db0b26cba714ed5e557ae8 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 22:43:09 +0200 Subject: [PATCH 24/29] Replace var with explicit type for collected pipeline variable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.RegularExpressions/gen/RegexGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 60f736a52d0eca..b8e5157fd18825 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -48,7 +48,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // - Diagnostic in the case of a failure that should end the compilation // - (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers) in the case of valid regex // - (RegexMethod regexMethod, string reason, Diagnostic diagnostic) in the case of a limited-support regex - var collected = + IncrementalValueProvider<(ImmutableArray Results, ImmutableArray Diagnostics)> collected = context.SyntaxProvider // Find all MethodDeclarationSyntax nodes attributed with GeneratedRegex and gather the required information. From 1d6b1bc459ef809d48a5ea0b8ae8ab8952d42372 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 22:56:29 +0200 Subject: [PATCH 25/29] Remove Location from RegexPatternAndSyntax and RegexMethod records Thread Location separately through the pipeline tuples so it does not participate in record equality. This improves incremental caching since Location instances are not value-comparable. The location is only used for creating Diagnostic objects in the pipeline and is discarded in the Collect phase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.Parser.cs | 7 +++--- .../gen/RegexGenerator.cs | 23 +++++++++++++------ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 4614018778e4e5..7b3bcd2390db21 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -214,7 +214,6 @@ regexPropertySymbol.SetMethod is not null || var result = new RegexPatternAndSyntax( regexType, IsProperty: regexMemberSymbol is IPropertySymbol, - memberSyntax.GetLocation(), regexMemberSymbol.Name, memberSyntax.Modifiers.ToString(), nullableRegex, @@ -238,7 +237,7 @@ regexPropertySymbol.SetMethod is not null || parent = parent.Parent as TypeDeclarationSyntax; } - return result; + return (result, memberSyntax.GetLocation()); static bool IsAllowedKind(SyntaxKind kind) => kind is SyntaxKind.ClassDeclaration or @@ -249,10 +248,10 @@ SyntaxKind.RecordStructDeclaration or } /// Data about a regex directly from the GeneratedRegex attribute. - internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); + internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); /// Data about a regex, including a fully parsed RegexTree and subsequent analysis. - internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) + internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) { public string? GeneratedName { get; set; } public bool IsDuplicate { get; set; } diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index b8e5157fd18825..23d73dce506951 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -48,6 +48,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // - Diagnostic in the case of a failure that should end the compilation // - (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary requiredHelpers) in the case of valid regex // - (RegexMethod regexMethod, string reason, Diagnostic diagnostic) in the case of a limited-support regex + // + // Location is threaded separately from the records so that it doesn't participate in + // record equality — this allows the incremental pipeline to cache results by value. IncrementalValueProvider<(ImmutableArray Results, ImmutableArray Diagnostics)> collected = context.SyntaxProvider @@ -68,20 +71,23 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static m => m is not null) // The input here will either be a Diagnostic (in the case of something erroneous detected in GetRegexMethodDataOrFailureDiagnostic) - // or it will be a RegexPatternAndSyntax containing all of the successfully parsed data from the attribute/method. + // or it will be a (RegexPatternAndSyntax, Location) tuple containing all of the successfully parsed data from the attribute/method. .Select((methodOrDiagnostic, _) => { - if (methodOrDiagnostic is RegexPatternAndSyntax method) + if (methodOrDiagnostic is ValueTuple methodAndLocation) { + RegexPatternAndSyntax method = methodAndLocation.Item1; + Location location = methodAndLocation.Item2; try { RegexTree regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it AnalysisResults analysis = RegexTreeAnalyzer.Analyze(regexTree); - return new RegexMethod(method.DeclaringType, method.IsProperty, method.DiagnosticLocation, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); + RegexMethod regexMethod = new(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); + return (object)(regexMethod, location); } catch (Exception e) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, method.DiagnosticLocation, e.Message); + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, location, e.Message); } } @@ -91,17 +97,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Generate the RunnerFactory for each regex, if possible. This is where the bulk of the implementation occurs. .Select((state, _) => { - if (state is not RegexMethod regexMethod) + if (state is not ValueTuple regexMethodAndLocation) { Debug.Assert(state is Diagnostic); return state; } + RegexMethod regexMethod = regexMethodAndLocation.Item1; + Location location = regexMethodAndLocation.Item2; + // If we're unable to generate a full implementation for this regex, report a diagnostic. // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) { - return (regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, regexMethod.DiagnosticLocation), regexMethod.CompilationData); + return (object)(regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, location), regexMethod.CompilationData); } // Generate the core logic for the regex. @@ -112,7 +121,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.WriteLine(); EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, regexMethod.CompilationData.CheckOverflow); writer.Indent -= 2; - return (regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData); + return (object)(regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData); }) // Combine all of the generated text outputs into a single batch, then split From 1708293505c9fa0619d065ef3509f2c5ca702a47 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 22:59:49 +0200 Subject: [PATCH 26/29] Remove unnecessary (object) casts from second Select lambda Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Text.RegularExpressions/gen/RegexGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 23d73dce506951..438968392a7c1b 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -110,7 +110,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) { - return (object)(regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, location), regexMethod.CompilationData); + return (regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, location), regexMethod.CompilationData); } // Generate the core logic for the regex. @@ -121,7 +121,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) writer.WriteLine(); EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers, regexMethod.CompilationData.CheckOverflow); writer.Indent -= 2; - return (object)(regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData); + return (regexMethod, sw.ToString(), requiredHelpers, regexMethod.CompilationData); }) // Combine all of the generated text outputs into a single batch, then split From 466a9605a171380ab3063ab7df139fe1ffe8fa82 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 23:04:15 +0200 Subject: [PATCH 27/29] Lift SupportsCodeGeneration check into first Select, eliminating (RegexMethod, Location) tuple The Location is now fully consumed in the first Select for diagnostic creation and not propagated further. The second Select receives either a bare RegexMethod (for full codegen) or a Diagnostic/limited-support tuple to pass through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.cs | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 438968392a7c1b..0bae13f0998955 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -72,6 +72,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // The input here will either be a Diagnostic (in the case of something erroneous detected in GetRegexMethodDataOrFailureDiagnostic) // or it will be a (RegexPatternAndSyntax, Location) tuple containing all of the successfully parsed data from the attribute/method. + // This step parses the regex tree and checks whether full code generation is supported. + // The Location is consumed here for diagnostic creation and not propagated further. .Select((methodOrDiagnostic, _) => { if (methodOrDiagnostic is ValueTuple methodAndLocation) @@ -83,7 +85,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) RegexTree regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it AnalysisResults analysis = RegexTreeAnalyzer.Analyze(regexTree); RegexMethod regexMethod = new(method.DeclaringType, method.IsProperty, method.MemberName, method.Modifiers, method.NullableRegex, method.Pattern, method.Options, method.MatchTimeout, regexTree, analysis, method.CompilationData); - return (object)(regexMethod, location); + + // If we're unable to generate a full implementation for this regex, report a diagnostic. + // We'll still output a limited implementation that just caches a new Regex(...). + if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) + { + return (object)(regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, location), regexMethod.CompilationData); + } + + return regexMethod; } catch (Exception e) { @@ -97,22 +107,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Generate the RunnerFactory for each regex, if possible. This is where the bulk of the implementation occurs. .Select((state, _) => { - if (state is not ValueTuple regexMethodAndLocation) + if (state is not RegexMethod regexMethod) { - Debug.Assert(state is Diagnostic); + Debug.Assert(state is Diagnostic or ValueTuple); return state; } - RegexMethod regexMethod = regexMethodAndLocation.Item1; - Location location = regexMethodAndLocation.Item2; - - // If we're unable to generate a full implementation for this regex, report a diagnostic. - // We'll still output a limited implementation that just caches a new Regex(...). - if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) - { - return (regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, location), regexMethod.CompilationData); - } - // Generate the core logic for the regex. Dictionary requiredHelpers = new(); var sw = new StringWriter(); From 36371804f27141e22ddb2bd99e34c8e7e19a8e24 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Mar 2026 23:07:05 +0200 Subject: [PATCH 28/29] Restore Location in RegexPatternAndSyntax record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegexPatternAndSyntax is not part of the incremental model — it is consumed in the first Select and never reaches the Collect phase. Restoring DiagnosticLocation simplifies the first Select by removing the (RegexPatternAndSyntax, Location) tuple indirection. RegexMethod remains Location-free since it is part of the cached model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/RegexGenerator.Parser.cs | 5 +++-- .../gen/RegexGenerator.cs | 12 +++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs index 7b3bcd2390db21..1d1df92c107d9b 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs @@ -214,6 +214,7 @@ regexPropertySymbol.SetMethod is not null || var result = new RegexPatternAndSyntax( regexType, IsProperty: regexMemberSymbol is IPropertySymbol, + memberSyntax.GetLocation(), regexMemberSymbol.Name, memberSyntax.Modifiers.ToString(), nullableRegex, @@ -237,7 +238,7 @@ regexPropertySymbol.SetMethod is not null || parent = parent.Parent as TypeDeclarationSyntax; } - return (result, memberSyntax.GetLocation()); + return result; static bool IsAllowedKind(SyntaxKind kind) => kind is SyntaxKind.ClassDeclaration or @@ -248,7 +249,7 @@ SyntaxKind.RecordStructDeclaration or } /// Data about a regex directly from the GeneratedRegex attribute. - internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); + internal sealed record RegexPatternAndSyntax(RegexType DeclaringType, bool IsProperty, Location DiagnosticLocation, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, CultureInfo Culture, CompilationData CompilationData); /// Data about a regex, including a fully parsed RegexTree and subsequent analysis. internal sealed record RegexMethod(RegexType DeclaringType, bool IsProperty, string MemberName, string Modifiers, bool NullableRegex, string Pattern, RegexOptions Options, int? MatchTimeout, RegexTree Tree, AnalysisResults Analysis, CompilationData CompilationData) diff --git a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs index 0bae13f0998955..69174c2a508efd 100644 --- a/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs +++ b/src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs @@ -71,15 +71,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static m => m is not null) // The input here will either be a Diagnostic (in the case of something erroneous detected in GetRegexMethodDataOrFailureDiagnostic) - // or it will be a (RegexPatternAndSyntax, Location) tuple containing all of the successfully parsed data from the attribute/method. + // or it will be a RegexPatternAndSyntax containing all of the successfully parsed data from the attribute/method. // This step parses the regex tree and checks whether full code generation is supported. - // The Location is consumed here for diagnostic creation and not propagated further. + // The DiagnosticLocation is consumed here for diagnostic creation and not propagated further. .Select((methodOrDiagnostic, _) => { - if (methodOrDiagnostic is ValueTuple methodAndLocation) + if (methodOrDiagnostic is RegexPatternAndSyntax method) { - RegexPatternAndSyntax method = methodAndLocation.Item1; - Location location = methodAndLocation.Item2; try { RegexTree regexTree = RegexParser.Parse(method.Pattern, method.Options | RegexOptions.Compiled, method.Culture); // make sure Compiled is included to get all optimizations applied to it @@ -90,14 +88,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // We'll still output a limited implementation that just caches a new Regex(...). if (!SupportsCodeGeneration(regexMethod, regexMethod.CompilationData.LanguageVersion, out string? reason)) { - return (object)(regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, location), regexMethod.CompilationData); + return (object)(regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, method.DiagnosticLocation), regexMethod.CompilationData); } return regexMethod; } catch (Exception e) { - return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, location, e.Message); + return Diagnostic.Create(DiagnosticDescriptors.InvalidRegexArguments, method.DiagnosticLocation, e.Message); } } From 59621884e24e4bdf096afaf95cfe4c26dbdf6789 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 11 Mar 2026 09:03:16 +0200 Subject: [PATCH 29/29] Address ericstj feedback: remove DiagnosticInfo.cs, fix dedup, add tests - Delete Common/src/SourceGenerators/DiagnosticInfo.cs (no longer used) - Remove orphaned DiagnosticInfo.cs Compile Include from Logging and STJ targets files - Add diagnostic.GetMessage() to Logger dedup key to avoid collapsing distinct diagnostics with same Id/location but different messages - Add pragma suppression test cases: negative (no pragma), partial (multiple diagnostics, only some suppressed), and scoping (suppress then restore before diagnostic site) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/SourceGenerators/DiagnosticInfo.cs | 60 ------------ .../SourceGenerationTests/GeneratorTests.cs | 96 +++++++++++++++++++ .../gen/LoggerMessageGenerator.Roslyn4.0.cs | 4 +- ...soft.Extensions.Logging.Generators.targets | 1 - .../System.Text.Json.SourceGeneration.targets | 1 - 5 files changed, 98 insertions(+), 64 deletions(-) delete mode 100644 src/libraries/Common/src/SourceGenerators/DiagnosticInfo.cs diff --git a/src/libraries/Common/src/SourceGenerators/DiagnosticInfo.cs b/src/libraries/Common/src/SourceGenerators/DiagnosticInfo.cs deleted file mode 100644 index 74f44f99c62baa..00000000000000 --- a/src/libraries/Common/src/SourceGenerators/DiagnosticInfo.cs +++ /dev/null @@ -1,60 +0,0 @@ -// 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.Linq; -using System.Numerics.Hashing; -using Microsoft.CodeAnalysis; - -namespace SourceGenerators; - -/// -/// Descriptor for diagnostic instances using structural equality comparison. -/// Provides a work-around for https://github.com/dotnet/roslyn/issues/68291. -/// -internal readonly struct DiagnosticInfo : IEquatable -{ - public DiagnosticDescriptor Descriptor { get; private init; } - public object?[] MessageArgs { get; private init; } - public Location? Location { get; private init; } - - public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, Location? location, object?[]? messageArgs) - { - Location? trimmedLocation = location is null ? null : GetTrimmedLocation(location); - - return new DiagnosticInfo - { - Descriptor = descriptor, - Location = trimmedLocation, - MessageArgs = messageArgs ?? Array.Empty() - }; - - // Creates a copy of the Location instance that does not capture a reference to Compilation. - static Location GetTrimmedLocation(Location location) - => Location.Create(location.SourceTree?.FilePath ?? "", location.SourceSpan, location.GetLineSpan().Span); - } - - public Diagnostic CreateDiagnostic() - => Diagnostic.Create(Descriptor, Location, MessageArgs); - - public override readonly bool Equals(object? obj) => obj is DiagnosticInfo info && Equals(info); - - public readonly bool Equals(DiagnosticInfo other) - { - return Descriptor.Equals(other.Descriptor) && - MessageArgs.SequenceEqual(other.MessageArgs) && - Location == other.Location; - } - - public override readonly int GetHashCode() - { - int hashCode = Descriptor.GetHashCode(); - foreach (object? messageArg in MessageArgs) - { - hashCode = HashHelpers.Combine(hashCode, messageArg?.GetHashCode() ?? 0); - } - - hashCode = HashHelpers.Combine(hashCode, Location?.GetHashCode() ?? 0); - return hashCode; - } -} diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs index c701a68b62b078..e15f9625a971a1 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -443,5 +443,101 @@ public static void Main() Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1103"); Assert.True(diagnostic.IsSuppressed); } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + public async Task Diagnostic_NoPragma_IsNotSuppressed() + { + string source = """ + using System; + using Microsoft.Extensions.Configuration; + + public class Program + { + public static void Main() + { + ConfigurationBuilder configurationBuilder = new(); + IConfigurationRoot config = configurationBuilder.Build(); + + int myInt = 1; + config.Bind(myInt); + } + } + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); + var effective = CompilationWithAnalyzers.GetEffectiveDiagnostics(result.Diagnostics, result.OutputCompilation); + Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1103"); + Assert.False(diagnostic.IsSuppressed); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + public async Task Diagnostic_MultipleDiagnostics_OnlySomeSuppressed() + { + string source = """ + using System; + using System.Collections.Immutable; + using System.Text; + using System.Text.Json; + using Microsoft.Extensions.Configuration; + + public class Program + { + public static void Main() + { + ConfigurationBuilder configurationBuilder = new(); + IConfigurationRoot config = configurationBuilder.Build(); + + // SYSLIB1103 suppressed for this call only. + #pragma warning disable SYSLIB1103 + int myInt = 1; + config.Bind(myInt); + #pragma warning restore SYSLIB1103 + + // SYSLIB1103 NOT suppressed for this call. + long myLong = 1; + config.Bind(myLong); + } + } + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); + var effective = CompilationWithAnalyzers.GetEffectiveDiagnostics(result.Diagnostics, result.OutputCompilation) + .Where(d => d.Id == "SYSLIB1103") + .ToList(); + + Assert.Equal(2, effective.Count); + Assert.Single(effective, d => d.IsSuppressed); + Assert.Single(effective, d => !d.IsSuppressed); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + public async Task Diagnostic_PragmaRestoreOutsideSpan_IsNotSuppressed() + { + string source = """ + using System; + using Microsoft.Extensions.Configuration; + + public class Program + { + public static void Main() + { + ConfigurationBuilder configurationBuilder = new(); + IConfigurationRoot config = configurationBuilder.Build(); + + // Suppress and restore BEFORE the diagnostic site. + #pragma warning disable SYSLIB1103 + #pragma warning restore SYSLIB1103 + + int myInt = 1; + config.Bind(myInt); + } + } + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); + var effective = CompilationWithAnalyzers.GetEffectiveDiagnostics(result.Diagnostics, result.OutputCompilation); + Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1103"); + Assert.False(diagnostic.IsSuppressed); + } } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs index 958a0c426c5c63..9a205f873a6d00 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Roslyn4.0.cs @@ -108,7 +108,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { ImmutableArray<(LoggerClassSpec, bool)>.Builder? specs = null; ImmutableArray.Builder? diagnostics = null; - HashSet<(string Id, TextSpan? Span, string? FilePath)>? seen = null; + HashSet<(string Id, TextSpan? Span, string? FilePath, string Message)>? seen = null; foreach (var item in items) { @@ -118,7 +118,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } foreach (Diagnostic diagnostic in item.Diagnostics) { - if ((seen ??= new()).Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath))) + if ((seen ??= new()).Add((diagnostic.Id, diagnostic.Location?.SourceSpan, diagnostic.Location?.SourceTree?.FilePath, diagnostic.GetMessage()))) { (diagnostics ??= ImmutableArray.CreateBuilder()).Add(diagnostic); } diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/Microsoft.Extensions.Logging.Generators.targets b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/Microsoft.Extensions.Logging.Generators.targets index f1a42f8831ecfa..096dc2f89a3709 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/Microsoft.Extensions.Logging.Generators.targets +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/Microsoft.Extensions.Logging.Generators.targets @@ -25,7 +25,6 @@ - diff --git a/src/libraries/System.Text.Json/gen/System.Text.Json.SourceGeneration.targets b/src/libraries/System.Text.Json/gen/System.Text.Json.SourceGeneration.targets index aa7ae500ce7870..2d4351a3eab530 100644 --- a/src/libraries/System.Text.Json/gen/System.Text.Json.SourceGeneration.targets +++ b/src/libraries/System.Text.Json/gen/System.Text.Json.SourceGeneration.targets @@ -31,7 +31,6 @@ -