From 219b07c3d35cdcc9ebf03773037620e10865118b Mon Sep 17 00:00:00 2001 From: Teneko Date: Fri, 9 Oct 2026 07:19:07 +0200 Subject: [PATCH 1/4] fix: report analyzer diagnostics per type at in-source locations The analyzer collected all targets and reported every diagnostic from a CompilationEndAction. Locations were rebuilt from stored paths and spans (LocationKind.ExternalFile), so #pragma, [SuppressMessage] and per-file .editorconfig severities never applied, and the IDE did not show the diagnostics while typing. The analyzer now registers a symbol action per named type, builds the model for that type only and runs the shared parser and plan builder on it. Each type reports only the diagnostics located in its own declaration, mapped to the declaring syntax tree. Diagnostics at a matcher are reported when the matcher type itself is analyzed, which also removes duplicates that were reported once per target. MOG002 is now evaluated per target and reported at the typeof reference in Matchers. Its message names the target type. The acceptance fixture reports suppressed diagnostics, because the [SuppressMessage] attributes that declare expected diagnostics now suppress them, and it fails on analyzer exceptions. MOG002 expectations move from the matchers to the targets. Co-Authored-By: Claude Opus 5.5 --- .../Bug/Bug_Class_1.cs | 4 + .../Class_21.cs | 3 +- .../Class_25.cs | 6 +- .../Class_39.cs | 4 + .../Class_40.cs | 4 + .../Class_5.cs | 3 +- .../Generation/OverloadPlanBuilder.cs | 10 +- .../GeneratorDiagnostics.cs | 2 +- .../MethodOverloadsDiagnosticsAnalyzer.cs | 271 ++++++++++++------ .../Models/GeneratorModel.cs | 13 +- .../Parsing/Parser.ModelMapper.cs | 8 +- .../AcceptanceDiagnosticsTests.cs | 6 + ...sts.Diagnostics_HaveNoChanges.verified.txt | 2 +- .../Infrastructure/AcceptanceFixture.cs | 25 +- .../Infrastructure/AcceptanceTestData.cs | 33 +-- 15 files changed, 257 insertions(+), 137 deletions(-) diff --git a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Bug/Bug_Class_1.cs b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Bug/Bug_Class_1.cs index 1f7381c..7b85ce7 100644 --- a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Bug/Bug_Class_1.cs +++ b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Bug/Bug_Class_1.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Tenekon.MethodOverloads.AcceptanceCriterias.Bug; public sealed class Bug_Class_1_Constraint_1; @@ -39,6 +41,8 @@ Bug_Class_1_ReturnType Create( Bug_Class_1_Type_4? param_4); } +// The bucket declares no methods, so the matcher has no match here (MOG002 at the typeof reference). +[SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateMethodOverloads(Matchers = [typeof(Bug_Class_1_ICommandRuntimeFactoryMatchers)])] public static partial class Bug_Class_1_Bucket { diff --git a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_21.cs b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_21.cs index e38ca29..1f89949 100644 --- a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_21.cs +++ b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_21.cs @@ -41,11 +41,12 @@ public static void Parameterless() { } internal interface Class_21_Matcher { - [SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateOverloads(nameof(m))] void MatcherSignature(int m); } +// 2) Matcher without subsequence match (reported at the typeof reference of the target). +[SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateMethodOverloads(Matchers = [typeof(Class_21_Matcher)])] public abstract class Class_21_Matched { diff --git a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_25.cs b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_25.cs index cc428ec..303f595 100644 --- a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_25.cs +++ b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_25.cs @@ -4,14 +4,14 @@ namespace Tenekon.MethodOverloads.AcceptanceCriterias; internal interface Class_25_MatcherUnused { - [SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateOverloads(nameof(param_a))] void Matcher_1(Guid param_a); } /// -/// Unused matcher method should produce MOG002 once per matcher method. +/// Matcher method without a match in this target produces MOG002 at the typeof reference. /// +[SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateMethodOverloads(Matchers = [typeof(Class_25_MatcherUnused)])] public sealed class Class_25_Target { @@ -21,6 +21,7 @@ public void Case_1(int param_1, string? param_2, bool param_3) { } /// /// Mixed matcher set: one matcher applies, one does not (MOG002 expected for the unused one). /// +[SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateMethodOverloads(Matchers = [typeof(Class_25_MatcherMixed)])] public sealed class Class_25_MixedTarget { @@ -32,7 +33,6 @@ internal interface Class_25_MatcherMixed [GenerateOverloads(nameof(param_b))] void Matcher_1(int param_a, string? param_b); - [SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateOverloads(nameof(param_a))] void Matcher_2(Guid param_a); } diff --git a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_39.cs b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_39.cs index cab50f6..da70956 100644 --- a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_39.cs +++ b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_39.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Tenekon.MethodOverloads.AcceptanceCriterias; public readonly struct Class_39_Case_1_Param_1; @@ -41,6 +43,8 @@ public abstract class Class_39_1 public abstract void Case_2(Class_39_Case_2_Param_1 param_1, Class_39_Case_2_Param_2 param_2); } +// Matcher_2 has no match in Class_39_2 (MOG002 at the typeof reference). +[SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateMethodOverloads(Matchers = [typeof(Class_39_Matcher)])] [OverloadGenerationOptions(SubsequenceStrategy = OverloadSubsequenceStrategy.UniqueBySignature)] public abstract class Class_39_2 diff --git a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_40.cs b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_40.cs index 77ba4e0..6b8c120 100644 --- a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_40.cs +++ b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_40.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Tenekon.MethodOverloads.AcceptanceCriterias; public readonly struct Class_40_Case_1_Param_1; @@ -33,6 +35,8 @@ public abstract class Class_40_1 protected internal abstract void Case_2(Class_40_Case_2_Param_1 param_1, Class_40_Case_2_Param_2 param_2); } +// Matcher_2 has no match in Class_40_2 (MOG002 at the typeof reference). +[SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateMethodOverloads(Matchers = [typeof(Class_40_Matcher)])] [OverloadGenerationOptions(OverloadVisibility = OverloadVisibility.Public)] public abstract class Class_40_2 diff --git a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_5.cs b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_5.cs index 9628dd0..d5fa88b 100644 --- a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_5.cs +++ b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Class_5.cs @@ -13,11 +13,12 @@ internal interface Class_5_Matcher void Matcher_1(bool param_1, CancellationToken param_2); [OverloadGenerationOptions(RangeAnchorMatchMode = RangeAnchorMatchMode.TypeAndName)] - [SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateOverloads(EndExclusive = nameof(param_2))] void Matcher_2(string? param_1, bool param_2, CancellationToken param_3); } +// Matcher_2 has no match in Class_5 (MOG002 at the typeof reference). +[SuppressMessage("MethodOverloadsGenerator", "MOG002")] [GenerateMethodOverloads(Matchers = [typeof(Class_5_Matcher)])] public abstract class Class_5 { diff --git a/src/Tenekon.MethodOverloads.SourceGenerator/Generation/OverloadPlanBuilder.cs b/src/Tenekon.MethodOverloads.SourceGenerator/Generation/OverloadPlanBuilder.cs index ed8de56..612c324 100644 --- a/src/Tenekon.MethodOverloads.SourceGenerator/Generation/OverloadPlanBuilder.cs +++ b/src/Tenekon.MethodOverloads.SourceGenerator/Generation/OverloadPlanBuilder.cs @@ -359,10 +359,12 @@ private void BuildMethods() { if (entry.Value) continue; - Report( - GeneratorDiagnostics.MatcherHasNoSubsequenceMatch, - matcherLocations.TryGetValue(entry.Key, out var location) ? location : null, - entry.Key.MethodName); + _diagnostics.Add( + new EquatableDiagnostic( + GeneratorDiagnostics.MatcherHasNoSubsequenceMatch, + matcherLocations.TryGetValue(entry.Key, out var location) ? location : null, + new EquatableArray([entry.Key.MethodName]), + entry.Key.ContainingTypeDisplay)); } } diff --git a/src/Tenekon.MethodOverloads.SourceGenerator/GeneratorDiagnostics.cs b/src/Tenekon.MethodOverloads.SourceGenerator/GeneratorDiagnostics.cs index 5f7f37f..547e2b2 100644 --- a/src/Tenekon.MethodOverloads.SourceGenerator/GeneratorDiagnostics.cs +++ b/src/Tenekon.MethodOverloads.SourceGenerator/GeneratorDiagnostics.cs @@ -15,7 +15,7 @@ internal static class GeneratorDiagnostics public static readonly DiagnosticDescriptor MatcherHasNoSubsequenceMatch = new( "MOG002", "Matcher has no subsequence match", - "Matcher '{0}' has no subsequence match for any target method", + "Matcher '{0}' has no subsequence match for any target method in '{1}'", "MethodOverloadsGenerator", DiagnosticSeverity.Info, isEnabledByDefault: true); diff --git a/src/Tenekon.MethodOverloads.SourceGenerator/MethodOverloadsDiagnosticsAnalyzer.cs b/src/Tenekon.MethodOverloads.SourceGenerator/MethodOverloadsDiagnosticsAnalyzer.cs index 09f061c..872c4f0 100644 --- a/src/Tenekon.MethodOverloads.SourceGenerator/MethodOverloadsDiagnosticsAnalyzer.cs +++ b/src/Tenekon.MethodOverloads.SourceGenerator/MethodOverloadsDiagnosticsAnalyzer.cs @@ -1,18 +1,18 @@ -using System.Collections.Concurrent; using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Operations; using Tenekon.MethodOverloads.SourceGenerator.Generation; using Tenekon.MethodOverloads.SourceGenerator.Helpers; +using Tenekon.MethodOverloads.SourceGenerator.Models; using Tenekon.MethodOverloads.SourceGenerator.Parsing; using Tenekon.MethodOverloads.SourceGenerator.Parsing.Inputs; namespace Tenekon.MethodOverloads.SourceGenerator; /// -/// Reports diagnostics for overload generation by analyzing attributed symbols and delegating to the -/// shared parsing and generation pipeline. +/// Reports diagnostics for overload generation. Each type is analyzed on its own with the shared parsing and +/// generation pipeline, so diagnostics are reported live in the IDE and can be suppressed in source. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class MethodOverloadsDiagnosticsAnalyzer : DiagnosticAnalyzer @@ -49,103 +49,202 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterCompilationStartAction(startContext => + context.RegisterCompilationStartAction(static startContext => { if (IsAttributesOnly(startContext.Options.AnalyzerConfigOptionsProvider)) return; - var typeTargets = new ConcurrentBag(); - var methodTargets = new ConcurrentBag(); - - startContext.RegisterOperationAction( - operationContext => AnalyzeAttributeOperation(operationContext, typeTargets, methodTargets), - OperationKind.Attribute); + startContext.RegisterSymbolAction(AnalyzeType, SymbolKind.NamedType); + }); + } - startContext.RegisterCompilationEndAction(endContext => - { - if (typeTargets.IsEmpty && methodTargets.IsEmpty) return; + private static void AnalyzeType(SymbolAnalysisContext context) + { + if (context.Symbol is not INamedTypeSymbol typeSymbol) return; - var model = Parser.Parse([.. typeTargets], [.. methodTargets], endContext.CancellationToken); - if (model is null) return; + var cancellationToken = context.CancellationToken; + var typeTargets = ImmutableArray.CreateBuilder(); + var methodTargets = ImmutableArray.CreateBuilder(); + + if (HasAttribute(typeSymbol, AttributeNames.GenerateMethodOverloadsAttribute) + && TargetFactory.CreateTypeTargetFromSymbol(typeSymbol, cancellationToken) is { } typeTarget) + typeTargets.Add(typeTarget); + + foreach (var member in typeSymbol.GetMembers()) + { + if (member is not IMethodSymbol methodSymbol + || !HasAttribute(methodSymbol, AttributeNames.GenerateOverloadsAttribute)) + continue; - foreach (var diagnostic in model.Diagnostics.Items) - endContext.ReportDiagnostic(diagnostic.CreateDiagnostic()); + if (TargetFactory.CreateMethodTargetFromSymbol(methodSymbol, cancellationToken) is { } methodTarget) + methodTargets.Add(methodTarget); + } - var builder = new OverloadPlanBuilder(model); - var result = builder.Build(); + if (typeTargets.Count == 0 && methodTargets.Count == 0) return; - foreach (var diagnostic in result.Diagnostics.Items) - endContext.ReportDiagnostic(diagnostic.CreateDiagnostic()); - }); + // The model only knows this type, so every diagnostic is evaluated for this type alone. + var model = Parser.Parse(typeTargets.ToImmutable(), methodTargets.ToImmutable(), cancellationToken); + if (model is null) return; - return; + var plan = new OverloadPlanBuilder(model).Build(); + var reported = new HashSet(); - void AnalyzeAttributeOperation( - OperationAnalysisContext operationContext, - ConcurrentBag collectedTypeTargets, - ConcurrentBag collectedMethodTargets) + foreach (var diagnostic in model.Diagnostics.Items.Concat(plan.Diagnostics.Items)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!reported.Add(diagnostic)) continue; + + if (diagnostic.MatcherTypeDisplay is { } matcherTypeDisplay) { - if (operationContext.Operation is not IAttributeOperation attributeOperation) return; - - var attributeClass = GetAttributeClass(attributeOperation); - if (attributeClass is null) return; - - if (IsTargetAttribute(attributeClass, AttributeNames.GenerateOverloadsAttribute)) - { - if (operationContext.ContainingSymbol is not IMethodSymbol methodSymbol) return; - - var target = TargetFactory.CreateMethodTargetFromSymbol( - methodSymbol, - operationContext.CancellationToken); - if (target.HasValue) collectedMethodTargets.Add(target.Value); - - return; - } - - if (IsTargetAttribute(attributeClass, AttributeNames.GenerateMethodOverloadsAttribute)) - { - if (operationContext.ContainingSymbol is not INamedTypeSymbol typeSymbol) return; - - var target = TargetFactory.CreateTypeTargetFromSymbol( - typeSymbol, - operationContext.CancellationToken); - if (target.HasValue) collectedTypeTargets.Add(target.Value); - } - - return; - - static bool IsTargetAttribute(INamedTypeSymbol attributeClass, string expectedFullName) - { - var display = attributeClass.ToDisplayString(RoslynHelpers.TypeDisplayFormat); - if (display.StartsWith("global::", StringComparison.Ordinal)) - display = display.Substring("global::".Length); - - return string.Equals(display, expectedFullName, StringComparison.Ordinal); - } - - static INamedTypeSymbol? GetAttributeClass(IAttributeOperation attributeOperation) - { - var operation = attributeOperation.Operation; - - return operation switch - { - IObjectCreationOperation creation => creation.Constructor?.ContainingType - ?? creation.Type as INamedTypeSymbol, - IInvalidOperation invalid => invalid.Type as INamedTypeSymbol, - _ => operation.Type as INamedTypeSymbol - }; - } + var referenceLocation = FindMatcherReference(typeSymbol, matcherTypeDisplay, cancellationToken) + ?? typeSymbol.Locations.FirstOrDefault(); + context.ReportDiagnostic(diagnostic.CreateDiagnostic(referenceLocation, typeSymbol.Name)); + continue; } - static bool IsAttributesOnly(AnalyzerConfigOptionsProvider optionsProvider) + // Diagnostics located in other types (e.g. at a matcher) are reported by the analysis of that type. + if (diagnostic.Location is not { } sourceLocation) continue; + + var location = ResolveOwnLocation(typeSymbol, sourceLocation, cancellationToken); + if (location is null) continue; + + context.ReportDiagnostic(diagnostic.CreateDiagnostic(location)); + } + } + + /// + /// Maps a stored source location to an in-source location when it lies directly in this type, so that + /// #pragma and [SuppressMessage] apply. Returns null for locations owned by other types. + /// + private static Location? ResolveOwnLocation( + INamedTypeSymbol typeSymbol, + SourceLocationModel sourceLocation, + CancellationToken cancellationToken) + { + var filePath = sourceLocation.SourceTreeFilePath ?? string.Empty; + var span = sourceLocation.SourceSpan; + Location? resolved = null; + var matches = 0; + + foreach (var reference in typeSymbol.DeclaringSyntaxReferences) + { + var tree = reference.SyntaxTree; + if (!string.Equals(tree.FilePath, filePath, StringComparison.Ordinal) || !reference.Span.Contains(span)) + continue; + + var declaration = reference.GetSyntax(cancellationToken); + var node = tree.GetRoot(cancellationToken).FindNode(span, getInnermostNodeForTie: true); + if (node.FirstAncestorOrSelf() != declaration) continue; + + resolved = Location.Create(tree, span); + matches++; + } + + // Several declarations share the path (e.g. in-memory trees without a path), so the tree is ambiguous. + return matches > 1 ? sourceLocation.ToLocation() : resolved; + } + + private static Location? FindMatcherReference( + INamedTypeSymbol typeSymbol, + string matcherTypeDisplay, + CancellationToken cancellationToken) + { + var location = FindMatcherReference( + RoslynHelpers.GetAttributes(typeSymbol, "GenerateMethodOverloadsAttribute"), + matcherTypeDisplay, + cancellationToken); + if (location is not null) return location; + + foreach (var member in typeSymbol.GetMembers()) + { + if (member is not IMethodSymbol methodSymbol) continue; + + location = FindMatcherReference( + RoslynHelpers.GetAttributes(methodSymbol, "GenerateOverloadsAttribute"), + matcherTypeDisplay, + cancellationToken); + if (location is not null) return location; + } + + return null; + } + + private static Location? FindMatcherReference( + ImmutableArray attributes, + string matcherTypeDisplay, + CancellationToken cancellationToken) + { + foreach (var attribute in attributes) + foreach (var named in attribute.NamedArguments) + { + if (!string.Equals(named.Key, "Matchers", StringComparison.Ordinal) + || named.Value.Kind != TypedConstantKind.Array) + continue; + + var matchers = named.Value.Values; + for (var index = 0; index < matchers.Length; index++) { - if (optionsProvider.GlobalOptions.TryGetValue( - "build_property.TenekonMethodOverloadsSourceGeneratorAttributesOnly", - out var raw)) - return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) - || string.Equals(raw, "1", StringComparison.Ordinal); + if (matchers[index].Value is not INamedTypeSymbol matcherType + || !string.Equals( + Parser.GetMatcherTypeDisplay(matcherType), + matcherTypeDisplay, + StringComparison.Ordinal)) + continue; + + if (attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken) is not AttributeSyntax syntax) + continue; - return false; + return (GetMatchersElement(syntax, index) ?? syntax).GetLocation(); } - }); + } + + return null; + } + + private static SyntaxNode? GetMatchersElement(AttributeSyntax attribute, int index) + { + var argument = attribute.ArgumentList?.Arguments.FirstOrDefault(argument => string.Equals( + argument.NameEquals?.Name.Identifier.ValueText, + "Matchers", + StringComparison.Ordinal)); + if (argument is null) return null; + + IReadOnlyList? elements = argument.Expression switch + { + CollectionExpressionSyntax collection when collection.Elements.All(e => e is ExpressionElementSyntax) => + collection.Elements.Select(e => (SyntaxNode)((ExpressionElementSyntax)e).Expression).ToList(), + ArrayCreationExpressionSyntax { Initializer: { } initializer } => initializer.Expressions, + ImplicitArrayCreationExpressionSyntax implicitArray => implicitArray.Initializer.Expressions, + _ => null + }; + + return elements is not null && index < elements.Count ? elements[index] : argument; + } + + private static bool HasAttribute(ISymbol symbol, string expectedFullName) + { + foreach (var attribute in symbol.GetAttributes()) + { + if (attribute.AttributeClass is not { } attributeClass) continue; + + var display = attributeClass.ToDisplayString(RoslynHelpers.TypeDisplayFormat); + if (display.StartsWith("global::", StringComparison.Ordinal)) + display = display.Substring("global::".Length); + + if (string.Equals(display, expectedFullName, StringComparison.Ordinal)) return true; + } + + return false; + } + + private static bool IsAttributesOnly(AnalyzerConfigOptionsProvider optionsProvider) + { + if (optionsProvider.GlobalOptions.TryGetValue( + "build_property.TenekonMethodOverloadsSourceGeneratorAttributesOnly", + out var raw)) + return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(raw, "1", StringComparison.Ordinal); + + return false; } } diff --git a/src/Tenekon.MethodOverloads.SourceGenerator/Models/GeneratorModel.cs b/src/Tenekon.MethodOverloads.SourceGenerator/Models/GeneratorModel.cs index 5e1b99b..38a9a81 100644 --- a/src/Tenekon.MethodOverloads.SourceGenerator/Models/GeneratorModel.cs +++ b/src/Tenekon.MethodOverloads.SourceGenerator/Models/GeneratorModel.cs @@ -10,14 +10,17 @@ internal sealed record GeneratorModel( EquatableArray MatcherTypes, EquatableArray Diagnostics); +// MatcherTypeDisplay is set for diagnostics about a matcher usage: the matcher type whose typeof reference in the +// target carries the diagnostic. internal readonly record struct EquatableDiagnostic( DiagnosticDescriptor Descriptor, SourceLocationModel? Location, - EquatableArray MessageArgs) + EquatableArray MessageArgs, + string? MatcherTypeDisplay = null) { - public Diagnostic CreateDiagnostic() + public Diagnostic CreateDiagnostic(Location? location, params string[] additionalMessageArgs) { - var args = MessageArgs.Items.Length == 0 ? [] : MessageArgs.Items.Cast().ToArray(); - return Diagnostic.Create(Descriptor, Location?.ToLocation(), args); + object?[] args = [.. MessageArgs.Items, .. additionalMessageArgs]; + return Diagnostic.Create(Descriptor, location, args); } -} \ No newline at end of file +} diff --git a/src/Tenekon.MethodOverloads.SourceGenerator/Parsing/Parser.ModelMapper.cs b/src/Tenekon.MethodOverloads.SourceGenerator/Parsing/Parser.ModelMapper.cs index 6dae4e1..10a1778 100644 --- a/src/Tenekon.MethodOverloads.SourceGenerator/Parsing/Parser.ModelMapper.cs +++ b/src/Tenekon.MethodOverloads.SourceGenerator/Parsing/Parser.ModelMapper.cs @@ -609,7 +609,7 @@ internal static (ImmutableArray Displays, ImmutableArray Displays, ImmutableArray BuildMatcherTypeModels( ImmutableArray matcherSymbols, CancellationToken cancellationToken) diff --git a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/AcceptanceDiagnosticsTests.cs b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/AcceptanceDiagnosticsTests.cs index 3a07db1..b6d080b 100644 --- a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/AcceptanceDiagnosticsTests.cs +++ b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/AcceptanceDiagnosticsTests.cs @@ -4,6 +4,12 @@ namespace Tenekon.MethodOverloads.SourceGenerator.Tests; public sealed class AcceptanceDiagnosticsTests { + [Fact] + public void Analyzer_runs_without_exceptions() + { + Assert.Empty(AcceptanceFixtureCache.Instance.AnalyzerExceptions); + } + [Theory] [ClassData(typeof(AcceptanceDiagnosticsData))] public void Expected_diagnostics_are_reported(DiagnosticCaseResult caseResult) diff --git a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/DiagnosticsTests.Diagnostics_HaveNoChanges.verified.txt b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/DiagnosticsTests.Diagnostics_HaveNoChanges.verified.txt index 85e76bc..baae0b9 100644 --- a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/DiagnosticsTests.Diagnostics_HaveNoChanges.verified.txt +++ b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/DiagnosticsTests.Diagnostics_HaveNoChanges.verified.txt @@ -10,7 +10,7 @@ { Id: MOG002, Title: Matcher has no subsequence match, - MessageFormat: Matcher '{0}' has no subsequence match for any target method, + MessageFormat: Matcher '{0}' has no subsequence match for any target method in '{1}', Category: MethodOverloadsGenerator, DefaultSeverity: 1, IsEnabledByDefault: true diff --git a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceFixture.cs b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceFixture.cs index 0f48c02..bab1236 100644 --- a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceFixture.cs +++ b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceFixture.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -32,7 +33,8 @@ public AcceptanceFixture() var actual = AcceptanceTestData.ExtractActualSignatures(outputCompilation, generatedTrees); Cases = AcceptanceTestData.BuildCaseResults(expected, actual); - Diagnostics = GetAnalyzerDiagnostics(outputCompilation); + Diagnostics = GetAnalyzerDiagnostics(outputCompilation, out var analyzerExceptions); + AnalyzerExceptions = analyzerExceptions; DiagnosticCases = AcceptanceTestData.BuildDiagnosticResults(ExpectedDiagnostics, Diagnostics); } @@ -42,13 +44,30 @@ public AcceptanceFixture() public IReadOnlyList Cases { get; } internal ImmutableArray ExpectedDiagnostics { get; } public ImmutableArray Diagnostics { get; } + public IReadOnlyCollection AnalyzerExceptions { get; } public IReadOnlyList DiagnosticCases { get; } - private static ImmutableArray GetAnalyzerDiagnostics(Compilation compilation) + private static ImmutableArray GetAnalyzerDiagnostics( + Compilation compilation, + out IReadOnlyCollection analyzerExceptions) { var analyzer = new MethodOverloadsDiagnosticsAnalyzer(); var analyzers = ImmutableArray.Create(analyzer); - var diagnostics = compilation.WithAnalyzers(analyzers).GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult(); + + // The reference sources declare expected diagnostics via [SuppressMessage], which suppresses them. + // Suppressed diagnostics are therefore reported too (Diagnostic.IsSuppressed == true). + var exceptions = new ConcurrentQueue(); + var options = new CompilationWithAnalyzersOptions( + new AnalyzerOptions(ImmutableArray.Empty), + onAnalyzerException: (exception, _, _) => exceptions.Enqueue(exception), + concurrentAnalysis: true, + logAnalyzerExecutionTime: false, + reportSuppressedDiagnostics: true); + var diagnostics = compilation.WithAnalyzers(analyzers, options) + .GetAnalyzerDiagnosticsAsync() + .GetAwaiter() + .GetResult(); + analyzerExceptions = exceptions; return diagnostics; } } \ No newline at end of file diff --git a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceTestData.cs b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceTestData.cs index 3b7001f..5351860 100644 --- a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceTestData.cs +++ b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceTestData.cs @@ -509,42 +509,13 @@ private static bool IsAttributeNameMatch(string name, string expected) private static string? GetClassNameFromDiagnostic(Diagnostic diagnostic) { var location = diagnostic.Location; - if (location == Location.None) return null; + if (location.SourceTree is not { } tree) return null; - var root = location.SourceTree?.GetRoot() ?? GetRootFromPath(location); - if (root is null) return null; - - var node = root.FindNode(location.SourceSpan, getInnermostNodeForTie: true); + var node = tree.GetRoot().FindNode(location.SourceSpan, getInnermostNodeForTie: true); var classDecl = node.FirstAncestorOrSelf(); return classDecl?.Identifier.ValueText; } - private static SyntaxNode? GetRootFromPath(Location location) - { - var lineSpan = location.GetLineSpan(); - var path = lineSpan.Path; - if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return null; - - var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); - var text = File.ReadAllText(path); - var tree = CSharpSyntaxTree.ParseText(text, parseOptions, path); - var root = tree.GetRoot(); - var toRemove = root.DescendantNodes() - .OfType() - .Where(cls => cls.Identifier.ValueText.EndsWith("AcceptanceCriterias", StringComparison.Ordinal)) - .ToArray(); - - if (toRemove.Length > 0) root = root.RemoveNodes(toRemove, SyntaxRemoveOptions.KeepNoTrivia)!; - - var normalizedText = root.NormalizeWhitespace().ToFullString(); - var normalizedTree = CSharpSyntaxTree.ParseText(normalizedText, parseOptions, path); - var normalizedRoot = normalizedTree.GetRoot(); - var sourceText = normalizedTree.GetText(); - if (location.SourceSpan.End > sourceText.Length) return null; - - return normalizedRoot; - } - public sealed record SourceFile(string Path, string Content); internal sealed record ParameterSignature(string Type, RefKind RefKind, bool IsParams); From 3740b6802daf83dacec1ef9995487b4c4e3448e3 Mon Sep 17 00:00:00 2001 From: Teneko Date: Fri, 9 Oct 2026 07:19:17 +0200 Subject: [PATCH 2/4] test: cover diagnostic suppression and in-source locations In-memory regression tests for the analyzer: #pragma warning disable and [SuppressMessage] on the method suppress MOG001, reported locations are in source and span the attribute, and MOG002 is reported at the typeof reference of the target. Co-Authored-By: Claude Opus 5.5 --- .../DiagnosticSuppressionTests.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/Tenekon.MethodOverloads.SourceGenerator.Tests/DiagnosticSuppressionTests.cs diff --git a/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/DiagnosticSuppressionTests.cs b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/DiagnosticSuppressionTests.cs new file mode 100644 index 0000000..66ed835 --- /dev/null +++ b/tests/Tenekon.MethodOverloads.SourceGenerator.Tests/DiagnosticSuppressionTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Tenekon.MethodOverloads.SourceGenerator.Tests.Infrastructure; + +namespace Tenekon.MethodOverloads.SourceGenerator.Tests; + +public sealed class DiagnosticSuppressionTests +{ + private const string InvalidAnchorMethod = """ + [GenerateOverloads(Begin = "missing")] + public static void Method(int value) { } + """; + + [Fact] + public void Diagnostic_is_reported_unsuppressed_by_default() + { + var diagnostic = Assert.Single(GetDiagnostics(CreateTargetSource(InvalidAnchorMethod)), IsMog001); + + Assert.False(diagnostic.IsSuppressed); + } + + [Fact] + public void Pragma_warning_disable_suppresses_diagnostic() + { + var source = "#pragma warning disable MOG001\n" + CreateTargetSource(InvalidAnchorMethod); + + var diagnostic = Assert.Single(GetDiagnostics(source), IsMog001); + + Assert.True(diagnostic.IsSuppressed); + } + + [Fact] + public void SuppressMessage_attribute_on_method_suppresses_diagnostic() + { + var source = CreateTargetSource( + """ + [System.Diagnostics.CodeAnalysis.SuppressMessage("MethodOverloadsGenerator", "MOG001")] + """ + "\n" + InvalidAnchorMethod); + + var diagnostic = Assert.Single(GetDiagnostics(source), IsMog001); + + Assert.True(diagnostic.IsSuppressed); + } + + [Fact] + public void Diagnostic_location_is_in_source_and_spans_the_attribute() + { + var diagnostic = Assert.Single(GetDiagnostics(CreateTargetSource(InvalidAnchorMethod)), IsMog001); + + Assert.True(diagnostic.Location.IsInSource); + Assert.Equal("Target.cs", diagnostic.Location.SourceTree!.FilePath); + Assert.Equal("GenerateOverloads(Begin = \"missing\")", GetLocationText(diagnostic.Location)); + } + + [Fact] + public void Matcher_without_match_is_reported_at_typeof_reference_of_target() + { + const string source = """ + using Tenekon.MethodOverloads; + + namespace Demo; + + internal interface IMatcher + { + [GenerateOverloads(nameof(value))] + void Match(Guid value); + } + + [GenerateMethodOverloads(Matchers = [typeof(IMatcher)])] + public sealed class Target + { + public void Method(int value) { } + } + """; + + var diagnostic = Assert.Single(GetDiagnostics(source), d => d.Id == "MOG002"); + + Assert.True(diagnostic.Location.IsInSource); + Assert.Equal("typeof(IMatcher)", GetLocationText(diagnostic.Location)); + Assert.Equal( + "Matcher 'Match' has no subsequence match for any target method in 'Target'", + diagnostic.GetMessage(CultureInfo.InvariantCulture)); + } + + private static bool IsMog001(Diagnostic diagnostic) + { + return diagnostic.Id == "MOG001"; + } + + private static string CreateTargetSource(string members) + { + return $$""" + using Tenekon.MethodOverloads; + + namespace Demo; + + public static class Target + { + {{members}} + } + """; + } + + private static string GetLocationText(Location location) + { + return location.SourceTree!.GetText().ToString(location.SourceSpan); + } + + private static ImmutableArray GetDiagnostics(string source) + { + var compilation = AcceptanceTestData.CreateCompilation([new AcceptanceTestData.SourceFile("Target.cs", source)]); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new MethodOverloadsGenerator().AsSourceGenerator()], + parseOptions: new CSharpParseOptions(LanguageVersion.Preview)); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); + + var exceptions = new ConcurrentQueue(); + var options = new CompilationWithAnalyzersOptions( + new AnalyzerOptions(ImmutableArray.Empty), + onAnalyzerException: (exception, _, _) => exceptions.Enqueue(exception), + concurrentAnalysis: true, + logAnalyzerExecutionTime: false, + reportSuppressedDiagnostics: true); + var diagnostics = outputCompilation + .WithAnalyzers([new MethodOverloadsDiagnosticsAnalyzer()], options) + .GetAnalyzerDiagnosticsAsync() + .GetAwaiter() + .GetResult(); + + Assert.Empty(exceptions); + return diagnostics; + } +} From 1fd075574634e63aac16dbfc61c3b7cd26fe82ec Mon Sep 17 00:00:00 2001 From: Teneko Date: Fri, 9 Oct 2026 07:19:17 +0200 Subject: [PATCH 3/4] test: drop obsolete severity overrides from acceptance project The error-level MOG diagnostics in the acceptance project are now suppressed by the [SuppressMessage] attributes that declare them as expected, so the project builds without downgrading their severity. Verified with full builds in both attributes-only modes. Co-Authored-By: Claude Opus 5.5 --- .../.globalconfig | 10 ---------- .../Tenekon.MethodOverloads.AcceptanceCriterias.csproj | 1 - 2 files changed, 11 deletions(-) delete mode 100644 ref/Tenekon.MethodOverloads.AcceptanceCriterias/.globalconfig diff --git a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/.globalconfig b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/.globalconfig deleted file mode 100644 index ea680a2..0000000 --- a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/.globalconfig +++ /dev/null @@ -1,10 +0,0 @@ -// This is part of the acceptance criterias testing. -// Add here diagnostic that are errors and not suppressable. -dotnet_diagnostic.MOG007.severity = warning -dotnet_diagnostic.MOG009.severity = warning -dotnet_diagnostic.MOG010.severity = warning -dotnet_diagnostic.MOG012.severity = warning -dotnet_diagnostic.MOG013.severity = warning -dotnet_diagnostic.MOG017.severity = warning -dotnet_diagnostic.MOG018.severity = warning -dotnet_diagnostic.MOG019.severity = warning diff --git a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Tenekon.MethodOverloads.AcceptanceCriterias.csproj b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Tenekon.MethodOverloads.AcceptanceCriterias.csproj index af21ec2..d7ffc4d 100644 --- a/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Tenekon.MethodOverloads.AcceptanceCriterias.csproj +++ b/ref/Tenekon.MethodOverloads.AcceptanceCriterias/Tenekon.MethodOverloads.AcceptanceCriterias.csproj @@ -23,7 +23,6 @@ - From b292d92859ebefd079e46b4c2ddc11c90bb069fd Mon Sep 17 00:00:00 2001 From: Teneko Date: Fri, 9 Oct 2026 07:19:17 +0200 Subject: [PATCH 4/4] docs: describe live and suppressible diagnostics Document that diagnostics are reported live and can be suppressed with #pragma, [SuppressMessage] or .editorconfig severities, that MOG002 is reported per target at the typeof reference, and how expected diagnostics are declared now that the acceptance project no longer uses a .globalconfig. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++++++++++++++++--- docs/acceptance-criterias.md | 30 +++++++++++++----------------- docs/generator.md | 10 ++++++++++ docs/testing.md | 2 +- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8f03b6e..5b52c9e 100644 --- a/README.md +++ b/README.md @@ -258,10 +258,10 @@ Emit attributes only (skip generation and diagnostics): ## Diagnostics -Diagnostics are reported by the analyzer and surfaced during build: +Diagnostics are reported by the analyzer, live in the IDE and during build: - `MOG001` Invalid window anchor. -- `MOG002` Matcher has no subsequence match. +- `MOG002` Matcher has no subsequence match in a target (reported at the `typeof` reference in `Matchers`). - `MOG003` Defaults inside window. - `MOG004` Params outside window. - `MOG005` Ref/out/in omitted. @@ -280,7 +280,20 @@ Diagnostics are reported by the analyzer and surfaced during build: - `MOG018` ExcludeAny refers to missing/out-of-window parameter. - `MOG019` ExcludeAny contains invalid entries. -You can downgrade error-level diagnostics in `.globalconfig` if you need the project to compile with intentional violations. +Diagnostics can be suppressed in source like any other analyzer diagnostic: + +```csharp +#pragma warning disable MOG001 +[GenerateOverloads(Begin = "missing")] +public void Configure(int value) { } +#pragma warning restore MOG001 + +[SuppressMessage("MethodOverloadsGenerator", "MOG011")] +[GenerateOverloads] +public void Reset() { } +``` + +Severities can also be changed in `.editorconfig` or `.globalconfig`, e.g. `dotnet_diagnostic.MOG002.severity = none`. ## Generation Rules (Summary) diff --git a/docs/acceptance-criterias.md b/docs/acceptance-criterias.md index f875d59..fd21393 100644 --- a/docs/acceptance-criterias.md +++ b/docs/acceptance-criterias.md @@ -23,7 +23,6 @@ Source files: Support files: - `.editorconfig` (IDE/inspection suppression + generated code marker) -- `.globalconfig` (diagnostic severity overrides) ## How tests consume it @@ -33,6 +32,8 @@ There are two main test paths: - Tests read the `ref/*.cs` files and build an in-memory compilation. - Expected overload signatures come from `Class_*_AcceptanceCriterias`. - Expected diagnostics are inferred from `[SuppressMessage]` attributes (MOG IDs). + - The analyzer runs with `reportSuppressedDiagnostics: true`, so diagnostics suppressed by those attributes + are still compared (`Diagnostic.IsSuppressed == true`). - See: `tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure/AcceptanceTestData.cs`. 2) **Project build validation (real MSBuild)** @@ -54,20 +55,11 @@ The tests explicitly build the project with: - `TenekonMethodOverloadsSourceGeneratorAttributesOnly=false` to ensure a full generation build also succeeds. -## .globalconfig (diagnostic severities) +## Error-level diagnostics -File: -- `ref/Tenekon.MethodOverloads.AcceptanceCriterias/.globalconfig` - -Role: -- Downgrades certain MOG diagnostics from **error** to **warning** so the project can build while still - exercising those diagnostics in acceptance criteria. - -Current overrides: -- `MOG007`, `MOG009`, `MOG010`, `MOG012` → warning - -These diagnostics are treated as errors by default and are not suppressable in source; -the globalconfig allows the project to compile while still surfacing them for tests. +Some MOG diagnostics are errors by default (e.g. `MOG007`, `MOG012`). The project still compiles because the +`[SuppressMessage]` attributes that declare them as expected also suppress them in the build. No severity +overrides are needed. ## .editorconfig (IDE suppression and generated code) @@ -80,9 +72,13 @@ Role: ## How expected diagnostics are declared -Use `[SuppressMessage]` on the target class with a `CheckId` that includes the MOG ID: +Use `[SuppressMessage]` with a `CheckId` that includes the MOG ID: - Example: `[SuppressMessage("MethodOverloadsGenerator", "MOG012")]` -Tests interpret these attributes as **expected diagnostics** for that class, even if the suppression is only -used for documentation/clarity in the source. +Place it on the class or member that contains the diagnostic location. It does two things: +- Tests interpret it as an **expected diagnostic** for the enclosing class. +- It suppresses the diagnostic in the real build and in the IDE. + +`MOG002` is reported at the `typeof` reference in the target's `Matchers`, so its `[SuppressMessage]` belongs on +the target class, not on the matcher. diff --git a/docs/generator.md b/docs/generator.md index 7287a0b..60e3c46 100644 --- a/docs/generator.md +++ b/docs/generator.md @@ -121,6 +121,16 @@ Diagnostics are reported by the analyzer (not by the generator): - Analyzer: src/Tenekon.MethodOverloads.SourceGenerator/MethodOverloadsDiagnosticsAnalyzer.cs - Generator: src/Tenekon.MethodOverloads.SourceGenerator/MethodOverloadsGenerator.cs +The analyzer analyzes each type on its own (symbol action) with the same parsing and planning logic as the +generator: +- Diagnostics are reported live in the IDE, not only at the end of a build. +- Each type reports only diagnostics located in its own declaration; diagnostics at a matcher are reported when the + matcher type itself is analyzed. +- Locations are in source, so `#pragma warning disable`, `[SuppressMessage]` and per-file `.editorconfig` + severities apply. +- MOG002 is evaluated per target: it is reported at the `typeof` reference in `Matchers` when a matcher method has + no subsequence match in that target. + Attributes-only mode suppresses diagnostics the same way it suppresses overload generation. ## 11) Entry point & files diff --git a/docs/testing.md b/docs/testing.md index 002e3af..c8f711d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -47,7 +47,7 @@ Each file defines: Non-test helpers (fixtures, data, models) live under: - tests/Tenekon.MethodOverloads.SourceGenerator.Tests/Infrastructure -More detail on the acceptance criterias project (including editorconfig/globalconfig behavior) is in: +More detail on the acceptance criterias project (including editorconfig behavior and expected diagnostics) is in: - docs/acceptance-criterias.md ## Running tests