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 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/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/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 @@ - 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/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; + } +} 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);