From 44b177705f45c6a4fa14daaaacae6e17c5ea5d4e Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 11 Aug 2026 23:46:19 +0200 Subject: [PATCH] Add diagnostic severity configuration and suppression-aware assertions WithDiagnosticSeverity(id, severity[, filename]) and WithGlobalDiagnosticSeverity configure severity through a SyntaxTreeOptionsProvider with file > all-source > global precedence, mirroring Roslyn. AssertSuppressedDiagnostics distinguishes 'reported but severity none' from 'not reported' via a neutral-provider re-run. Filename matching is normalized (separators, case). --- README.md | 11 +- src/FakeAnalyzers/DiagnosticDescriptors.cs | 8 + .../DiagnosticEmittingSourceGenerator.cs | 29 +++ src/FakeAnalyzers/DiagnosticIds.cs | 1 + .../AnalyzerConfigOptionsFactory.cs | 34 +++- .../AnalyzerTest.cs | 192 +++++++++++++++++- .../BaseAnalyzerTest.cs | 173 +++++++++++++++- .../BaseCompilationTest.cs | 45 ++++ src/Particular.AnalyzerTesting/CodeFixTest.cs | 12 +- .../CompilationExtensions.cs | 19 +- .../FilenameComparer.cs | 17 ++ .../SourceGeneratorTest.cs | 3 +- .../SyntaxTreeOptionsProviderFactory.cs | 84 ++++++++ .../Analyzers/DiagnosticSeverityTests.cs | 183 +++++++++++++++++ .../ApiApproval.ApproveApi.approved.txt | 5 + .../Fixers/SuppressedDiagnosticFixerTests.cs | 84 ++++++++ .../GeneratorSeverityTests.cs | 84 ++++++++ 17 files changed, 951 insertions(+), 33 deletions(-) create mode 100644 src/FakeAnalyzers/DiagnosticEmittingSourceGenerator.cs create mode 100644 src/Particular.AnalyzerTesting/FilenameComparer.cs create mode 100644 src/Particular.AnalyzerTesting/SyntaxTreeOptionsProviderFactory.cs create mode 100644 src/Tests/Analyzers/DiagnosticSeverityTests.cs create mode 100644 src/Tests/Fixers/SuppressedDiagnosticFixerTests.cs create mode 100644 src/Tests/SourceGenerators/GeneratorSeverityTests.cs diff --git a/README.md b/README.md index a539d38..e54c1f9 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,10 @@ Multiple source files can be added with separate `WithSource` calls. | `WithInterceptorNamespace(ns)` | Add an interceptors namespace feature flag to the compilation. | | `WithProperty(name, value)` | Add an arbitrary MSBuild-style build property (feature flag) to the compilation. The property is available through global and syntax-tree analyzer config options. | | `WithEditorConfigOption(name, value, filename)` | Add an EditorConfig option to syntax-tree analyzer config options. Omit `filename` to apply it to all source files; specify a filename to scope it to that source file. EditorConfig options are not available through global analyzer config options. | -| `AssertDiagnostics(expectedDiagnosticIds)` | Run the analyzer and assert that the diagnostics match the `[|…|]`-marked locations. | +| `WithDiagnosticSeverity(id, severity, filename)` | Configure the severity of a diagnostic, like `dotnet_diagnostic..severity` in an .editorconfig. Omit `filename` to apply it to all source files. File-specific severity overrides all-source severity, and tree-level severity overrides `WithGlobalDiagnosticSeverity`. | +| `WithGlobalDiagnosticSeverity(id, severity)` | Configure the severity of a diagnostic globally for the compilation, like a global configuration file. Applies only when no tree-level severity is configured. | +| `AssertDiagnostics(expectedDiagnosticIds)` | Run the analyzer and assert that the diagnostics match the `[|…|]`-marked locations and their effective severities. Diagnostics suppressed by a severity configuration are not considered reported. | +| `AssertSuppressedDiagnostics(expectedDiagnosticIds)` | Assert that the analyzer reported the `[|…|]`-marked diagnostics but that they are suppressed, for example because a severity configuration (per-diagnostic, global, or bulk `dotnet_analyzer_diagnostic` EditorConfig severity) set their severity to none. Distinguishes "the analyzer did not report" from "reported but suppressed". Fails with an explanatory message when the analyzer is self-gating (opt-in) and does not report without a severity configuration. | ## Testing code fixes @@ -256,7 +259,9 @@ To configure all code fix tests in a project, use `CodeFixTest.ConfigureAllCodeF | `WithInterceptorNamespace(ns)` | Add an interceptors namespace feature flag to the compilation. | | `WithProperty(name, value)` | Add an arbitrary build property to the compilation. The property is available through global and syntax-tree analyzer config options. | | `WithEditorConfigOption(name, value, filename)` | Add an EditorConfig option to syntax-tree analyzer config options. Omit `filename` to apply it to all source files; specify a filename to scope it to that source file. EditorConfig options are not available through global analyzer config options. | -| `AssertCodeFixes()` | Apply code fixes iteratively and assert that the final source matches the expected output. | +| `WithDiagnosticSeverity(id, severity, filename)` | Configure the severity of a diagnostic, like `dotnet_diagnostic..severity` in an .editorconfig. Omit `filename` to apply it to all source files. | +| `WithGlobalDiagnosticSeverity(id, severity)` | Configure the severity of a diagnostic globally for the compilation, like a global configuration file. | +| `AssertCodeFixes()` | Apply code fixes iteratively and assert that the final source matches the expected output. Suppressed diagnostics are never offered as fixes. | ## Testing source generators @@ -312,6 +317,8 @@ To configure all source generator tests in a project, use `SourceGeneratorTest.C | `WithInterceptorNamespace(ns)` | Add an interceptors namespace feature flag to the compilation. | | `WithProperty(name, value)` | Add an arbitrary build property to the compilation. The property is available through global and syntax-tree analyzer config options. | | `WithEditorConfigOption(name, value, filename)` | Add an EditorConfig option to syntax-tree analyzer config options. Omit `filename` to apply it to all source files; specify a filename to scope it to that source file. EditorConfig options are not available through global analyzer config options. | +| `WithDiagnosticSeverity(id, severity, filename)` | Configure the severity of a diagnostic, like `dotnet_diagnostic..severity` in an .editorconfig. Omit `filename` to apply it to all source files. Applies to generator and analyzer diagnostics. | +| `WithGlobalDiagnosticSeverity(id, severity)` | Configure the severity of a diagnostic globally for the compilation, like a global configuration file. | | `Run()` | Run the source generator without running an approval test. | | `Approve(scrubber)` | Run the generator (if not already run) and perform an approval test on the generated output. | | `ShouldNotGenerateCode()` | Assert that the source generator produces no output for the given sources. | diff --git a/src/FakeAnalyzers/DiagnosticDescriptors.cs b/src/FakeAnalyzers/DiagnosticDescriptors.cs index bd99fd5..e9fa8c2 100644 --- a/src/FakeAnalyzers/DiagnosticDescriptors.cs +++ b/src/FakeAnalyzers/DiagnosticDescriptors.cs @@ -43,4 +43,12 @@ public static class DiagnosticDescriptors category: "Code", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor GeneratorReported = new( + id: DiagnosticIds.GeneratorReported, + title: "Generator reported a diagnostic", + messageFormat: "The source generator reported a diagnostic for '{0}'", + category: "Code", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); } \ No newline at end of file diff --git a/src/FakeAnalyzers/DiagnosticEmittingSourceGenerator.cs b/src/FakeAnalyzers/DiagnosticEmittingSourceGenerator.cs new file mode 100644 index 0000000..eb0ecc8 --- /dev/null +++ b/src/FakeAnalyzers/DiagnosticEmittingSourceGenerator.cs @@ -0,0 +1,29 @@ +namespace FakeAnalyzers; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +[Generator(LanguageNames.CSharp)] +public sealed class DiagnosticEmittingSourceGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var syntaxTrees = context.CompilationProvider.SelectMany(static (compilation, _) => compilation.SyntaxTrees) + .WithTrackingName(TrackingNames.SyntaxTrees); + + context.RegisterSourceOutput(syntaxTrees, static (productionContext, tree) => + { + productionContext.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.GeneratorReported, + Location.Create(tree, new TextSpan(0, 0)), + tree.FilePath)); + }); + } + + internal static class TrackingNames + { + public const string SyntaxTrees = nameof(SyntaxTrees); + + public static string[] All => [SyntaxTrees]; + } +} diff --git a/src/FakeAnalyzers/DiagnosticIds.cs b/src/FakeAnalyzers/DiagnosticIds.cs index 6166541..6ad61f7 100644 --- a/src/FakeAnalyzers/DiagnosticIds.cs +++ b/src/FakeAnalyzers/DiagnosticIds.cs @@ -11,4 +11,5 @@ public static class DiagnosticIds public const string IdentifierContainsFoo = "FAKE0003"; public const string TestFlagEnabled = "FAKE0004"; public const string EditorConfigOptionEnabled = "FAKE0005"; + public const string GeneratorReported = "FAKE0006"; } \ No newline at end of file diff --git a/src/Particular.AnalyzerTesting/AnalyzerConfigOptionsFactory.cs b/src/Particular.AnalyzerTesting/AnalyzerConfigOptionsFactory.cs index 7c9c818..be8a725 100644 --- a/src/Particular.AnalyzerTesting/AnalyzerConfigOptionsFactory.cs +++ b/src/Particular.AnalyzerTesting/AnalyzerConfigOptionsFactory.cs @@ -1,5 +1,6 @@ namespace Particular.AnalyzerTesting; +using System; using System.Collections.Generic; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; @@ -10,27 +11,37 @@ public static AnalyzerConfigOptionsProvider CreateOptionsProvider( IReadOnlyDictionary globalProperties, IReadOnlyDictionary? sourceProperties = null, IReadOnlyDictionary>? sourceFileProperties = null) - => new OptionsProvider(globalProperties, sourceProperties ?? new Dictionary(), sourceFileProperties ?? new Dictionary>()); + => new OptionsProvider(globalProperties, sourceProperties ?? new Dictionary(), sourceFileProperties ?? new Dictionary>(), excludeBulkSeverityKeys: false); - public static AnalyzerOptions CreateAnalyzerOptions( + /// + /// Like , but the syntax-tree options omit bulk analyzer + /// severity keys (dotnet_analyzer_diagnostic.severity and + /// dotnet_analyzer_diagnostic.category-…severity). Used for the neutral run that observes + /// what analyzers report before Roslyn's severity filtering kicks in. + /// + public static AnalyzerConfigOptionsProvider CreateNeutralOptionsProvider( IReadOnlyDictionary globalProperties, IReadOnlyDictionary? sourceProperties = null, IReadOnlyDictionary>? sourceFileProperties = null) - => new([], CreateOptionsProvider(globalProperties, sourceProperties, sourceFileProperties)); + => new OptionsProvider(globalProperties, sourceProperties ?? new Dictionary(), sourceFileProperties ?? new Dictionary>(), excludeBulkSeverityKeys: true); sealed class OptionsProvider( IReadOnlyDictionary globalProperties, IReadOnlyDictionary sourceProperties, - IReadOnlyDictionary> sourceFileProperties) : AnalyzerConfigOptionsProvider + IReadOnlyDictionary> sourceFileProperties, + bool excludeBulkSeverityKeys) : AnalyzerConfigOptionsProvider { public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) { var properties = new Dictionary(globalProperties); AddProperties(properties, sourceProperties); - if (sourceFileProperties.TryGetValue(tree.FilePath, out var fileProperties)) + foreach (var (filename, fileProperties) in sourceFileProperties) { - AddProperties(properties, fileProperties); + if (FilenameComparer.Matches(filename, tree.FilePath)) + { + AddProperties(properties, fileProperties); + } } return new DictionaryAnalyzerConfigOptions(properties); @@ -41,13 +52,22 @@ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) public override AnalyzerConfigOptions GlobalOptions { get; } = new DictionaryAnalyzerConfigOptions(globalProperties); - static void AddProperties(Dictionary destination, IReadOnlyDictionary source) + void AddProperties(Dictionary destination, IReadOnlyDictionary source) { foreach (var (key, value) in source) { + if (excludeBulkSeverityKeys && IsBulkSeverityKey(key)) + { + continue; + } + destination[key] = value; } } + + static bool IsBulkSeverityKey(string key) + => key.StartsWith("dotnet_analyzer_diagnostic.", StringComparison.Ordinal) && + key.EndsWith(".severity", StringComparison.Ordinal); } sealed class DictionaryAnalyzerConfigOptions(IReadOnlyDictionary properties) : AnalyzerConfigOptions diff --git a/src/Particular.AnalyzerTesting/AnalyzerTest.cs b/src/Particular.AnalyzerTesting/AnalyzerTest.cs index af7e0ef..c97755d 100644 --- a/src/Particular.AnalyzerTesting/AnalyzerTest.cs +++ b/src/Particular.AnalyzerTesting/AnalyzerTest.cs @@ -1,10 +1,13 @@ namespace Particular.AnalyzerTesting; using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Runtime.CompilerServices; +using System.Text; using System.Threading.Tasks; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using NUnit.Framework; @@ -50,7 +53,9 @@ public AnalyzerTest WithSource(string source, string? filename = null) public Task AssertDiagnostics(params string[] expectedDiagnosticIds) => AssertDiagnostics(expectedDiagnosticIds, []); /// - /// Assert that the analyzer detects the expected diagnostic ids. + /// Assert that the analyzer detects the expected diagnostic ids. Diagnostics whose severity is + /// configured to 'none' (for example through WithDiagnosticSeverity) are not considered + /// reported, since they are not visible to the user. /// public async Task AssertDiagnostics(string[] expectedDiagnosticIds, string[] ignoreDiagnosticIds) { @@ -66,14 +71,185 @@ public async Task AssertDiagnostics(string[] expectedDiagnosticIds, string[] ign var compilation = await project.GetCompilationAsync(cancellationToken) ?? throw new Exception("Result of project compilation is null"); compilation.Compile(!suppressCompilationErrors); - var analyzerDiagnostics = await GetAnalyzerDiagnostics(compilation, ignoreDiagnosticIds, cancellationToken); + var expectedDiagnostics = CreateExpectedDiagnostics(codeSources, expectedDiagnosticIds); + + var analyzerDiagnostics = await GetAnalyzerDiagnostics(compilation, ignoreDiagnosticIds, includeSeveritySuppressed: false, cancellationToken); + + var actualDiagnostics = analyzerDiagnostics.Visible + .Select(diagnostic => new DiagnosticInfo( + diagnostic.Location.SourceTree?.FilePath ?? "", + diagnostic.Location.SourceSpan, + diagnostic.Id, + diagnostic.Severity)); + + try + { + Assert.That(actualDiagnostics, Is.EqualTo(expectedDiagnostics)); + } + catch (AssertionException) + { + var withSuppression = await GetAnalyzerDiagnostics(compilation, ignoreDiagnosticIds, includeSeveritySuppressed: true, cancellationToken); + + var suppressedDiagnostics = withSuppression.SeveritySuppressed + .Select(diagnostic => new DiagnosticInfo( + diagnostic.Location.SourceTree?.FilePath ?? "", + diagnostic.Location.SourceSpan, + diagnostic.Id, + diagnostic.Severity)); + + throw new AssertionException(BuildDiagnosticMismatchMessage(expectedDiagnostics, actualDiagnostics, suppressedDiagnostics)); + } + } + + /// + /// Assert that the analyzer reports the expected diagnostic ids at the [|…|]-marked locations, + /// but that the diagnostics are suppressed by the configured severity (for example through + /// WithDiagnosticSeverity(id, ReportDiagnostic.Suppress) or a bulk + /// dotnet_analyzer_diagnostic.severity EditorConfig option). + /// This distinguishes "the analyzer did not report a diagnostic" from "the analyzer reported it, + /// but an .editorconfig-style severity configuration set its severity to none". + /// Suppression through pragma directives or DiagnosticSuppressor is not treated as + /// severity suppression. The analyzer must report the diagnostic when no severity is configured; + /// a self-gating (opt-in) analyzer that only reports when explicitly enabled cannot be verified + /// with this assertion. + /// + public Task AssertSuppressedDiagnostics(params string[] expectedDiagnosticIds) + => AssertSuppressedDiagnostics(expectedDiagnosticIds, []); + + /// + /// Assert that the analyzer reports the expected diagnostic ids at the [|…|]-marked locations, + /// but that the diagnostics are suppressed by the configured severity. + /// + public async Task AssertSuppressedDiagnostics(string[] expectedDiagnosticIds, string[] ignoreDiagnosticIds) + { + var cancellationToken = TestContext.CurrentContext.CancellationToken; + + var codeSources = sources.Select(s => CreateFile(s.Filename, s.MarkupSource, parseDiagnosticMarkup: true)) + .ToImmutableArray(); + OutputCode(codeSources); + + var project = CreateProject(codeSources); + _ = await GetCompilerDiagnostics(project, cancellationToken); + + var compilation = await project.GetCompilationAsync(cancellationToken) ?? throw new Exception("Result of project compilation is null"); + compilation.Compile(!suppressCompilationErrors); + + var analyzerDiagnostics = await GetAnalyzerDiagnostics(compilation, ignoreDiagnosticIds, includeSeveritySuppressed: true, cancellationToken); + + var expectedDiagnostics = CreateExpectedDiagnostics(codeSources, expectedDiagnosticIds); + + var actualDiagnostics = analyzerDiagnostics.SeveritySuppressed + .Select(diagnostic => new DiagnosticInfo( + diagnostic.Location.SourceTree?.FilePath ?? "", + diagnostic.Location.SourceSpan, + diagnostic.Id, + DiagnosticSeverity.Hidden)); + + try + { + Assert.That(actualDiagnostics, Is.EqualTo(expectedDiagnostics)); + } + catch (AssertionException) + { + var visibleDiagnostics = analyzerDiagnostics.Visible + .Select(diagnostic => new DiagnosticInfo( + diagnostic.Location.SourceTree?.FilePath ?? "", + diagnostic.Location.SourceSpan, + diagnostic.Id, + diagnostic.Severity)); + + throw new AssertionException(BuildDiagnosticMismatchMessage(expectedDiagnostics, visibleDiagnostics, actualDiagnostics, suppressionAssertion: true)); + } + } + + IEnumerable CreateExpectedDiagnostics(ImmutableArray codeSources, string[] expectedDiagnosticIds) + => codeSources + .SelectMany(src => src.Spans.Select(span => (src.Filename, span))) + .SelectMany(src => expectedDiagnosticIds.Select(id => new DiagnosticInfo(src.Filename, src.span, id, ExpectedSeverity(src.Filename, id)))); + + DiagnosticSeverity ExpectedSeverity(string filename, string diagnosticId) + { + var descriptor = analyzers.SelectMany(analyzer => analyzer.SupportedDiagnostics).FirstOrDefault(descriptor => descriptor.Id == diagnosticId); + var configured = ResolveExpectedSeverity(filename, diagnosticId, descriptor); + if (configured != ReportDiagnostic.Default) + { + return configured switch + { + ReportDiagnostic.Default => DiagnosticSeverity.Hidden, + ReportDiagnostic.Error => DiagnosticSeverity.Error, + ReportDiagnostic.Warn => DiagnosticSeverity.Warning, + ReportDiagnostic.Info => DiagnosticSeverity.Info, + ReportDiagnostic.Hidden => DiagnosticSeverity.Hidden, + // Suppress: never visible, so the assertion fails. + ReportDiagnostic.Suppress => DiagnosticSeverity.Hidden, + _ => DiagnosticSeverity.Hidden + }; + } + + // Unconfigured: a disabled-by-default descriptor is effectively suppressed. + return descriptor is null + ? DiagnosticSeverity.Hidden + : MapDescriptorSeverity(descriptor) switch + { + ReportDiagnostic.Default => DiagnosticSeverity.Hidden, + ReportDiagnostic.Error => DiagnosticSeverity.Error, + ReportDiagnostic.Warn => DiagnosticSeverity.Warning, + ReportDiagnostic.Info => DiagnosticSeverity.Info, + ReportDiagnostic.Hidden => DiagnosticSeverity.Hidden, + ReportDiagnostic.Suppress => DiagnosticSeverity.Hidden, + _ => DiagnosticSeverity.Hidden + }; + } + + static string BuildDiagnosticMismatchMessage(IEnumerable expected, IEnumerable actual, IEnumerable severitySuppressed, bool suppressionAssertion = false) + { + var expectedArray = expected.ToArray(); + var actualArray = actual.ToArray(); + var suppressedArray = severitySuppressed.ToArray(); + + var sb = new StringBuilder(); + sb.AppendLine("The analyzer diagnostics did not match the expected diagnostics."); - var expectedDiagnostics = codeSources.SelectMany(src => src.Spans.Select(span => (src.Filename, span))) - .SelectMany(src => expectedDiagnosticIds.Select(id => new DiagnosticInfo(src.Filename, src.span, id))); + foreach (var expectedInfo in expectedArray) + { + var exactMatch = actualArray.FirstOrDefault(actualInfo => SameLocation(expectedInfo, actualInfo)); + if (exactMatch is not null) + { + if (exactMatch.Severity != expectedInfo.Severity) + { + sb.AppendLine(expectedInfo.Severity == DiagnosticSeverity.Hidden + ? $" {expectedInfo.Id} at {FormatLocation(expectedInfo)} was reported with severity {exactMatch.Severity} instead of being suppressed. Use AssertDiagnostics to assert visible diagnostics." + : $" {expectedInfo.Id} at {FormatLocation(expectedInfo)} was reported with severity {exactMatch.Severity}, but the expected severity is {expectedInfo.Severity}. Configure the severity with WithDiagnosticSeverity or adjust the expected result."); + } - var actualDiagnostics = analyzerDiagnostics - .Select(diagnostic => new DiagnosticInfo(diagnostic.Location.SourceTree?.FilePath ?? "", diagnostic.Location.SourceSpan, diagnostic.Id)); + continue; + } - Assert.That(actualDiagnostics, Is.EqualTo(expectedDiagnostics)); + if (suppressedArray.Any(suppressedInfo => SameLocation(expectedInfo, suppressedInfo))) + { + sb.AppendLine($" {expectedInfo.Id} at {FormatLocation(expectedInfo)} was reported by the analyzer, but is suppressed, most likely because its severity is configured to 'none'. Use AssertSuppressedDiagnostics to assert suppressed diagnostics, or remove the suppression."); + continue; + } + + sb.AppendLine(suppressionAssertion + ? $" {expectedInfo.Id} at {FormatLocation(expectedInfo)} was not reported by the analyzer even without a severity configuration, so it was not suppressed by severity either. Either it is not emitted at this location, or the analyzer is self-gating (opt-in) and only reports when explicitly enabled." + : $" {expectedInfo.Id} at {FormatLocation(expectedInfo)} was not reported by the analyzer."); + } + + foreach (var actualInfo in actualArray) + { + if (!expectedArray.Any(expectedInfo => SameLocation(expectedInfo, actualInfo))) + { + sb.AppendLine($" Unexpected diagnostic {actualInfo.Id} at {FormatLocation(actualInfo)} with severity {actualInfo.Severity}."); + } + } + + return sb.ToString(); } -} \ No newline at end of file + + static bool SameLocation(DiagnosticInfo left, DiagnosticInfo right) + => left.Filename == right.Filename && left.Span == right.Span && left.Id == right.Id; + + static string FormatLocation(DiagnosticInfo diagnostic) + => $"{diagnostic.Filename}({diagnostic.Span.Start},{diagnostic.Span.End})"; +} diff --git a/src/Particular.AnalyzerTesting/BaseAnalyzerTest.cs b/src/Particular.AnalyzerTesting/BaseAnalyzerTest.cs index 824d2ad..c20e5da 100644 --- a/src/Particular.AnalyzerTesting/BaseAnalyzerTest.cs +++ b/src/Particular.AnalyzerTesting/BaseAnalyzerTest.cs @@ -49,7 +49,8 @@ private protected Project CreateProject(IEnumerable codeSources) var project = new AdhocWorkspace() .AddProject(outputAssemblyName, LanguageNames.CSharp) .WithParseOptions(parseOptions) - .WithCompilationOptions(new CSharpCompilationOptions(buildOutputType)) + .WithCompilationOptions(new CSharpCompilationOptions(buildOutputType) + .WithSyntaxTreeOptionsProvider(CreateSyntaxTreeOptionsProvider())) .AddMetadataReferences(References); foreach (var source in codeSources) @@ -83,10 +84,13 @@ private protected static async Task GetCompilerDiagnostics(Project return compilerDiagnostics; } - private protected async Task GetAnalyzerDiagnostics(Compilation compilation, string[] ignoreDiagnosticIds, CancellationToken cancellationToken = default) + private protected async Task GetAnalyzerDiagnostics(Compilation compilation, string[] ignoreDiagnosticIds, bool includeSeveritySuppressed, CancellationToken cancellationToken = default) { + var optionsProvider = AnalyzerConfigOptionsFactory.CreateOptionsProvider(features, editorConfigOptions, editorConfigOptionsByFilename); + var configuredProvider = CreateSyntaxTreeOptionsProvider(); + var analyzerTasks = analyzers - .Select(analyzer => compilation.GetAnalyzerDiagnostics(analyzer, features, editorConfigOptions, editorConfigOptionsByFilename, cancellationToken)) + .Select(analyzer => compilation.GetAnalyzerDiagnostics(analyzer, optionsProvider, configuredProvider, reportSuppressedDiagnostics: true, cancellationToken)) .ToArray(); await Task.WhenAll(analyzerTasks); @@ -97,9 +101,167 @@ private protected async Task GetAnalyzerDiagnostics(Compilation co .ToArray(); OutputAnalyzerDiagnostics(analyzerDiagnostics); - return analyzerDiagnostics; + + // Suppressed diagnostics (pragma, DiagnosticSuppressor) are reported with IsSuppressed set; + // they are never visible but were not suppressed by a severity configuration. + var visibleDiagnostics = analyzerDiagnostics.Where(d => !d.IsSuppressed).ToArray(); + + if (!includeSeveritySuppressed) + { + return new AnalyzerDiagnosticsResult(visibleDiagnostics, []); + } + + // Roslyn drops severity-suppressed diagnostics before reporting, even with + // reportSuppressedDiagnostics enabled. Re-run with a neutral provider to observe what the + // analyzer reported without severity filtering, so the two cases can be distinguished. + // The neutral run also strips bulk severity keys from the analyzer config options, since + // Roslyn applies them through the driver as well. + var neutralProvider = SyntaxTreeOptionsProviderFactory.CreateNeutral(); + var neutralOptionsProvider = AnalyzerConfigOptionsFactory.CreateNeutralOptionsProvider(features, editorConfigOptions, editorConfigOptionsByFilename); + var neutralTasks = analyzers + .Select(analyzer => compilation.GetAnalyzerDiagnostics(analyzer, neutralOptionsProvider, neutralProvider, reportSuppressedDiagnostics: true, cancellationToken)) + .ToArray(); + + await Task.WhenAll(neutralTasks); + + var severitySuppressedDiagnostics = neutralTasks + .SelectMany(t => t.Result) + .Where(d => !ignoreDiagnosticIds.Contains(d.Id)) + // Exclude diagnostics suppressed through pragma directives or DiagnosticSuppressors: + // they are reported with IsSuppressed set and are not suppressed by a severity configuration. + .Where(d => !d.IsSuppressed) + .Where(d => !visibleDiagnostics.Any(v => v.Id == d.Id && v.Location.SourceSpan == d.Location.SourceSpan && v.Location.SourceTree?.FilePath == d.Location.SourceTree?.FilePath)) + .ToArray(); + + return new AnalyzerDiagnosticsResult(visibleDiagnostics, severitySuppressedDiagnostics); + } + + /// + /// Resolve the expected effective severity of a diagnostic id for a source file, mirroring how + /// Roslyn resolves severity: file-specific, then all-source, then global, then bulk category and + /// all-analyzer configuration, then the descriptor default. + /// + private protected ReportDiagnostic ResolveExpectedSeverity(string filename, string diagnosticId, DiagnosticDescriptor? descriptor) + { + foreach (var (configuredFilename, fileSeverities) in diagnosticSeveritiesByFilename) + { + if (FilenameComparer.Matches(configuredFilename, filename) && + fileSeverities.TryGetValue(diagnosticId, out var fileSeverity)) + { + return fileSeverity; + } + } + + if (diagnosticSeverities.TryGetValue(diagnosticId, out var allSourceSeverity)) + { + return allSourceSeverity; + } + + if (globalDiagnosticSeverities.TryGetValue(diagnosticId, out var globalSeverity)) + { + return globalSeverity; + } + + // Bulk configuration only applies to diagnostics enabled by default. + if (descriptor is { IsEnabledByDefault: true }) + { + var bulkOptions = new Dictionary(editorConfigOptions); + + foreach (var (configuredFilename, fileOptions) in editorConfigOptionsByFilename) + { + if (FilenameComparer.Matches(configuredFilename, filename)) + { + foreach (var (key, value) in fileOptions) + { + bulkOptions[key] = value; + } + } + } + + if (TryGetBulkSeverity(bulkOptions, $"dotnet_analyzer_diagnostic.category-{descriptor.Category}.severity", out var bulkSeverity)) + { + return bulkSeverity; + } + + if (TryGetBulkSeverity(bulkOptions, "dotnet_analyzer_diagnostic.severity", out bulkSeverity)) + { + return bulkSeverity; + } + } + + return ReportDiagnostic.Default; } + static bool TryGetBulkSeverity(IReadOnlyDictionary options, string key, out ReportDiagnostic severity) + { + if (options.TryGetValue(key, out var value) && TryParseSeverity(value, out severity)) + { + return true; + } + + severity = ReportDiagnostic.Default; + return false; + } + + // Mirrors AnalyzerConfigSet.TryParseSeverity. + static bool TryParseSeverity(string value, out ReportDiagnostic severity) + { + if (string.Equals(value, "default", StringComparison.OrdinalIgnoreCase)) + { + severity = ReportDiagnostic.Default; + return true; + } + + if (string.Equals(value, "error", StringComparison.OrdinalIgnoreCase)) + { + severity = ReportDiagnostic.Error; + return true; + } + + if (string.Equals(value, "warning", StringComparison.OrdinalIgnoreCase)) + { + severity = ReportDiagnostic.Warn; + return true; + } + + if (string.Equals(value, "suggestion", StringComparison.OrdinalIgnoreCase)) + { + severity = ReportDiagnostic.Info; + return true; + } + + if (string.Equals(value, "silent", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "refactoring", StringComparison.OrdinalIgnoreCase)) + { + severity = ReportDiagnostic.Hidden; + return true; + } + + if (string.Equals(value, "none", StringComparison.OrdinalIgnoreCase)) + { + severity = ReportDiagnostic.Suppress; + return true; + } + + severity = ReportDiagnostic.Default; + return false; + } + + private protected static ReportDiagnostic MapDescriptorSeverity(DiagnosticDescriptor descriptor) + => descriptor.IsEnabledByDefault + ? MapSeverityToReport(descriptor.DefaultSeverity) + : ReportDiagnostic.Suppress; + + static ReportDiagnostic MapSeverityToReport(DiagnosticSeverity severity) + => severity switch + { + DiagnosticSeverity.Error => ReportDiagnostic.Error, + DiagnosticSeverity.Warning => ReportDiagnostic.Warn, + DiagnosticSeverity.Info => ReportDiagnostic.Info, + DiagnosticSeverity.Hidden => ReportDiagnostic.Hidden, + _ => ReportDiagnostic.Hidden + }; + private protected SourceFile CreateFile(string filename, string sourceCode, bool parseDiagnosticMarkup) { var code = new StringBuilder(sourceCode.Length + (commonUsings.Count * 20)); @@ -211,5 +373,6 @@ static void OutputAnalyzerDiagnostics(Diagnostic[] analyzerDiagnostics) } private protected record SourceFile(string Filename, string Source, TextSpan[] Spans); - private protected record DiagnosticInfo(string Filename, TextSpan Span, string Id); + private protected record DiagnosticInfo(string Filename, TextSpan Span, string Id, DiagnosticSeverity Severity); + private protected record AnalyzerDiagnosticsResult(Diagnostic[] Visible, Diagnostic[] SeveritySuppressed); } diff --git a/src/Particular.AnalyzerTesting/BaseCompilationTest.cs b/src/Particular.AnalyzerTesting/BaseCompilationTest.cs index dd3f16f..b8af74b 100644 --- a/src/Particular.AnalyzerTesting/BaseCompilationTest.cs +++ b/src/Particular.AnalyzerTesting/BaseCompilationTest.cs @@ -22,6 +22,9 @@ public abstract class BaseCompilationTest where TSelf : BaseCompilationTe private protected readonly Dictionary features = []; private protected readonly Dictionary editorConfigOptions = []; private protected readonly Dictionary> editorConfigOptionsByFilename = []; + private protected readonly Dictionary diagnosticSeverities = []; + private protected readonly Dictionary> diagnosticSeveritiesByFilename = []; + private protected readonly Dictionary globalDiagnosticSeverities = []; private protected BaseCompilationTest(string? outputAssemblyName = null) { @@ -160,4 +163,46 @@ public TSelf WithEditorConfigOption(string name, string value, string? filename fileOptions.Add(name, value); return Self; } + + /// + /// Configure the severity of a diagnostic id for every source file in the test, equivalent to + /// dotnet_diagnostic.<id>.severity = ... in an .editorconfig applied to all files. + /// The severity is available to Roslyn through the compilation's . + /// + public TSelf WithDiagnosticSeverity(string diagnosticId, ReportDiagnostic severity) + { + diagnosticSeverities[diagnosticId] = severity; + return Self; + } + + /// + /// Configure the severity of a diagnostic id for a specific source file, equivalent to a + /// file-scoped [filename] .editorconfig section. A file-specific severity overrides the + /// all-source severity for that file. + /// + public TSelf WithDiagnosticSeverity(string diagnosticId, ReportDiagnostic severity, string filename) + { + if (!diagnosticSeveritiesByFilename.TryGetValue(filename, out var fileSeverities)) + { + fileSeverities = []; + diagnosticSeveritiesByFilename.Add(filename, fileSeverities); + } + + fileSeverities[diagnosticId] = severity; + return Self; + } + + /// + /// Configure the severity of a diagnostic id globally for the compilation, equivalent to a global + /// configuration file. A global severity only applies when no tree-level severity (file-specific + /// or all-source) is configured for the diagnostic. + /// + public TSelf WithGlobalDiagnosticSeverity(string diagnosticId, ReportDiagnostic severity) + { + globalDiagnosticSeverities[diagnosticId] = severity; + return Self; + } + + private protected SyntaxTreeOptionsProvider CreateSyntaxTreeOptionsProvider() + => SyntaxTreeOptionsProviderFactory.Create(diagnosticSeverities, diagnosticSeveritiesByFilename, globalDiagnosticSeverities); } \ No newline at end of file diff --git a/src/Particular.AnalyzerTesting/CodeFixTest.cs b/src/Particular.AnalyzerTesting/CodeFixTest.cs index 8a5f97a..256be01 100644 --- a/src/Particular.AnalyzerTesting/CodeFixTest.cs +++ b/src/Particular.AnalyzerTesting/CodeFixTest.cs @@ -90,14 +90,14 @@ public async Task AssertCodeFixes() } compilation.Compile(!suppressCompilationErrors); - var analyzerDiagnostics = await GetAnalyzerDiagnostics(compilation, [], cancellationToken); + var analyzerDiagnostics = await GetAnalyzerDiagnostics(compilation, [], includeSeveritySuppressed: false, cancellationToken); - if (analyzerDiagnostics.Length == 0) + if (analyzerDiagnostics.Visible.Length == 0) { break; } - var actions = await GetCodeFixActions(project, analyzerDiagnostics, cancellationToken); + var actions = await GetCodeFixActions(project, analyzerDiagnostics.Visible, cancellationToken); if (actions.Length == 0) { break; @@ -150,7 +150,11 @@ public async Task AssertCodeFixes() async Task<(Document Document, CodeAction Action)[]> GetCodeFixActions(Project project, Diagnostic[] diagnostics, CancellationToken cancellationToken) { - var diagnosticsByFile = diagnostics.ToLookup(d => d.Location.SourceTree!.FilePath); + // Suppressed diagnostics (for example through pragma directives or a DiagnosticSuppressor) + // must never be offered as code fixes. Severity-suppressed diagnostics do not reach this + // point at all because Roslyn drops them before reporting. + var visibleDiagnostics = diagnostics.Where(diagnostic => !diagnostic.IsSuppressed).ToArray(); + var diagnosticsByFile = visibleDiagnostics.ToLookup(d => d.Location.SourceTree!.FilePath); var fixesById = codeFixes.SelectMany(fix => fix.FixableDiagnosticIds.Select(id => (id, fix))) .ToLookup(f => f.id, f => f.fix); diff --git a/src/Particular.AnalyzerTesting/CompilationExtensions.cs b/src/Particular.AnalyzerTesting/CompilationExtensions.cs index 8206ae4..87bad16 100644 --- a/src/Particular.AnalyzerTesting/CompilationExtensions.cs +++ b/src/Particular.AnalyzerTesting/CompilationExtensions.cs @@ -34,20 +34,27 @@ public void Compile(bool throwOnFailure = true) public async Task> GetAnalyzerDiagnostics( DiagnosticAnalyzer analyzer, - IReadOnlyDictionary globalProperties, - IReadOnlyDictionary sourceProperties, - IReadOnlyDictionary> sourceFileProperties, + AnalyzerConfigOptionsProvider optionsProvider, + SyntaxTreeOptionsProvider syntaxTreeOptionsProvider, + bool reportSuppressedDiagnostics, CancellationToken cancellationToken = default) { var exceptions = new List(); var analysisOptions = new CompilationWithAnalyzersOptions( - AnalyzerConfigOptionsFactory.CreateAnalyzerOptions(globalProperties, sourceProperties, sourceFileProperties), + new AnalyzerOptions([], optionsProvider), (exception, _, __) => exceptions.Add(exception), concurrentAnalysis: false, - logAnalyzerExecutionTime: false); + logAnalyzerExecutionTime: false, + reportSuppressedDiagnostics: reportSuppressedDiagnostics); - var diagnostics = await compilation + // Swap in the requested severity provider. The compilation is created by the test with + // the configured provider, but the neutral provider is used to observe what analyzers + // report before Roslyn's severity filtering kicks in. + var compilationWithSeverity = compilation.WithOptions( + compilation.Options.WithSyntaxTreeOptionsProvider(syntaxTreeOptionsProvider)); + + var diagnostics = await compilationWithSeverity .WithAnalyzers([analyzer], analysisOptions) .GetAnalyzerDiagnosticsAsync(cancellationToken); diff --git a/src/Particular.AnalyzerTesting/FilenameComparer.cs b/src/Particular.AnalyzerTesting/FilenameComparer.cs new file mode 100644 index 0000000..b232c1b --- /dev/null +++ b/src/Particular.AnalyzerTesting/FilenameComparer.cs @@ -0,0 +1,17 @@ +namespace Particular.AnalyzerTesting; + +using System; + +/// +/// Defines how configured filenames are matched against syntax tree file paths for file-scoped +/// EditorConfig options and diagnostic severities. The contract is deterministic across platforms: +/// \ and / are equivalent, comparison is case-insensitive on every platform, and +/// matching is exact on the full path (a bare Foo.cs does not match src/Foo.cs). +/// +static class FilenameComparer +{ + public static bool Matches(string configuredFilename, string syntaxTreePath) + => string.Equals(Normalize(configuredFilename), Normalize(syntaxTreePath), StringComparison.OrdinalIgnoreCase); + + static string Normalize(string path) => path.Replace('\\', '/'); +} diff --git a/src/Particular.AnalyzerTesting/SourceGeneratorTest.cs b/src/Particular.AnalyzerTesting/SourceGeneratorTest.cs index 4defd4f..6ae13d0 100644 --- a/src/Particular.AnalyzerTesting/SourceGeneratorTest.cs +++ b/src/Particular.AnalyzerTesting/SourceGeneratorTest.cs @@ -177,7 +177,8 @@ public SourceGeneratorTestResult Run() optionsProvider: optsProvider, parseOptions: parseOptions); - var compileOpts = new CSharpCompilationOptions(buildOutputType); + var compileOpts = new CSharpCompilationOptions(buildOutputType) + .WithSyntaxTreeOptionsProvider(CreateSyntaxTreeOptionsProvider()); initialCompilation = CSharpCompilation.Create(outputAssemblyName, syntaxTrees, References, compileOpts); diff --git a/src/Particular.AnalyzerTesting/SyntaxTreeOptionsProviderFactory.cs b/src/Particular.AnalyzerTesting/SyntaxTreeOptionsProviderFactory.cs new file mode 100644 index 0000000..0cc52a5 --- /dev/null +++ b/src/Particular.AnalyzerTesting/SyntaxTreeOptionsProviderFactory.cs @@ -0,0 +1,84 @@ +namespace Particular.AnalyzerTesting; + +using System.Collections.Generic; +using System.Threading; +using Microsoft.CodeAnalysis; + +/// +/// Creates implementations that back the diagnostic severity +/// configuration made through WithDiagnosticSeverity and WithGlobalDiagnosticSeverity. +/// Severity resolution mirrors Roslyn: file-specific beats all-source, tree-level beats global, +/// otherwise the descriptor default applies. +/// +static class SyntaxTreeOptionsProviderFactory +{ + public static SyntaxTreeOptionsProvider Create( + IReadOnlyDictionary allSourceSeverities, + IReadOnlyDictionary>? filenameSeverities = null, + IReadOnlyDictionary? globalSeverities = null) + => new OptionsProvider( + allSourceSeverities, + filenameSeverities ?? new Dictionary>(), + globalSeverities ?? new Dictionary()); + + /// + /// A provider that applies no severity configuration, used to observe what analyzers report + /// before Roslyn's severity filtering kicks in. + /// + public static SyntaxTreeOptionsProvider CreateNeutral() + => new OptionsProvider( + new Dictionary(), + new Dictionary>(), + new Dictionary()); + + sealed class OptionsProvider( + IReadOnlyDictionary allSourceSeverities, + IReadOnlyDictionary> filenameSeverities, + IReadOnlyDictionary globalSeverities) : SyntaxTreeOptionsProvider + { + public override GeneratedKind IsGenerated(SyntaxTree tree, CancellationToken cancellationToken = default) => GeneratedKind.NotGenerated; + +#pragma warning disable PS0003 // Make the CancellationToken parameter optional - the base member signature must be matched + public override bool TryGetDiagnosticValue(SyntaxTree tree, string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic severity) + { + if (TryGetFileSpecificSeverity(tree.FilePath, diagnosticId, out severity)) + { + return true; + } + + if (allSourceSeverities.TryGetValue(diagnosticId, out severity)) + { + return true; + } + + severity = ReportDiagnostic.Default; + return false; + } + + public override bool TryGetGlobalDiagnosticValue(string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic severity) + { + if (globalSeverities.TryGetValue(diagnosticId, out severity)) + { + return true; + } + + severity = ReportDiagnostic.Default; + return false; + } +#pragma warning restore PS0003 + + bool TryGetFileSpecificSeverity(string treePath, string diagnosticId, out ReportDiagnostic severity) + { + foreach (var (filename, severities) in filenameSeverities) + { + if (FilenameComparer.Matches(filename, treePath) && severities.TryGetValue(diagnosticId, out severity)) + { + return true; + } + } + + severity = ReportDiagnostic.Default; + return false; + } + } +} diff --git a/src/Tests/Analyzers/DiagnosticSeverityTests.cs b/src/Tests/Analyzers/DiagnosticSeverityTests.cs new file mode 100644 index 0000000..cef86bd --- /dev/null +++ b/src/Tests/Analyzers/DiagnosticSeverityTests.cs @@ -0,0 +1,183 @@ +namespace Tests; + +using System.Threading.Tasks; +using FakeAnalyzers; +using Microsoft.CodeAnalysis; +using NUnit.Framework; +using Particular.AnalyzerTesting; + +public class DiagnosticSeverityTests +{ + [Test] + public Task SeverityChangeMakesDiagnosticVisibleAsWarning() => + AnalyzerTest.ForAnalyzer() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Warn) + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task SuppressedDiagnosticIsStillReportedByAnalyzer() => + AnalyzerTest.ForAnalyzer() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress) + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertSuppressedDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task NothingIsVisibleWhenDiagnosticIsSuppressed() => + AnalyzerTest.ForAnalyzer() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress) + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertDiagnostics(); + + [Test] + public void AssertSuppressedDiagnosticsThrowsWhenDiagnosticWasNotReported() + { + Assert.ThrowsAsync(async () => + { + await AnalyzerTest.ForAnalyzer() + .WithSource("public class MyClass { public string [|Bar|] { get; set; } }") + .AssertSuppressedDiagnostics(DiagnosticIds.IdentifierContainsFoo); + }); + } + + [Test] + public Task FileSpecificSeverityOverridesAllSourceSeverity() => + AnalyzerTest.ForAnalyzer() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress) + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Warn, "FileA.cs") + .WithSource("public class MyClass { public string [|FooA|] { get; set; } }", "FileA.cs") + .WithSource("public class Other { public string FooB { get; set; } }", "FileB.cs") + .AssertDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task AllSourceSeverityOverridesGlobalSeverity() => + AnalyzerTest.ForAnalyzer() + .WithGlobalDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Warn) + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress) + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertDiagnostics(); + + [Test] + public Task GlobalSeverityAppliesWhenNoTreeLevelSeverityIsConfigured() => + AnalyzerTest.ForAnalyzer() + .WithGlobalDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Warn) + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task FileSpecificSeverityMatchesFilenameCaseInsensitively() => + AnalyzerTest.ForAnalyzer() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress, "src/Foo.cs") + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }", "src/FOO.cs") + .AssertSuppressedDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task FileSpecificSeverityTreatsDirectorySeparatorsAsEqual() => + AnalyzerTest.ForAnalyzer() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress, "dir/File.cs") + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }", "dir\\File.cs") + .AssertSuppressedDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public void PragmaSuppressedDiagnosticIsNotReportedButSuppressed() + { + // A pragma-suppressed diagnostic is not suppressed by a severity configuration, so + // AssertSuppressedDiagnostics must not treat it as one. + Assert.ThrowsAsync(async () => + { + await AnalyzerTest.ForAnalyzer() + .WithSource("#pragma warning disable FAKE0003\npublic class MyClass { public string [|FooBar|] { get; set; } }") + .AssertSuppressedDiagnostics(DiagnosticIds.IdentifierContainsFoo); + }); + } + + [Test] + public Task PragmaSuppressedDiagnosticIsNotVisible() => + AnalyzerTest.ForAnalyzer() + .WithSource("#pragma warning disable FAKE0003\npublic class MyClass { public string [|FooBar|] { get; set; } }") + .AssertDiagnostics(); + + [Test] + public Task WarnSeverityMatchesNormalizedFilename() => + AnalyzerTest.ForAnalyzer() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Warn, "src/FOO.cs") + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }", "src/foo.cs") + .AssertDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task BulkCategorySeveritySetsExpectedSeverity() => + AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("dotnet_analyzer_diagnostic.category-Code.severity", "warning") + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task BulkCategorySeveritySuppressesDiagnostic() => + AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("dotnet_analyzer_diagnostic.category-Code.severity", "none") + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertSuppressedDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task BulkAllAnalyzerSeveritySuppressesDiagnostic() => + AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("dotnet_analyzer_diagnostic.severity", "none") + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertSuppressedDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task BulkCategorySeverityWinsOverBulkAllAnalyzerSeverity() => + AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("dotnet_analyzer_diagnostic.category-Code.severity", "warning") + .WithEditorConfigOption("dotnet_analyzer_diagnostic.severity", "none") + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public Task BulkSeverityNoneHidesDiagnostic() => + AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("dotnet_analyzer_diagnostic.severity", "none") + .WithSource("public class MyClass { public string FooBar { get; set; } }") + .AssertDiagnostics(); + + [Test] + public Task ExplicitPerRuleDefaultSeverityBlocksBulkSeverity() => + AnalyzerTest.ForAnalyzer() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Default) + .WithEditorConfigOption("dotnet_analyzer_diagnostic.category-Code.severity", "none") + .WithSource("public class MyClass { public string [|FooBar|] { get; set; } }") + .AssertDiagnostics(DiagnosticIds.IdentifierContainsFoo); + + [Test] + public void SelfGatedDiagnosticIsNotTreatableAsSeveritySuppressed() + { + Assert.ThrowsAsync(async () => + { + await AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("nservicebus_enable_message_overload_migration_diagnostics", "true") + .WithSource("public class [|MyClass|] { }") + .AssertSuppressedDiagnostics(DiagnosticIds.EditorConfigOptionEnabled); + }); + } + + [Test] + public Task SeverityConfigurationSuppressesSelfGatedDiagnostic() => + AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("nservicebus_enable_message_overload_migration_diagnostics", "true") + .WithDiagnosticSeverity(DiagnosticIds.EditorConfigOptionEnabled, ReportDiagnostic.Suppress) + .WithSource("public class [|MyClass|] { }") + .AssertSuppressedDiagnostics(DiagnosticIds.EditorConfigOptionEnabled); + + [Test] + public void AssertSuppressedDiagnosticsExplainsSelfGatingAnalyzer() + { + var exception = Assert.ThrowsAsync(async () => + { + await AnalyzerTest.ForAnalyzer() + .WithSource("public class [|MyClass|] { }") + .AssertSuppressedDiagnostics(DiagnosticIds.EditorConfigOptionEnabled); + }); + + Assert.That(exception!.Message, Does.Contain("self-gating")); + } +} diff --git a/src/Tests/ApprovalFiles/ApiApproval.ApproveApi.approved.txt b/src/Tests/ApprovalFiles/ApiApproval.ApproveApi.approved.txt index f4948e6..dfbc333 100644 --- a/src/Tests/ApprovalFiles/ApiApproval.ApproveApi.approved.txt +++ b/src/Tests/ApprovalFiles/ApiApproval.ApproveApi.approved.txt @@ -5,6 +5,8 @@ namespace Particular.AnalyzerTesting { public System.Threading.Tasks.Task AssertDiagnostics(params string[] expectedDiagnosticIds) { } public System.Threading.Tasks.Task AssertDiagnostics(string[] expectedDiagnosticIds, string[] ignoreDiagnosticIds) { } + public System.Threading.Tasks.Task AssertSuppressedDiagnostics(params string[] expectedDiagnosticIds) { } + public System.Threading.Tasks.Task AssertSuppressedDiagnostics(string[] expectedDiagnosticIds, string[] ignoreDiagnosticIds) { } public Particular.AnalyzerTesting.AnalyzerTest WithSource(string source, string? filename = null) { } public static void ConfigureAllAnalyzerTests(System.Action configure) { } public static Particular.AnalyzerTesting.AnalyzerTest ForAnalyzer([System.Runtime.CompilerServices.CallerMemberName] string? outputAssemblyName = null) @@ -37,7 +39,10 @@ namespace Particular.AnalyzerTesting public TSelf SuppressCompilationErrors() { } public TSelf WithAnalyzer() where TAnalyzer : Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzer, new () { } + public TSelf WithDiagnosticSeverity(string diagnosticId, Microsoft.CodeAnalysis.ReportDiagnostic severity) { } + public TSelf WithDiagnosticSeverity(string diagnosticId, Microsoft.CodeAnalysis.ReportDiagnostic severity, string filename) { } public TSelf WithEditorConfigOption(string name, string value, string? filename = null) { } + public TSelf WithGlobalDiagnosticSeverity(string diagnosticId, Microsoft.CodeAnalysis.ReportDiagnostic severity) { } public TSelf WithInterceptorNamespace(string interceptorNamespace) { } public TSelf WithLangVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion langVersion) { } public TSelf WithProperty(string name, string value) { } diff --git a/src/Tests/Fixers/SuppressedDiagnosticFixerTests.cs b/src/Tests/Fixers/SuppressedDiagnosticFixerTests.cs new file mode 100644 index 0000000..cb49981 --- /dev/null +++ b/src/Tests/Fixers/SuppressedDiagnosticFixerTests.cs @@ -0,0 +1,84 @@ +namespace Tests.Fixers; + +using System.Threading.Tasks; +using FakeAnalyzers; +using FakeFixes; +using Microsoft.CodeAnalysis; +using NUnit.Framework; +using Particular.AnalyzerTesting; + +public class SuppressedDiagnosticFixerTests +{ + const string code = """ + public class MyFoo + { + public string Foo1 { get; set; } + public string Foo2 { get; set; } + } + """; + + const string expected = """ + public class MyFoo + { + public string Bar1 { get; set; } + public string Bar2 { get; set; } + } + """; + + const string pragmaSuppressedCode = """ + #pragma warning disable FAKE0003 + public class MyFoo + { + public string Foo1 { get; set; } + public string Foo2 { get; set; } + } + """; + + [Test] + public Task FixIsAppliedWhenDiagnosticIsNotSuppressed() => CodeFixTest.ForAnalyzer() + .WithCodeFix() + .WithSource(code, expected, "Code.cs") + .AssertCodeFixes(); + + [Test] + public Task FixIsNotAppliedWhenDiagnosticSeverityIsSuppressed() => CodeFixTest.ForAnalyzer() + .WithCodeFix() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress) + .WithSource(code, code, "Code.cs") + .AssertCodeFixes(); + + [Test] + public Task FixIsNotAppliedWhenDiagnosticIsPragmaSuppressed() => CodeFixTest.ForAnalyzer() + .WithCodeFix() + .WithSource(pragmaSuppressedCode, pragmaSuppressedCode, "Code.cs") + .AssertCodeFixes(); + + [Test] + public Task FixIsAppliedOnlyToFileWithoutSeveritySuppression() + { + const string one = """ + public class One + { + public string Foo1 { get; set; } + } + """; + const string two = """ + public class Two + { + public string Foo1 { get; set; } + } + """; + const string fixedTwo = """ + public class Two + { + public string Bar1 { get; set; } + } + """; + return CodeFixTest.ForAnalyzer() + .WithCodeFix() + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress, "One.cs") + .WithSource(one, one, "One.cs") + .WithSource(two, fixedTwo, "Two.cs") + .AssertCodeFixes(); + } +} diff --git a/src/Tests/SourceGenerators/GeneratorSeverityTests.cs b/src/Tests/SourceGenerators/GeneratorSeverityTests.cs new file mode 100644 index 0000000..6066965 --- /dev/null +++ b/src/Tests/SourceGenerators/GeneratorSeverityTests.cs @@ -0,0 +1,84 @@ +namespace Tests; + +using System.Linq; +using FakeAnalyzers; +using Microsoft.CodeAnalysis; +using NUnit.Framework; +using Particular.AnalyzerTesting; + +public class GeneratorSeverityTests +{ + const string SimpleSource = """ + public class MyClass + { + } + """; + + const string SourceWithFooProperty = """ + public class MyClass + { + public string Foo { get; set; } + } + """; + + [Test] + public void DefaultSeverityIsError() + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(SimpleSource) + .SuppressDiagnosticErrors() + .Run(); + + var diagnostic = result.GeneratorDiagnostics.Single(d => d.Id == DiagnosticIds.GeneratorReported); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + } + + [Test] + public void SuppressedGeneratorDiagnosticIsNotReported() + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(SimpleSource) + .WithDiagnosticSeverity(DiagnosticIds.GeneratorReported, ReportDiagnostic.Suppress) + .Run(); + + Assert.That(result.GeneratorDiagnostics.Select(d => d.Id), Does.Not.Contain(DiagnosticIds.GeneratorReported)); + } + + [Test] + public void GeneratorDiagnosticReportedAsWarning() + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(SimpleSource) + .WithDiagnosticSeverity(DiagnosticIds.GeneratorReported, ReportDiagnostic.Warn) + .Run(); + + var diagnostic = result.GeneratorDiagnostics.Single(d => d.Id == DiagnosticIds.GeneratorReported); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Warning)); + } + + [Test] + public void SuppressedAnalyzerDiagnosticIsNotReported() + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithAnalyzer() + .WithSource(SourceWithFooProperty) + .WithDiagnosticSeverity(DiagnosticIds.IdentifierContainsFoo, ReportDiagnostic.Suppress) + .SuppressDiagnosticErrors() + .Run(); + + Assert.That(result.AnalyzerDiagnostics.Select(d => d.Id), Does.Not.Contain(DiagnosticIds.IdentifierContainsFoo)); + } + + [Test] + public void AttachedAnalyzerReportsDiagnosticByDefault() + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithAnalyzer() + .WithSource(SourceWithFooProperty) + .SuppressDiagnosticErrors() + .SuppressCompilationErrors() + .Run(); + + Assert.That(result.AnalyzerDiagnostics.Select(d => d.Id), Contains.Item(DiagnosticIds.IdentifierContainsFoo)); + } +}