From b2689ad249441fc79025c3b3a297c4e4eb4dd482 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Mon, 13 Apr 2026 14:52:18 +0200 Subject: [PATCH 01/10] Add tests for suppressing AOT warnings inside method invocations --- ...onfigurationBindingGenerator.Suppressor.cs | 5 +- .../ConfigBindingGenTestDriver.cs | 7 + .../SourceGenerationTests/GeneratorTests.cs | 132 ++++++++++++++++++ ...ation.Binder.SourceGeneration.Tests.csproj | 3 +- 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs index 6e9a1448fcbfb7..a1691c96d392e0 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs @@ -51,9 +51,12 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) // The trim analyzer changed from warning on the InvocationExpression to the MemberAccessExpression in https://github.com/dotnet/runtime/pull/110086 // In other words, the warning location went from from `{|Method1(arg1, arg2)|}` to `{|Method1|}(arg1, arg2)` // To account for this, we need to check if the location is an InvocationExpression or a child of an InvocationExpression. + // Use getInnermostNodeForTie to handle the case where the binding call is an + // argument to another method (e.g. Some.Method(config.Get())). In that case, + // ArgumentSyntax and the inner InvocationExpressionSyntax can share the same span. bool shouldSuppressDiagnostic = location.SourceTree is SyntaxTree sourceTree && - sourceTree.GetRoot().FindNode(location.SourceSpan) is SyntaxNode syntaxNode && + sourceTree.GetRoot().FindNode(location.SourceSpan, getInnermostNodeForTie: true) is SyntaxNode syntaxNode && (syntaxNode as InvocationExpressionSyntax ?? syntaxNode.Parent as InvocationExpressionSyntax) is InvocationExpressionSyntax invocation && BinderInvocation.IsCandidateSyntaxNode(invocation) && context.GetSemanticModel(sourceTree) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/ConfigBindingGenTestDriver.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/ConfigBindingGenTestDriver.cs index b15815d72527fc..1233c890a48d73 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/ConfigBindingGenTestDriver.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/ConfigBindingGenTestDriver.cs @@ -62,6 +62,7 @@ public async Task RunGeneratorAndUpdateCompilation(st return new ConfigBindingGenRunResult { + InputCompilation = _compilation, OutputCompilation = outputCompilation, Diagnostics = runResult.Diagnostics, GeneratedSource = runResult.Results[0].GeneratedSources is { Length: not 0 } sources ? sources[0] : null, @@ -96,6 +97,12 @@ private async Task UpdateCompilationWithSource(string? source = null) internal struct ConfigBindingGenRunResult { + /// + /// The compilation before source generation, used for running analyzers/suppressors + /// which need to see the original (non-intercepted) method resolution. + /// + public Compilation InputCompilation { get; init; } + public Compilation OutputCompilation { get; init; } public GeneratedSourceResult? GeneratedSource { get; init; } 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 3db19473fe2f48..0ce8f294d62a69 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -10,10 +10,12 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using ILLink.RoslynAnalyzer; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Binder.SourceGeneration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; @@ -535,5 +537,135 @@ public static void Main() Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1103"); Assert.False(diagnostic.IsSuppressed); } + + /// + /// Verifies that the suppressor suppresses IL2026/IL3050 when a ConfigurationBinder call + /// is passed directly as a method argument (e.g. Some.Method(config.Get<T>())). + /// Regression test for https://github.com/dotnet/runtime/issues/94544. + /// + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + public async Task Suppressor_SuppressesWarnings_WhenBindingCallIsMethodArgument() + { + string source = """ + using Microsoft.Extensions.Configuration; + + public class Program + { + public static void Main() + { + IConfigurationSection c = new ConfigurationBuilder().Build().GetSection("Options"); + Some.Method(c.Get()); + } + } + + internal static class Some + { + public static void Method(MyOptions? options) { } + } + + public class MyOptions + { + public int MaxRetries { get; set; } + } + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); + Assert.NotNull(result.GeneratedSource); + + ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.InputCompilation); + + Assert.Contains(diagnostics, d => d.Id == "IL2026" && d.IsSuppressed); + Assert.Contains(diagnostics, d => d.Id == "IL3050" && d.IsSuppressed); + Assert.DoesNotContain(diagnostics, d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed); + } + + /// + /// Verifies that the suppressor also works for the straightforward assignment case, + /// ensuring no regression in existing behavior. + /// + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + public async Task Suppressor_SuppressesWarnings_ForSimpleBindingCall() + { + string source = """ + using Microsoft.Extensions.Configuration; + + public class Program + { + public static void Main() + { + IConfigurationSection c = new ConfigurationBuilder().Build().GetSection("Options"); + var options = c.Get(); + } + } + + public class MyOptions + { + public int MaxRetries { get; set; } + } + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); + Assert.NotNull(result.GeneratedSource); + + ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.InputCompilation); + + Assert.Contains(diagnostics, d => d.Id == "IL2026" && d.IsSuppressed); + Assert.Contains(diagnostics, d => d.Id == "IL3050" && d.IsSuppressed); + Assert.DoesNotContain(diagnostics, d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed); + } + + private static async Task> GetDiagnosticsWithSuppressor(Compilation compilation) + { + var analyzers = ImmutableArray.Create( + new DynamicallyAccessedMembersAnalyzer(), + new ConfigurationBindingGenerator.Suppressor()); + + var globalOptions = ImmutableDictionary.CreateRange( + StringComparer.OrdinalIgnoreCase, + new[] + { + new KeyValuePair("build_property.EnableTrimAnalyzer", "true"), + new KeyValuePair("build_property.EnableAotAnalyzer", "true"), + }); + var analyzerOptions = new AnalyzerOptions( + ImmutableArray.Empty, + new SimpleAnalyzerConfigOptionsProvider(globalOptions)); + var options = new CompilationWithAnalyzersOptions( + analyzerOptions, + onAnalyzerException: null, + concurrentAnalysis: true, + logAnalyzerExecutionTime: false, + reportSuppressedDiagnostics: true); + + return await new CompilationWithAnalyzers(compilation, analyzers, options) + .GetAllDiagnosticsAsync(); + } + + private sealed class SimpleAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider + { + private readonly SimpleOptions _globalOptions; + + public SimpleAnalyzerConfigOptionsProvider(ImmutableDictionary globalOptions) + { + _globalOptions = new SimpleOptions(globalOptions); + } + + public override AnalyzerConfigOptions GlobalOptions => _globalOptions; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => SimpleOptions.Empty; + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => SimpleOptions.Empty; + + private sealed class SimpleOptions : AnalyzerConfigOptions + { + public static readonly SimpleOptions Empty = new(ImmutableDictionary.Empty); + + private readonly ImmutableDictionary _dict; + public SimpleOptions(ImmutableDictionary dict) => _dict = dict; + +#pragma warning disable 8765 // Nullability of parameter doesn't match overridden member + public override bool TryGetValue(string key, out string? value) => _dict.TryGetValue(key, out value); +#pragma warning restore 8765 + } + } } } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests.csproj b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests.csproj index ddc4d17d10f019..627380b20e1228 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent);$(NetFrameworkCurrent) @@ -46,6 +46,7 @@ + From 328517e4e2c24f47cf2ff32047e186dcfa3422a9 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Tue, 14 Apr 2026 13:42:02 +0200 Subject: [PATCH 02/10] Test that intercepted and suppressed locations are the same --- .../GeneratorTests.Helpers.cs | 1 + .../SourceGenerationTests/GeneratorTests.cs | 88 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs index 1e245d516d2653..5c41c1f68d82a1 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs @@ -99,6 +99,7 @@ private static async Task VerifyThatSourceIsGenerated(string testSourceCode) Assert.NotNull(source); Assert.Empty(result.Diagnostics); Assert.True(source.Value.SourceText.Lines.Count > 10); + await VerifySuppressedCallsMatchInterceptedCalls(result); } private static bool s_initializedInterceptorVersion; 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 0ce8f294d62a69..8e042f6d908339 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -577,6 +577,8 @@ public class MyOptions Assert.Contains(diagnostics, d => d.Id == "IL2026" && d.IsSuppressed); Assert.Contains(diagnostics, d => d.Id == "IL3050" && d.IsSuppressed); Assert.DoesNotContain(diagnostics, d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed); + + await VerifySuppressedCallsMatchInterceptedCalls(result); } /// @@ -612,6 +614,92 @@ public class MyOptions Assert.Contains(diagnostics, d => d.Id == "IL2026" && d.IsSuppressed); Assert.Contains(diagnostics, d => d.Id == "IL3050" && d.IsSuppressed); Assert.DoesNotContain(diagnostics, d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed); + + await VerifySuppressedCallsMatchInterceptedCalls(result); + } + + /// + /// Verifies that the set of IL2026/IL3050 diagnostics suppressed by the suppressor + /// matches exactly the set of calls intercepted by the source generator. + /// Catches both under-suppression (https://github.com/dotnet/runtime/issues/94544) + /// and over-suppression (https://github.com/dotnet/runtime/issues/96643). + /// + private static async Task VerifySuppressedCallsMatchInterceptedCalls(ConfigBindingGenRunResult result) + { + Assert.NotNull(result.GenerationSpec); + + // Collect all intercepted line numbers from the generator spec. + HashSet interceptedLines = GetInterceptedLines(result.GenerationSpec); + Assert.NotEmpty(interceptedLines); + + // Run the ILLink analyzer + suppressor on the input compilation (pre-interceptor). + ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.InputCompilation); + + // Every suppressed IL2026/IL3050 diagnostic should be on an intercepted line. + foreach (Diagnostic d in diagnostics.Where(d => (d.Id is "IL2026" or "IL3050") && d.IsSuppressed)) + { + int line = d.Location.GetLineSpan().StartLinePosition.Line + 1; + Assert.True(interceptedLines.Contains(line), + $"Suppressed {d.Id} at line {line} but no interceptor was generated for that call site."); + } + + // Every intercepted line should have its IL2026/IL3050 diagnostics suppressed. + var unsuppressed = diagnostics.Where(d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed).ToList(); + foreach (Diagnostic d in unsuppressed) + { + int line = d.Location.GetLineSpan().StartLinePosition.Line + 1; + Assert.False(interceptedLines.Contains(line), + $"Unsuppressed {d.Id} at line {line} but an interceptor was generated for that call site."); + } + } + + private static HashSet GetInterceptedLines(SourceGenerationSpec spec) + { + var lines = new HashSet(); + InterceptorInfo info = spec.InterceptorInfo; + + AddLocations(info.ConfigBinder); + AddTypedLocations(info.ConfigBinder_Bind_instance); + AddTypedLocations(info.ConfigBinder_Bind_instance_BinderOptions); + AddTypedLocations(info.ConfigBinder_Bind_key_instance); + + return lines; + + void AddLocations(IEnumerable? locationInfos) + { + if (locationInfos is null) + return; + + foreach (InvocationLocationInfo loc in locationInfos) + { + lines.Add(GetLineNumber(loc)); + } + } + + void AddTypedLocations(IEnumerable? typedInfos) + { + if (typedInfos is null) + return; + + foreach (TypedInterceptorInvocationInfo typed in typedInfos) + { + AddLocations(typed.Locations); + } + } + } + + private static int GetLineNumber(InvocationLocationInfo loc) + { + if (loc.LineNumber != 0) + { + return loc.LineNumber; + } + + // v1 interceptor: parse line from display location, e.g. "path(line,col)" + string display = loc.InterceptableLocationGetDisplayLocation(); + int parenIndex = display.LastIndexOf('('); + int commaIndex = display.IndexOf(',', parenIndex); + return int.Parse(display.Substring(parenIndex + 1, commaIndex - parenIndex - 1)); } private static async Task> GetDiagnosticsWithSuppressor(Compilation compilation) From 76fec6bd32e38b9a0506984ae5c347a0a72e9c06 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Tue, 14 Apr 2026 16:08:18 +0200 Subject: [PATCH 03/10] Only suppress warnings for calls that were intercepted --- ...onfigurationBindingGenerator.Suppressor.cs | 155 ++++++++++++++++-- .../ConfigBindingGenTestDriver.cs | 7 - .../GeneratorTests.Helpers.cs | 2 + .../SourceGenerationTests/GeneratorTests.cs | 8 +- 4 files changed, 147 insertions(+), 25 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs index a1691c96d392e0..19ffe1eef82fc8 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs @@ -1,11 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; namespace Microsoft.Extensions.Configuration.Binder.SourceGeneration { @@ -35,6 +39,9 @@ public sealed class Suppressor : DiagnosticSuppressor public override void ReportSuppressions(SuppressionAnalysisContext context) { + // Lazily built set of locations that were actually intercepted by the generator. + HashSet? interceptedLocationKeys = null; + foreach (Diagnostic diagnostic in context.ReportedDiagnostics) { string diagnosticId = diagnostic.Id; @@ -54,22 +61,142 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) // Use getInnermostNodeForTie to handle the case where the binding call is an // argument to another method (e.g. Some.Method(config.Get())). In that case, // ArgumentSyntax and the inner InvocationExpressionSyntax can share the same span. - bool shouldSuppressDiagnostic = - location.SourceTree is SyntaxTree sourceTree && - sourceTree.GetRoot().FindNode(location.SourceSpan, getInnermostNodeForTie: true) is SyntaxNode syntaxNode && - (syntaxNode as InvocationExpressionSyntax ?? syntaxNode.Parent as InvocationExpressionSyntax) is InvocationExpressionSyntax invocation && - BinderInvocation.IsCandidateSyntaxNode(invocation) && - context.GetSemanticModel(sourceTree) - .GetOperation(invocation, context.CancellationToken) is IInvocationOperation operation && - BinderInvocation.IsBindingOperation(operation); - - if (shouldSuppressDiagnostic) + if (location.SourceTree is not SyntaxTree sourceTree) + { + continue; + } + + SyntaxNode syntaxNode = sourceTree.GetRoot().FindNode(location.SourceSpan, getInnermostNodeForTie: true); + if ((syntaxNode as InvocationExpressionSyntax ?? syntaxNode.Parent as InvocationExpressionSyntax) is not InvocationExpressionSyntax invocation) + { + continue; + } + + if (!BinderInvocation.IsCandidateSyntaxNode(invocation)) + { + continue; + } + + SemanticModel semanticModel = context.GetSemanticModel(sourceTree); + if (semanticModel.GetOperation(invocation, context.CancellationToken) is not IInvocationOperation operation || + !BinderInvocation.IsBindingOperation(operation)) + { + continue; + } + + // Only suppress if the generator actually intercepted this call site. + // The generator may skip interception for unsupported types (https://github.com/dotnet/runtime/issues/96643). + interceptedLocationKeys ??= CollectInterceptedLocationKeys(context.Compilation); + + string? locationKey = GetInvocationLocationKey(invocation, semanticModel); + if (locationKey is null || !interceptedLocationKeys.Contains(locationKey)) + { + continue; + } + + SuppressionDescriptor targetSuppression = diagnosticId == RUCDiagnostic.SuppressedDiagnosticId + ? RUCDiagnostic + : RDCDiagnostic; + context.ReportSuppression(Suppression.Create(targetSuppression, diagnostic)); + } + } + + /// + /// Scans the generated source trees for [InterceptsLocation] attributes and collects + /// all intercepted locations. For v0, locations are (filePath, line, column) tuples. + /// For v1, locations are the encoded data strings from the attribute. + /// + private static HashSet CollectInterceptedLocationKeys(Compilation compilation) + { + var keys = new HashSet(); + + foreach (SyntaxTree tree in compilation.SyntaxTrees) + { + if (!tree.FilePath.EndsWith("BindingExtensions.g.cs")) + { + continue; + } + + SyntaxNode root = tree.GetRoot(); + foreach (AttributeSyntax attr in root.DescendantNodes().OfType()) + { + if (attr.Name.ToString() != "InterceptsLocation") + { + continue; + } + + AttributeArgumentListSyntax? argList = attr.ArgumentList; + if (argList is null) + { + continue; + } + + SeparatedSyntaxList args = argList.Arguments; + + if (InterceptorVersion == 0) + { + // v0 format: [InterceptsLocation("filePath", line, column)] + if (args.Count == 3 && + args[0].Expression is LiteralExpressionSyntax filePathLiteral && + filePathLiteral.IsKind(SyntaxKind.StringLiteralExpression) && + args[1].Expression is LiteralExpressionSyntax lineLiteral && + lineLiteral.IsKind(SyntaxKind.NumericLiteralExpression) && + args[2].Expression is LiteralExpressionSyntax colLiteral && + colLiteral.IsKind(SyntaxKind.NumericLiteralExpression)) + { + keys.Add(MakeV0Key(filePathLiteral.Token.ValueText, (int)lineLiteral.Token.Value!, (int)colLiteral.Token.Value!)); + } + } + else + { + // v1 format: [InterceptsLocation(version, "data")] + if (args.Count == 2 && + args[1].Expression is LiteralExpressionSyntax dataLiteral && + dataLiteral.IsKind(SyntaxKind.StringLiteralExpression)) + { + keys.Add(MakeV1Key(dataLiteral.Token.ValueText)); + } + } + } + } + + return keys; + } + + private static string MakeV0Key(string filePath, int line, int column) => $"{filePath}|{line}|{column}"; + + private static string MakeV1Key(string data) => data; + + private static string? GetInvocationLocationKey(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return null; + } + + if (InterceptorVersion == 0) + { + SyntaxTree syntaxTree = memberAccess.SyntaxTree; + TextSpan nameSpan = memberAccess.Name.Span; + FileLinePositionSpan lineSpan = syntaxTree.GetLineSpan(nameSpan); + + string filePath; + SourceReferenceResolver? resolver = semanticModel.Compilation.Options.SourceReferenceResolver; + filePath = resolver?.NormalizePath(syntaxTree.FilePath, baseFilePath: null) ?? syntaxTree.FilePath; + + return MakeV0Key(filePath, lineSpan.StartLinePosition.Line + 1, lineSpan.StartLinePosition.Character + 1); + } + else + { + object? interceptableLocation = GetInterceptableLocationFunc?.Invoke(semanticModel, invocation, default); + if (interceptableLocation is null) { - SuppressionDescriptor targetSuppression = diagnosticId == RUCDiagnostic.SuppressedDiagnosticId - ? RUCDiagnostic - : RDCDiagnostic; - context.ReportSuppression(Suppression.Create(targetSuppression, diagnostic)); + return null; } + + string data = (string)InterceptableLocationDataGetter!.Invoke(interceptableLocation, parameters: null)!; + + return MakeV1Key(data); } } } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/ConfigBindingGenTestDriver.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/ConfigBindingGenTestDriver.cs index 1233c890a48d73..b15815d72527fc 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/ConfigBindingGenTestDriver.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/ConfigBindingGenTestDriver.cs @@ -62,7 +62,6 @@ public async Task RunGeneratorAndUpdateCompilation(st return new ConfigBindingGenRunResult { - InputCompilation = _compilation, OutputCompilation = outputCompilation, Diagnostics = runResult.Diagnostics, GeneratedSource = runResult.Results[0].GeneratedSources is { Length: not 0 } sources ? sources[0] : null, @@ -97,12 +96,6 @@ private async Task UpdateCompilationWithSource(string? source = null) internal struct ConfigBindingGenRunResult { - /// - /// The compilation before source generation, used for running analyzers/suppressors - /// which need to see the original (non-intercepted) method resolution. - /// - public Compilation InputCompilation { get; init; } - public Compilation OutputCompilation { get; init; } public GeneratedSourceResult? GeneratedSource { get; init; } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs index 5c41c1f68d82a1..9dd14fd5dc2886 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs @@ -175,6 +175,8 @@ private static async Task VerifyAgainstBaselineUsingF Assert.True(resultEqualsBaseline, errorMessage); + await VerifySuppressedCallsMatchInterceptedCalls(result); + return result; } 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 8e042f6d908339..8fc0f3adefc896 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -572,7 +572,7 @@ public class MyOptions ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); Assert.NotNull(result.GeneratedSource); - ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.InputCompilation); + ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); Assert.Contains(diagnostics, d => d.Id == "IL2026" && d.IsSuppressed); Assert.Contains(diagnostics, d => d.Id == "IL3050" && d.IsSuppressed); @@ -609,7 +609,7 @@ public class MyOptions ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); Assert.NotNull(result.GeneratedSource); - ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.InputCompilation); + ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); Assert.Contains(diagnostics, d => d.Id == "IL2026" && d.IsSuppressed); Assert.Contains(diagnostics, d => d.Id == "IL3050" && d.IsSuppressed); @@ -632,8 +632,8 @@ private static async Task VerifySuppressedCallsMatchInterceptedCalls(ConfigBindi HashSet interceptedLines = GetInterceptedLines(result.GenerationSpec); Assert.NotEmpty(interceptedLines); - // Run the ILLink analyzer + suppressor on the input compilation (pre-interceptor). - ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.InputCompilation); + // Run the ILLink analyzer + suppressor on the output compilation (which includes generated InterceptsLocation attributes). + ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); // Every suppressed IL2026/IL3050 diagnostic should be on an intercepted line. foreach (Diagnostic d in diagnostics.Where(d => (d.Id is "IL2026" or "IL3050") && d.IsSuppressed)) From a82d0e818d2e2dd5f9b8e1888556a8cfee82e4bb Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Wed, 15 Apr 2026 17:39:36 +0200 Subject: [PATCH 04/10] Addressed review comments --- .../gen/ConfigurationBindingGenerator.Suppressor.cs | 11 ++++++----- .../gen/Specs/InterceptorInfo.cs | 4 ++-- .../tests/SourceGenerationTests/GeneratorTests.cs | 2 ++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs index 19ffe1eef82fc8..5ace6b0ae50a84 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -88,7 +89,7 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) // The generator may skip interception for unsupported types (https://github.com/dotnet/runtime/issues/96643). interceptedLocationKeys ??= CollectInterceptedLocationKeys(context.Compilation); - string? locationKey = GetInvocationLocationKey(invocation, semanticModel); + string? locationKey = GetInvocationLocationKey(invocation, semanticModel, context.CancellationToken); if (locationKey is null || !interceptedLocationKeys.Contains(locationKey)) { continue; @@ -112,7 +113,7 @@ private static HashSet CollectInterceptedLocationKeys(Compilation compil foreach (SyntaxTree tree in compilation.SyntaxTrees) { - if (!tree.FilePath.EndsWith("BindingExtensions.g.cs")) + if (!tree.FilePath.EndsWith("BindingExtensions.g.cs", System.StringComparison.Ordinal)) { continue; } @@ -167,7 +168,7 @@ args[1].Expression is LiteralExpressionSyntax dataLiteral && private static string MakeV1Key(string data) => data; - private static string? GetInvocationLocationKey(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + private static string? GetInvocationLocationKey(InvocationExpressionSyntax invocation, SemanticModel semanticModel, CancellationToken cancellationToken) { if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) { @@ -178,7 +179,7 @@ args[1].Expression is LiteralExpressionSyntax dataLiteral && { SyntaxTree syntaxTree = memberAccess.SyntaxTree; TextSpan nameSpan = memberAccess.Name.Span; - FileLinePositionSpan lineSpan = syntaxTree.GetLineSpan(nameSpan); + FileLinePositionSpan lineSpan = syntaxTree.GetLineSpan(nameSpan, cancellationToken); string filePath; SourceReferenceResolver? resolver = semanticModel.Compilation.Options.SourceReferenceResolver; @@ -188,7 +189,7 @@ args[1].Expression is LiteralExpressionSyntax dataLiteral && } else { - object? interceptableLocation = GetInterceptableLocationFunc?.Invoke(semanticModel, invocation, default); + object? interceptableLocation = GetInterceptableLocationFunc?.Invoke(semanticModel, invocation, cancellationToken); if (interceptableLocation is null) { return null; diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/InterceptorInfo.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/InterceptorInfo.cs index 5b4f903db7c210..a1e20eb7a796a3 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/InterceptorInfo.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/InterceptorInfo.cs @@ -33,7 +33,7 @@ public sealed record InterceptorInfo Debug.Assert((MethodsToGen.ConfigBinder_Bind & interceptor) is 0); ImmutableEquatableArray? infoList; - if ((MethodsToGen.ConfigBinder_Any ^ MethodsToGen.ConfigBinder_Bind & interceptor) is not 0) + if (((MethodsToGen.ConfigBinder_Any & ~MethodsToGen.ConfigBinder_Bind) & interceptor) is not 0) { infoList = ConfigBinder; } @@ -92,7 +92,7 @@ public void RegisterInterceptor(MethodsToGen overload, IInvocationOperation oper { Debug.Assert((MethodsToGen.ConfigBinder_Bind & overload) is 0); - if ((MethodsToGen.ConfigBinder_Any ^ MethodsToGen.ConfigBinder_Bind & overload) is not 0) + if (((MethodsToGen.ConfigBinder_Any & ~MethodsToGen.ConfigBinder_Bind) & overload) is not 0) { RegisterInterceptor(ref _interceptors_configBinder); } 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 8fc0f3adefc896..7bd65eded35680 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -659,6 +659,8 @@ private static HashSet GetInterceptedLines(SourceGenerationSpec spec) InterceptorInfo info = spec.InterceptorInfo; AddLocations(info.ConfigBinder); + AddLocations(info.OptionsBuilderExt); + AddLocations(info.ServiceCollectionExt); AddTypedLocations(info.ConfigBinder_Bind_instance); AddTypedLocations(info.ConfigBinder_Bind_instance_BinderOptions); AddTypedLocations(info.ConfigBinder_Bind_key_instance); From 60632829dc8d5e7f8ae334a19e3bd0a3b17cccbf Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Thu, 16 Apr 2026 11:49:03 +0200 Subject: [PATCH 05/10] Improved tests --- .../tests/SourceGenerationTests/GeneratorTests.cs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 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 7bd65eded35680..06032ecf8ace02 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -574,10 +574,6 @@ public class MyOptions ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); - Assert.Contains(diagnostics, d => d.Id == "IL2026" && d.IsSuppressed); - Assert.Contains(diagnostics, d => d.Id == "IL3050" && d.IsSuppressed); - Assert.DoesNotContain(diagnostics, d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed); - await VerifySuppressedCallsMatchInterceptedCalls(result); } @@ -611,10 +607,6 @@ public class MyOptions ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); - Assert.Contains(diagnostics, d => d.Id == "IL2026" && d.IsSuppressed); - Assert.Contains(diagnostics, d => d.Id == "IL3050" && d.IsSuppressed); - Assert.DoesNotContain(diagnostics, d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed); - await VerifySuppressedCallsMatchInterceptedCalls(result); } @@ -635,6 +627,10 @@ private static async Task VerifySuppressedCallsMatchInterceptedCalls(ConfigBindi // Run the ILLink analyzer + suppressor on the output compilation (which includes generated InterceptsLocation attributes). ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); + // The ILLink analyzer must have produced at least one IL2026 or IL3050 that was suppressed. + // Without this, the assertions below would pass vacuously if the analyzer didn't fire. + Assert.Contains(diagnostics, d => (d.Id is "IL2026" or "IL3050") && d.IsSuppressed); + // Every suppressed IL2026/IL3050 diagnostic should be on an intercepted line. foreach (Diagnostic d in diagnostics.Where(d => (d.Id is "IL2026" or "IL3050") && d.IsSuppressed)) { @@ -644,8 +640,7 @@ private static async Task VerifySuppressedCallsMatchInterceptedCalls(ConfigBindi } // Every intercepted line should have its IL2026/IL3050 diagnostics suppressed. - var unsuppressed = diagnostics.Where(d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed).ToList(); - foreach (Diagnostic d in unsuppressed) + foreach (Diagnostic d in diagnostics.Where(d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed)) { int line = d.Location.GetLineSpan().StartLinePosition.Line + 1; Assert.False(interceptedLines.Contains(line), From 5a0e3651810bb44d437a45f742aa4132e7c9c978 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Thu, 16 Apr 2026 11:53:13 +0200 Subject: [PATCH 06/10] Pass CancellationToken through --- .../gen/ConfigurationBindingGenerator.Suppressor.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs index 5ace6b0ae50a84..377a89575db5c7 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs @@ -67,7 +67,7 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) continue; } - SyntaxNode syntaxNode = sourceTree.GetRoot().FindNode(location.SourceSpan, getInnermostNodeForTie: true); + SyntaxNode syntaxNode = sourceTree.GetRoot(context.CancellationToken).FindNode(location.SourceSpan, getInnermostNodeForTie: true); if ((syntaxNode as InvocationExpressionSyntax ?? syntaxNode.Parent as InvocationExpressionSyntax) is not InvocationExpressionSyntax invocation) { continue; @@ -87,7 +87,7 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) // Only suppress if the generator actually intercepted this call site. // The generator may skip interception for unsupported types (https://github.com/dotnet/runtime/issues/96643). - interceptedLocationKeys ??= CollectInterceptedLocationKeys(context.Compilation); + interceptedLocationKeys ??= CollectInterceptedLocationKeys(context.Compilation, context.CancellationToken); string? locationKey = GetInvocationLocationKey(invocation, semanticModel, context.CancellationToken); if (locationKey is null || !interceptedLocationKeys.Contains(locationKey)) @@ -107,7 +107,7 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) /// all intercepted locations. For v0, locations are (filePath, line, column) tuples. /// For v1, locations are the encoded data strings from the attribute. /// - private static HashSet CollectInterceptedLocationKeys(Compilation compilation) + private static HashSet CollectInterceptedLocationKeys(Compilation compilation, CancellationToken cancellationToken) { var keys = new HashSet(); @@ -118,7 +118,7 @@ private static HashSet CollectInterceptedLocationKeys(Compilation compil continue; } - SyntaxNode root = tree.GetRoot(); + SyntaxNode root = tree.GetRoot(cancellationToken); foreach (AttributeSyntax attr in root.DescendantNodes().OfType()) { if (attr.Name.ToString() != "InterceptsLocation") From 84be7327f4b6c5a309c67fa33d6c6a3d28c58ed2 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Thu, 16 Apr 2026 15:10:38 +0200 Subject: [PATCH 07/10] Add column macthing to tests --- .../SourceGenerationTests/GeneratorTests.cs | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 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 06032ecf8ace02..161f1065608b99 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -9,10 +9,12 @@ using System.Linq; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using ILLink.RoslynAnalyzer; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.Binder.SourceGeneration; @@ -620,9 +622,10 @@ private static async Task VerifySuppressedCallsMatchInterceptedCalls(ConfigBindi { Assert.NotNull(result.GenerationSpec); - // Collect all intercepted line numbers from the generator spec. - HashSet interceptedLines = GetInterceptedLines(result.GenerationSpec); - Assert.NotEmpty(interceptedLines); + // Collect all intercepted (line, column) locations from the generator spec. + // The interceptor targets MemberAccessExpression.Name (e.g. "Get" in "c.Get()"). + HashSet<(int Line, int Column)> interceptedLocations = GetInterceptedLocations(result.GenerationSpec); + Assert.NotEmpty(interceptedLocations); // Run the ILLink analyzer + suppressor on the output compilation (which includes generated InterceptsLocation attributes). ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); @@ -631,26 +634,50 @@ private static async Task VerifySuppressedCallsMatchInterceptedCalls(ConfigBindi // Without this, the assertions below would pass vacuously if the analyzer didn't fire. Assert.Contains(diagnostics, d => (d.Id is "IL2026" or "IL3050") && d.IsSuppressed); - // Every suppressed IL2026/IL3050 diagnostic should be on an intercepted line. + // Every suppressed IL2026/IL3050 diagnostic should be at an intercepted location. foreach (Diagnostic d in diagnostics.Where(d => (d.Id is "IL2026" or "IL3050") && d.IsSuppressed)) { - int line = d.Location.GetLineSpan().StartLinePosition.Line + 1; - Assert.True(interceptedLines.Contains(line), - $"Suppressed {d.Id} at line {line} but no interceptor was generated for that call site."); + (int line, int column) = GetMethodNameLocation(d); + Assert.True(interceptedLocations.Contains((line, column)), + $"Suppressed {d.Id} at ({line},{column}) but no interceptor was generated for that call site."); } - // Every intercepted line should have its IL2026/IL3050 diagnostics suppressed. + // Every intercepted location should have its IL2026/IL3050 diagnostics suppressed. foreach (Diagnostic d in diagnostics.Where(d => (d.Id is "IL2026" or "IL3050") && !d.IsSuppressed)) { - int line = d.Location.GetLineSpan().StartLinePosition.Line + 1; - Assert.False(interceptedLines.Contains(line), - $"Unsuppressed {d.Id} at line {line} but an interceptor was generated for that call site."); + (int line, int column) = GetMethodNameLocation(d); + Assert.False(interceptedLocations.Contains((line, column)), + $"Unsuppressed {d.Id} at ({line},{column}) but an interceptor was generated for that call site."); } } - private static HashSet GetInterceptedLines(SourceGenerationSpec spec) + /// + /// Resolves a diagnostic's location to the method name position that the interceptor targets. + /// The ILLink analyzer reports on the MemberAccessExpression (e.g. "c.Get<T>"), + /// but the interceptor targets just the Name part (e.g. "Get"). This method walks from + /// the diagnostic location to the InvocationExpression's MemberAccessExpression.Name + /// to get the matching (line, column). + /// + private static (int Line, int Column) GetMethodNameLocation(Diagnostic diagnostic) { - var lines = new HashSet(); + Location location = diagnostic.AdditionalLocations.Count > 0 + ? diagnostic.AdditionalLocations[0] + : diagnostic.Location; + SyntaxTree sourceTree = location.SourceTree!; + SyntaxNode node = sourceTree.GetRoot().FindNode(location.SourceSpan, getInnermostNodeForTie: true); + + InvocationExpressionSyntax invocation = (node as InvocationExpressionSyntax + ?? node.Parent as InvocationExpressionSyntax)!; + + var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression; + FileLinePositionSpan nameSpan = sourceTree.GetLineSpan(memberAccess.Name.Span); + + return (nameSpan.StartLinePosition.Line + 1, nameSpan.StartLinePosition.Character + 1); + } + + private static HashSet<(int Line, int Column)> GetInterceptedLocations(SourceGenerationSpec spec) + { + var locations = new HashSet<(int, int)>(); InterceptorInfo info = spec.InterceptorInfo; AddLocations(info.ConfigBinder); @@ -660,7 +687,7 @@ private static HashSet GetInterceptedLines(SourceGenerationSpec spec) AddTypedLocations(info.ConfigBinder_Bind_instance_BinderOptions); AddTypedLocations(info.ConfigBinder_Bind_key_instance); - return lines; + return locations; void AddLocations(IEnumerable? locationInfos) { @@ -669,7 +696,7 @@ void AddLocations(IEnumerable? locationInfos) foreach (InvocationLocationInfo loc in locationInfos) { - lines.Add(GetLineNumber(loc)); + locations.Add(GetLocation(loc)); } } @@ -685,18 +712,20 @@ void AddTypedLocations(IEnumerable? typedInfos) } } - private static int GetLineNumber(InvocationLocationInfo loc) + private static (int Line, int Column) GetLocation(InvocationLocationInfo loc) { if (loc.LineNumber != 0) { - return loc.LineNumber; + return (loc.LineNumber, loc.CharacterNumber); } - // v1 interceptor: parse line from display location, e.g. "path(line,col)" + // v1 interceptor: parse from display location, e.g. "path(line,col)" string display = loc.InterceptableLocationGetDisplayLocation(); - int parenIndex = display.LastIndexOf('('); - int commaIndex = display.IndexOf(',', parenIndex); - return int.Parse(display.Substring(parenIndex + 1, commaIndex - parenIndex - 1)); + Match match = Regex.Match(display, @"\((\d+),(\d+)\)$"); + Assert.True(match.Success, $"Could not parse display location: {display}"); + + return (int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); } private static async Task> GetDiagnosticsWithSuppressor(Compilation compilation) From f0ce15af686be8f4a351868e4ea3eed4103aab8b Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Thu, 16 Apr 2026 15:17:43 +0200 Subject: [PATCH 08/10] Remove unnecessary code --- .../tests/SourceGenerationTests/GeneratorTests.cs | 4 ---- 1 file changed, 4 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 161f1065608b99..e4161db0d77aab 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -574,8 +574,6 @@ public class MyOptions ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); Assert.NotNull(result.GeneratedSource); - ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); - await VerifySuppressedCallsMatchInterceptedCalls(result); } @@ -607,8 +605,6 @@ public class MyOptions ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); Assert.NotNull(result.GeneratedSource); - ImmutableArray diagnostics = await GetDiagnosticsWithSuppressor(result.OutputCompilation); - await VerifySuppressedCallsMatchInterceptedCalls(result); } From 5fa5d9957e4e14177ee92c28b0b59303ae3466ce Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Thu, 16 Apr 2026 16:51:49 +0200 Subject: [PATCH 09/10] Small test improvements --- ...onfigurationBindingGenerator.Suppressor.cs | 1 + .../SourceGenerationTests/GeneratorTests.cs | 44 +++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs index 377a89575db5c7..3806567ca6ac54 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/ConfigurationBindingGenerator.Suppressor.cs @@ -121,6 +121,7 @@ private static HashSet CollectInterceptedLocationKeys(Compilation compil SyntaxNode root = tree.GetRoot(cancellationToken); foreach (AttributeSyntax attr in root.DescendantNodes().OfType()) { + // Matching the name like this is somewhat brittle, but it's okay as long as we match what the generator emits. if (attr.Name.ToString() != "InterceptsLocation") { continue; 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 e4161db0d77aab..3c2677e2ccea81 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -545,7 +545,7 @@ public static void Main() /// is passed directly as a method argument (e.g. Some.Method(config.Get<T>())). /// Regression test for https://github.com/dotnet/runtime/issues/94544. /// - [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + [Fact] public async Task Suppressor_SuppressesWarnings_WhenBindingCallIsMethodArgument() { string source = """ @@ -581,7 +581,7 @@ public class MyOptions /// Verifies that the suppressor also works for the straightforward assignment case, /// ensuring no regression in existing behavior. /// - [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + [Fact] public async Task Suppressor_SuppressesWarnings_ForSimpleBindingCall() { string source = """ @@ -608,6 +608,35 @@ public class MyOptions await VerifySuppressedCallsMatchInterceptedCalls(result); } + [Fact] + public async Task Suppressor_SuppressesWarnings_WithLineDirective() + { + string source = """ + using Microsoft.Extensions.Configuration; + + public class Program + { + public static void Main() + { + IConfigurationSection c = new ConfigurationBuilder().Build().GetSection("Options"); + #line 100 "Remapped.cs" + var options = c.Get(); + #line default + } + } + + public class MyOptions + { + public int MaxRetries { get; set; } + } + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source); + Assert.NotNull(result.GeneratedSource); + + await VerifySuppressedCallsMatchInterceptedCalls(result); + } + /// /// Verifies that the set of IL2026/IL3050 diagnostics suppressed by the suppressor /// matches exactly the set of calls intercepted by the source generator. @@ -730,13 +759,12 @@ private static async Task> GetDiagnosticsWithSuppress new DynamicallyAccessedMembersAnalyzer(), new ConfigurationBindingGenerator.Suppressor()); - var globalOptions = ImmutableDictionary.CreateRange( + var globalOptions = ImmutableDictionary.CreateRange( StringComparer.OrdinalIgnoreCase, - new[] - { - new KeyValuePair("build_property.EnableTrimAnalyzer", "true"), - new KeyValuePair("build_property.EnableAotAnalyzer", "true"), - }); + [ + new("build_property.EnableTrimAnalyzer", "true"), + new("build_property.EnableAotAnalyzer", "true"), + ]); var analyzerOptions = new AnalyzerOptions( ImmutableArray.Empty, new SimpleAnalyzerConfigOptionsProvider(globalOptions)); From de91ac3656dbd304f75f6642bdef02eb20066d03 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Thu, 16 Apr 2026 17:04:55 +0200 Subject: [PATCH 10/10] Simplified AnalyzerConfigOptions usage in tests --- .../GlobalOptionsOnlyProvider.cs | 34 ++++++++------- .../SourceGenerationTests/GeneratorTests.cs | 43 ++++--------------- ...ation.Binder.SourceGeneration.Tests.csproj | 1 + .../ComInterfaceGenerator.Unit.Tests.csproj | 1 - .../LibraryImportGenerator.Unit.Tests.csproj | 1 - .../ILLink.RoslynAnalyzer.Tests.csproj | 1 + .../TestCaseCompilation.cs | 39 ++++------------- 7 files changed, 37 insertions(+), 83 deletions(-) diff --git a/src/libraries/Common/tests/SourceGenerators/GlobalOptionsOnlyProvider.cs b/src/libraries/Common/tests/SourceGenerators/GlobalOptionsOnlyProvider.cs index 09b8b86af77d02..c44a9bf04ec75a 100644 --- a/src/libraries/Common/tests/SourceGenerators/GlobalOptionsOnlyProvider.cs +++ b/src/libraries/Common/tests/SourceGenerators/GlobalOptionsOnlyProvider.cs @@ -1,15 +1,10 @@ // 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.Diagnostics; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; -using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.Interop; -using Microsoft.Interop.UnitTests; -using SourceGenerators.Tests; namespace SourceGenerators.Tests { @@ -28,23 +23,30 @@ public GlobalOptionsOnlyProvider(AnalyzerConfigOptions globalOptions) public sealed override AnalyzerConfigOptions GetOptions(SyntaxTree tree) { - return EmptyOptions.Instance; + return DictionaryAnalyzerConfigOptions.Empty; } public sealed override AnalyzerConfigOptions GetOptions(AdditionalText textFile) { - return EmptyOptions.Instance; + return DictionaryAnalyzerConfigOptions.Empty; } + } - private sealed class EmptyOptions : AnalyzerConfigOptions - { - public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) - { - value = null; - return false; - } + /// + /// An implementation of backed by an . + /// + internal sealed class DictionaryAnalyzerConfigOptions : AnalyzerConfigOptions + { + public static readonly DictionaryAnalyzerConfigOptions Empty = new(ImmutableDictionary.Empty); + + private readonly ImmutableDictionary _options; - public static AnalyzerConfigOptions Instance = new EmptyOptions(); + public DictionaryAnalyzerConfigOptions(ImmutableDictionary options) + { + _options = options; } + + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + => _options.TryGetValue(key, out value); } } 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 3c2677e2ccea81..d74ac253e616eb 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -20,6 +20,7 @@ using Microsoft.Extensions.Configuration.Binder.SourceGeneration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using SourceGenerators.Tests; using Xunit; namespace Microsoft.Extensions.SourceGeneration.Configuration.Binder.Tests @@ -759,15 +760,16 @@ private static async Task> GetDiagnosticsWithSuppress new DynamicallyAccessedMembersAnalyzer(), new ConfigurationBindingGenerator.Suppressor()); - var globalOptions = ImmutableDictionary.CreateRange( - StringComparer.OrdinalIgnoreCase, - [ - new("build_property.EnableTrimAnalyzer", "true"), - new("build_property.EnableAotAnalyzer", "true"), - ]); + var trimAotAnalyzerOptions = new DictionaryAnalyzerConfigOptions( + ImmutableDictionary.CreateRange( + StringComparer.OrdinalIgnoreCase, + [ + new("build_property.EnableTrimAnalyzer", "true"), + new("build_property.EnableAotAnalyzer", "true"), + ])); var analyzerOptions = new AnalyzerOptions( ImmutableArray.Empty, - new SimpleAnalyzerConfigOptionsProvider(globalOptions)); + new GlobalOptionsOnlyProvider(trimAotAnalyzerOptions)); var options = new CompilationWithAnalyzersOptions( analyzerOptions, onAnalyzerException: null, @@ -778,32 +780,5 @@ private static async Task> GetDiagnosticsWithSuppress return await new CompilationWithAnalyzers(compilation, analyzers, options) .GetAllDiagnosticsAsync(); } - - private sealed class SimpleAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider - { - private readonly SimpleOptions _globalOptions; - - public SimpleAnalyzerConfigOptionsProvider(ImmutableDictionary globalOptions) - { - _globalOptions = new SimpleOptions(globalOptions); - } - - public override AnalyzerConfigOptions GlobalOptions => _globalOptions; - - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => SimpleOptions.Empty; - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => SimpleOptions.Empty; - - private sealed class SimpleOptions : AnalyzerConfigOptions - { - public static readonly SimpleOptions Empty = new(ImmutableDictionary.Empty); - - private readonly ImmutableDictionary _dict; - public SimpleOptions(ImmutableDictionary dict) => _dict = dict; - -#pragma warning disable 8765 // Nullability of parameter doesn't match overridden member - public override bool TryGetValue(string key, out string? value) => _dict.TryGetValue(key, out value); -#pragma warning restore 8765 - } - } } } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests.csproj b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests.csproj index 627380b20e1228..299c8afc31652e 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/src/libraries/System.Runtime.InteropServices/tests/ComInterfaceGenerator.Unit.Tests/ComInterfaceGenerator.Unit.Tests.csproj b/src/libraries/System.Runtime.InteropServices/tests/ComInterfaceGenerator.Unit.Tests/ComInterfaceGenerator.Unit.Tests.csproj index c612c394035dbd..9ce73c44b35c9e 100644 --- a/src/libraries/System.Runtime.InteropServices/tests/ComInterfaceGenerator.Unit.Tests/ComInterfaceGenerator.Unit.Tests.csproj +++ b/src/libraries/System.Runtime.InteropServices/tests/ComInterfaceGenerator.Unit.Tests/ComInterfaceGenerator.Unit.Tests.csproj @@ -11,7 +11,6 @@ - diff --git a/src/libraries/System.Runtime.InteropServices/tests/LibraryImportGenerator.UnitTests/LibraryImportGenerator.Unit.Tests.csproj b/src/libraries/System.Runtime.InteropServices/tests/LibraryImportGenerator.UnitTests/LibraryImportGenerator.Unit.Tests.csproj index 253e99c7fb0832..c86b834a9a2957 100644 --- a/src/libraries/System.Runtime.InteropServices/tests/LibraryImportGenerator.UnitTests/LibraryImportGenerator.Unit.Tests.csproj +++ b/src/libraries/System.Runtime.InteropServices/tests/LibraryImportGenerator.UnitTests/LibraryImportGenerator.Unit.Tests.csproj @@ -15,7 +15,6 @@ - diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj index 184616fc012c10..3f94c61f198d2f 100644 --- a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj +++ b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/TestCaseCompilation.cs b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/TestCaseCompilation.cs index 5ec67fd1f9742e..6e1d5b070749f9 100644 --- a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/TestCaseCompilation.cs +++ b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/TestCaseCompilation.cs @@ -10,6 +10,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; +using SourceGenerators.Tests; namespace ILLink.RoslynAnalyzer.Tests { @@ -87,7 +88,7 @@ public static (CompilationWithAnalyzers Compilation, SemanticModel SemanticModel })); var analyzerOptions = new AnalyzerOptions( additionalFiles: additionalFiles?.ToImmutableArray() ?? ImmutableArray.Empty, - new SimpleAnalyzerOptions(globalAnalyzerOptions)); + new GlobalOptionsOnlyProvider(CreateGlobalOptions(globalAnalyzerOptions))); var exceptionDiagnostics = new List(); @@ -103,40 +104,16 @@ public static (CompilationWithAnalyzers Compilation, SemanticModel SemanticModel return (new CompilationWithAnalyzers(comp, SupportedDiagnosticAnalyzers, compWithAnalyzerOptions), comp.GetSemanticModel(src), exceptionDiagnostics); } - sealed class SimpleAnalyzerOptions : AnalyzerConfigOptionsProvider + private static DictionaryAnalyzerConfigOptions CreateGlobalOptions((string, string)[]? globalOptions) { - public SimpleAnalyzerOptions((string, string)[]? globalOptions) + if (globalOptions is null or { Length: 0 }) { - globalOptions ??= Array.Empty<(string, string)>(); - GlobalOptions = new SimpleAnalyzerConfigOptions(ImmutableDictionary.CreateRange( - StringComparer.OrdinalIgnoreCase, - globalOptions.Select(x => new KeyValuePair(x.Item1, x.Item2)))); + return DictionaryAnalyzerConfigOptions.Empty; } - public override AnalyzerConfigOptions GlobalOptions { get; } - - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) - => SimpleAnalyzerConfigOptions.Empty; - - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) - => SimpleAnalyzerConfigOptions.Empty; - - sealed class SimpleAnalyzerConfigOptions : AnalyzerConfigOptions - { - public static readonly SimpleAnalyzerConfigOptions Empty = new SimpleAnalyzerConfigOptions(ImmutableDictionary.Empty); - - private readonly ImmutableDictionary _dict; - public SimpleAnalyzerConfigOptions(ImmutableDictionary dict) - { - _dict = dict; - } - - // Suppress warning about missing nullable attributes -#pragma warning disable 8765 - public override bool TryGetValue(string key, out string? value) - => _dict.TryGetValue(key, out value); -#pragma warning restore 8765 - } + return new DictionaryAnalyzerConfigOptions(ImmutableDictionary.CreateRange( + StringComparer.OrdinalIgnoreCase, + globalOptions.Select(x => new KeyValuePair(x.Item1, x.Item2)))); } } }