From 563460d90839f31fc5b15031b0f7df2ad3c0c3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20Sundstr=C3=B6m?= Date: Mon, 1 Sep 2025 13:43:24 +0200 Subject: [PATCH 1/3] feat: allow Exception declaration as catch-all --- CHANGELOG.md | 4 ++ .../CheckedExceptions.settings.json | 1 + ...sAnalyzerTests.ThrowsExceptionCatchRest.cs | 59 +++++++++++++++++++ CheckedExceptions/AnalyzerSettings.cs | 6 ++ ...onsAnalyzer.DeclaredSuperClassDetection.cs | 14 +++++ ...CheckedExceptionsAnalyzer.GeneralThrows.cs | 1 + README.md | 3 + SampleProject/CheckedExceptions.settings.json | 1 + Test/CheckedExceptions.settings.json | 1 + docs/analyzer-specification.md | 13 ++++ schemas/settings-schema.json | 5 ++ 11 files changed, 108 insertions(+) create mode 100644 CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a33262b..9347fd0 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 + ## [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..e66ce7e --- /dev/null +++ b/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs @@ -0,0 +1,59 @@ +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() + { + throw new InvalidOperationException(); + } + } + """; + + var expectedRedundant = Verifier.RedundantExceptionDeclarationBySuperType("Exception") + .WithSpan(5, 20, 5, 45); + + await Verifier.VerifyAnalyzerAsync(test, expectedRedundant); + } + + [Fact] + public async Task DeclaringExceptionWithSpecific_TreatAsCatchRest_ShouldNotReportDiagnostics() + { + var test = /* lang=c#-test */ """ + using System; + + public class TestClass + { + [Throws(typeof(InvalidOperationException), typeof(Exception))] + public void TestMethod() + { + throw new InvalidOperationException(); + } + } + """; + + await Verifier.VerifyAnalyzerAsync(test, t => + { + t.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", """ + { + "ignoredExceptions": [], + "informationalExceptions": {}, + "treatThrowsExceptionAsCatchRest": true + } + """)); + }); + } +} \ 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/CheckedExceptions/CheckedExceptionsAnalyzer.GeneralThrows.cs b/CheckedExceptions/CheckedExceptionsAnalyzer.GeneralThrows.cs index 1bcc95a..ff3310c 100644 --- a/CheckedExceptions/CheckedExceptionsAnalyzer.GeneralThrows.cs +++ b/CheckedExceptions/CheckedExceptionsAnalyzer.GeneralThrows.cs @@ -34,6 +34,7 @@ private static void CheckForGeneralExceptionThrows( continue; if (settings.BaseExceptionDeclaredDiagnosticEnabled && + !settings.TreatThrowsExceptionAsCatchRestEnabled && type.Name == generalExceptionName && type.ContainingNamespace?.ToDisplayString() == generalExceptionNamespace) { diff --git a/README.md b/README.md index 4e4661a..8c9b102 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,9 @@ 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 redundancy checks (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..5e0abbb 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) @@ -202,6 +203,7 @@ Control flow analysis is also used to determine whether declarations are truly n * **Throwing `System.Exception` directly** → **`THROW004`** * **Declaring `[Throws(typeof(Exception))]`** → **`THROW003`** + (suppressed when `treatThrowsExceptionAsCatchRest` is enabled) --- @@ -471,6 +473,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 diagnostics `THROW003` and `THROW008`. + +```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..3fd31ef 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." + }, "ignoredExceptions": { "type": "array", "items": { From 22629f2d9d4c98444ccab0de37c632a0661bbc8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20Sundstr=C3=B6m?= Date: Mon, 1 Sep 2025 13:51:33 +0200 Subject: [PATCH 2/3] test: add example covering Exception catch-all --- ...CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs b/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs index e66ce7e..9db906f 100644 --- a/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs +++ b/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs @@ -18,6 +18,8 @@ public class TestClass [Throws(typeof(InvalidOperationException), typeof(Exception))] public void TestMethod() { + // Throws "FormatException" and "OverflowException" + var x = int.Parse("42"); throw new InvalidOperationException(); } } @@ -40,6 +42,8 @@ public class TestClass [Throws(typeof(InvalidOperationException), typeof(Exception))] public void TestMethod() { + // Throws "FormatException" and "OverflowException" + var x = int.Parse("42"); throw new InvalidOperationException(); } } From 4f8677af9e16e9de9630eac2e07a563893d8aa70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20Sundstr=C3=B6m?= Date: Mon, 1 Sep 2025 14:06:05 +0200 Subject: [PATCH 3/3] fix: retain base exception diagnostic with catch-all --- CHANGELOG.md | 2 +- ...ionsAnalyzerTests.ThrowsExceptionCatchRest.cs | 16 +++++++++++++++- .../CheckedExceptionsAnalyzer.GeneralThrows.cs | 1 - README.md | 3 ++- docs/analyzer-specification.md | 3 +-- schemas/settings-schema.json | 2 +- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9347fd0..1df8633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- PR [#301](https://github.com/marinasundstrom/CheckedExceptions/pull/301) Allow treating `Exception` in `[Throws]` as a catch-all via `treatThrowsExceptionAsCatchRest` setting +- 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 diff --git a/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs b/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs index 9db906f..786dcc3 100644 --- a/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs +++ b/CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.ThrowsExceptionCatchRest.cs @@ -1,3 +1,5 @@ +using System.Linq; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Testing; @@ -32,7 +34,7 @@ public void TestMethod() } [Fact] - public async Task DeclaringExceptionWithSpecific_TreatAsCatchRest_ShouldNotReportDiagnostics() + public async Task DeclaringExceptionWithSpecific_TreatAsCatchRest_ShouldReportBaseDiagnostic() { var test = /* lang=c#-test */ """ using System; @@ -49,6 +51,9 @@ public void TestMethod() } """; + var expected = Verifier.AvoidDeclaringTypeException() + .WithSpan(5, 55, 5, 64); + await Verifier.VerifyAnalyzerAsync(test, t => { t.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", """ @@ -58,6 +63,15 @@ await Verifier.VerifyAnalyzerAsync(test, t => "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/CheckedExceptionsAnalyzer.GeneralThrows.cs b/CheckedExceptions/CheckedExceptionsAnalyzer.GeneralThrows.cs index ff3310c..1bcc95a 100644 --- a/CheckedExceptions/CheckedExceptionsAnalyzer.GeneralThrows.cs +++ b/CheckedExceptions/CheckedExceptionsAnalyzer.GeneralThrows.cs @@ -34,7 +34,6 @@ private static void CheckForGeneralExceptionThrows( continue; if (settings.BaseExceptionDeclaredDiagnosticEnabled && - !settings.TreatThrowsExceptionAsCatchRestEnabled && type.Name == generalExceptionName && type.ContainingNamespace?.ToDisplayString() == generalExceptionNamespace) { diff --git a/README.md b/README.md index 8c9b102..33830b7 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,8 @@ 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 redundancy checks (default: 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). diff --git a/docs/analyzer-specification.md b/docs/analyzer-specification.md index 5e0abbb..69a36fd 100644 --- a/docs/analyzer-specification.md +++ b/docs/analyzer-specification.md @@ -203,7 +203,6 @@ Control flow analysis is also used to determine whether declarations are truly n * **Throwing `System.Exception` directly** → **`THROW004`** * **Declaring `[Throws(typeof(Exception))]`** → **`THROW003`** - (suppressed when `treatThrowsExceptionAsCatchRest` is enabled) --- @@ -475,7 +474,7 @@ These options control whether to warn about the usage of base type `Exception`. ### Treat `[Throws(typeof(Exception))]` as catch-all -Allows `[Throws(typeof(Exception))]` to act as a catch-all for undeclared exceptions and suppresses diagnostics `THROW003` and `THROW008`. +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 { diff --git a/schemas/settings-schema.json b/schemas/settings-schema.json index 3fd31ef..2483dc6 100644 --- a/schemas/settings-schema.json +++ b/schemas/settings-schema.json @@ -45,7 +45,7 @@ "treatThrowsExceptionAsCatchRest": { "type": "boolean", "default": false, - "description": "Treat [Throws(typeof(Exception))] as a catch-all for remaining exceptions." + "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",