diff --git a/CHANGELOG.md b/CHANGELOG.md index a33262b..1df8633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- PR [#301](https://github.com/marinasundstrom/CheckedExceptions/pull/301) Allow treating `Exception` in `[Throws]` as a catch-all via `treatThrowsExceptionAsCatchRest` setting (base-type diagnostic unchanged) + ## [2.2.3] - 2025-08-24 ### Fixed diff --git a/CheckedExceptions.Tests/CheckedExceptions.settings.json b/CheckedExceptions.Tests/CheckedExceptions.settings.json index 307823d..b404f78 100644 --- a/CheckedExceptions.Tests/CheckedExceptions.settings.json +++ b/CheckedExceptions.Tests/CheckedExceptions.settings.json @@ -12,6 +12,7 @@ "disableLinqImplicitlyDeclaredExceptions": false, "disableControlFlowAnalysis": false, "enableLegacyRedundancyChecks": false, + "treatThrowsExceptionAsCatchRest": false, "disableBaseExceptionDeclaredDiagnostic": false, "disableBaseExceptionThrownDiagnostic": false } \ No newline at end of file diff --git a/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs b/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs new file mode 100644 index 0000000..786dcc3 --- /dev/null +++ b/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs @@ -0,0 +1,77 @@ +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace Sundstrom.CheckedExceptions.Tests; + +using Verifier = CSharpAnalyzerVerifier; + +public partial class CheckedExceptionsAnalyzerTests +{ + [Fact] + public async Task DeclaringExceptionWithSpecific_ShouldReportRedundantDiagnosticByDefault() + { + var test = /* lang=c#-test */ """ + using System; + + public class TestClass + { + [Throws(typeof(InvalidOperationException), typeof(Exception))] + public void TestMethod() + { + // Throws "FormatException" and "OverflowException" + var x = int.Parse("42"); + throw new InvalidOperationException(); + } + } + """; + + var expectedRedundant = Verifier.RedundantExceptionDeclarationBySuperType("Exception") + .WithSpan(5, 20, 5, 45); + + await Verifier.VerifyAnalyzerAsync(test, expectedRedundant); + } + + [Fact] + public async Task DeclaringExceptionWithSpecific_TreatAsCatchRest_ShouldReportBaseDiagnostic() + { + var test = /* lang=c#-test */ """ + using System; + + public class TestClass + { + [Throws(typeof(InvalidOperationException), typeof(Exception))] + public void TestMethod() + { + // Throws "FormatException" and "OverflowException" + var x = int.Parse("42"); + throw new InvalidOperationException(); + } + } + """; + + var expected = Verifier.AvoidDeclaringTypeException() + .WithSpan(5, 55, 5, 64); + + await Verifier.VerifyAnalyzerAsync(test, t => + { + t.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", """ + { + "ignoredExceptions": [], + "informationalExceptions": {}, + "treatThrowsExceptionAsCatchRest": true + } + """)); + + var allDiagnostics = CheckedExceptionsAnalyzer.AllDiagnosticsIds; + t.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRedundantTypedCatchClause); + t.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRedundantExceptionDeclaration); + t.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdUnreachableCode); + t.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdUnreachableCodeHidden); + t.DisabledDiagnostics.AddRange(allDiagnostics.Except(new[] { expected.Id })); + + t.ExpectedDiagnostics.Add(expected); + }); + } +} \ No newline at end of file diff --git a/CheckedExceptions/AnalyzerSettings.cs b/CheckedExceptions/AnalyzerSettings.cs index 4196782..f79ef86 100644 --- a/CheckedExceptions/AnalyzerSettings.cs +++ b/CheckedExceptions/AnalyzerSettings.cs @@ -58,6 +58,12 @@ public partial class AnalyzerSettings [JsonIgnore] internal bool BaseExceptionThrownDiagnosticEnabled => !DisableBaseExceptionThrownDiagnostic; + [JsonPropertyName("treatThrowsExceptionAsCatchRest")] + public bool TreatThrowsExceptionAsCatchRest { get; set; } = false; + + [JsonIgnore] + internal bool TreatThrowsExceptionAsCatchRestEnabled => TreatThrowsExceptionAsCatchRest; + [JsonPropertyName("ignoredExceptions")] public IEnumerable IgnoredExceptions { get; set; } = new List(); diff --git a/CheckedExceptions/CheckedExceptionsAnalyzer.DeclaredSuperClassDetection.cs b/CheckedExceptions/CheckedExceptionsAnalyzer.DeclaredSuperClassDetection.cs index c4ab8a7..5864fd3 100644 --- a/CheckedExceptions/CheckedExceptionsAnalyzer.DeclaredSuperClassDetection.cs +++ b/CheckedExceptions/CheckedExceptionsAnalyzer.DeclaredSuperClassDetection.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using Microsoft.CodeAnalysis; @@ -13,6 +14,7 @@ private static void CheckForRedundantThrowsHandledByDeclaredSuperClass( ThrowsContext context) { var semanticModel = context.SemanticModel; + var settings = GetAnalyzerSettings(context.Options); var declaredTypes = new HashSet(SymbolEqualityComparer.Default); var typeToExprMap = new Dictionary(SymbolEqualityComparer.Default); @@ -34,6 +36,18 @@ private static void CheckForRedundantThrowsHandledByDeclaredSuperClass( } } + if (settings.TreatThrowsExceptionAsCatchRestEnabled) + { + foreach (var type in declaredTypes.ToArray()) + { + if (type.Name == "Exception" && type.ContainingNamespace?.ToDisplayString() == "System") + { + declaredTypes.Remove(type); + typeToExprMap.Remove(type); + } + } + } + foreach (var type in declaredTypes) { foreach (var otherType in declaredTypes) diff --git a/README.md b/README.md index 4e4661a..33830b7 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,10 @@ Add `CheckedExceptions.settings.json`: // If true, basic redundancy checks are available when control flow analysis is disabled (default: false). "enableLegacyRedundancyChecks": false, + // If true, declaring [Throws(typeof(Exception))] acts as a catch-all and suppresses hierarchy redundancy checks; the base-type + // warning (THROW003) remains unless "disableBaseExceptionDeclaredDiagnostic" is set (default: false). + "treatThrowsExceptionAsCatchRest": false, + // If true, the analyzer will not warn about declaring base type Exception with [Throws] (default: false). "disableBaseExceptionDeclaredDiagnostic": false, diff --git a/SampleProject/CheckedExceptions.settings.json b/SampleProject/CheckedExceptions.settings.json index e08a3c5..bdc26db 100644 --- a/SampleProject/CheckedExceptions.settings.json +++ b/SampleProject/CheckedExceptions.settings.json @@ -10,6 +10,7 @@ "disableLinqImplicitlyDeclaredExceptions": false, "disableControlFlowAnalysis": false, "enableLegacyRedundancyChecks": false, + "treatThrowsExceptionAsCatchRest": false, "disableBaseExceptionDeclaredDiagnostic": false, "disableBaseExceptionThrownDiagnostic": false } \ No newline at end of file diff --git a/Test/CheckedExceptions.settings.json b/Test/CheckedExceptions.settings.json index e08a3c5..bdc26db 100644 --- a/Test/CheckedExceptions.settings.json +++ b/Test/CheckedExceptions.settings.json @@ -10,6 +10,7 @@ "disableLinqImplicitlyDeclaredExceptions": false, "disableControlFlowAnalysis": false, "enableLegacyRedundancyChecks": false, + "treatThrowsExceptionAsCatchRest": false, "disableBaseExceptionDeclaredDiagnostic": false, "disableBaseExceptionThrownDiagnostic": false } \ No newline at end of file diff --git a/docs/analyzer-specification.md b/docs/analyzer-specification.md index c6e35f9..69a36fd 100644 --- a/docs/analyzer-specification.md +++ b/docs/analyzer-specification.md @@ -191,6 +191,7 @@ Control flow analysis is also used to determine whether declarations are truly n * **Duplicate declarations** → **`THROW005`** * **Already covered by base type** → **`THROW008`** + (suppressed when `treatThrowsExceptionAsCatchRest` is enabled and the base type is `System.Exception`) ### Invalid placement (Core analysis) @@ -471,6 +472,17 @@ These options control whether to warn about the usage of base type `Exception`. > If another analyzer is used, it might warn about the use of base type `Exceptions` instead. +### Treat `[Throws(typeof(Exception))]` as catch-all + +Allows `[Throws(typeof(Exception))]` to act as a catch-all for undeclared exceptions and suppresses diagnostic `THROW008` for redundant declarations covered by `System.Exception`. The base-type declaration diagnostic (`THROW003`) remains active and can be disabled via `disableBaseExceptionDeclaredDiagnostic`. + +```json +{ + "treatThrowsExceptionAsCatchRest": true +} +``` + + ### Disable LINQ support This option controls whether all analysis on LINQ constructs should be disabled. diff --git a/schemas/settings-schema.json b/schemas/settings-schema.json index 3841ce1..2483dc6 100644 --- a/schemas/settings-schema.json +++ b/schemas/settings-schema.json @@ -42,6 +42,11 @@ "default": false, "description": "Indicates whether the analyzer should not warn when throwing base type 'Exception'." }, + "treatThrowsExceptionAsCatchRest": { + "type": "boolean", + "default": false, + "description": "Treat [Throws(typeof(Exception))] as a catch-all for remaining exceptions and suppress hierarchy redundancy checks; the base-type diagnostic remains active." + }, "ignoredExceptions": { "type": "array", "items": {