Skip to content

Commit 4472679

Browse files
Report diagnostics on return expression and arguments #280 (#284)
* Fix LINQ boundary tests * Update tests * Handle returns and arguments to methods #280 * Update CHANGELOG.md * Update CHANGELOG.md * Add separate diagnostic * Change setting name * Update diagnostic message
1 parent ac132cf commit 4472679

File tree

11 files changed

+398
-34
lines changed

11 files changed

+398
-34
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
## Unreleased
1010

11+
### Added
12+
13+
- PR [#284](https://github.com/marinasundstrom/CheckedExceptions/pull/284) Report diagnostics on return expression and arguments
14+
1115
## [2.1.1] - 2025-08-14
1216

1317
### Fixed

CheckedExceptions.Package/docs/README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,20 +151,23 @@ Add `CheckedExceptions.settings.json`:
151151
// If true, analysis on LINQ constructs will be disabled (default: false).
152152
"disableLinqSupport": false,
153153

154-
// If true, exceptions in lambdas do not have to be declared (default: false).
154+
// If true, exceptions in LINQ lambdas do not have to be declared (default: false).
155155
"disableLinqImplicitlyDeclaredExceptions": false,
156156

157+
// If true, no diagnostics will be issued on contract boundaries, such as arguments to methods and return statements (default: false).
158+
"disableLinqEnumerableBoundaryWarnings": false,
159+
157160
// If true, control flow analysis, with redundancy checks, is disabled (default: false).
158161
"disableControlFlowAnalysis": false,
159162

160163
// If true, basic redundancy checks are available when control flow analysis is disabled (default: false).
161164
"enableLegacyRedundancyChecks": false,
162165

163-
// If true, the analayzer will not warn about declaring base type Exception with [Throws] (default: false).
166+
// If true, the analyzer will not warn about declaring base type Exception with [Throws] (default: false).
164167
"disableBaseExceptionDeclaredDiagnostic": false,
165168

166-
// If true, the analayzer will not warn about throwing base type Exception (default: false).
167-
// Enable if you use another analyzer reporting a similar diagnostic.
169+
// If true, the analyzer will not warn about throwing base type Exception (default: false).
170+
// Set to true if you use another analyzer reporting a similar diagnostic.
168171
"disableBaseExceptionThrownDiagnostic": false
169172
}
170173
```
@@ -307,13 +310,15 @@ This is due to a **technical limitation**: the XML documentation files for .NET
307310
There is initial support for LINQ query operators.
308311

309312
```csharp
310-
List<string> values = new() { "10", "20", "abc", "30" };
313+
List<string> values = [ "10", "20", "abc", "30" ];
311314

312315
var tens = values
313-
.Where([Throws(typeof(FormatException), typeof(OverflowException))] s => int.Parse(s) % 10 is 0)
316+
.Where(s => int.Parse(s) % 10 is 0)
314317
.ToArray(); // THROW001: unhandled FormatException, OverflowException
315318
```
316319

320+
> Exceptions are inferred and implicit on LINQ methods, so no declarations needed. this behavior can be disabled.
321+
317322
Read about it [here](docs/linq-support.md).
318323

319324
---

CheckedExceptions.Tests/CSharpAnalyzerVerifier.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ public static DiagnosticResult UnhandledException(string exceptionType)
2020
=> AnalyzerVerifier<TAnalyzer, AnalyzerTest, TVerifier>.Diagnostic("THROW001")
2121
.WithArguments(exceptionType);
2222

23+
public static DiagnosticResult UnhandledExceptionBoundary(string collectionType, string exceptionType)
24+
=> AnalyzerVerifier<TAnalyzer, AnalyzerTest, TVerifier>.Diagnostic("THROW017")
25+
.WithArguments(collectionType, exceptionType);
26+
2327
public static DiagnosticResult Informational(string exceptionType)
2428
=> AnalyzerVerifier<TAnalyzer, AnalyzerTest, TVerifier>.Diagnostic("THROW002")
2529
.WithArguments(exceptionType);
@@ -62,8 +66,8 @@ await VerifyAnalyzerAsync(source, (test) =>
6266
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRedundantTypedCatchClause);
6367
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRedundantExceptionDeclaration);
6468
//test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdXmlDocButNoThrows);
65-
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRuleUnreachableCode);
66-
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRuleUnreachableCodeHidden);
69+
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdUnreachableCode);
70+
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdUnreachableCodeHidden);
6771

6872
test.DisabledDiagnostics.AddRange(allDiagnostics.Except(expected.Select(x => x.Id)));
6973
}
@@ -112,8 +116,8 @@ public static Task VerifyAnalyzerAsync([StringSyntax("c#-test")] string source,
112116

113117
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRedundantTypedCatchClause);
114118
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRedundantExceptionDeclaration);
115-
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRuleUnreachableCode);
116-
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRuleUnreachableCodeHidden);
119+
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdUnreachableCode);
120+
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdUnreachableCodeHidden);
117121
test.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdRedundantCatchClause);
118122

119123
if (executable)

CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.Linq.cs

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ public async Task CastOperator()
142142
IEnumerable<object> items = [];
143143
var query = items
144144
.Where([Throws(typeof(FormatException), typeof(OverflowException))] (x) => x is not null)
145-
.Cast<string>();
145+
.Cast<string>();
146146
147147
foreach (var item in query) { }
148148
""";
@@ -156,9 +156,157 @@ public async Task CastOperator()
156156
var expected3 = Verifier.UnhandledException("InvalidCastException")
157157
.WithSpan(11, 22, 11, 27);
158158

159+
var expected4 = Verifier.UnhandledException("FormatException")
160+
.WithSpan(8, 6, 8, 94);
161+
162+
var expected5 = Verifier.UnhandledException("OverflowException")
163+
.WithSpan(8, 6, 8, 94);
164+
159165
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
160166
{
161-
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3);
167+
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4, expected5);
168+
}, executable: true);
169+
}
170+
171+
[Fact]
172+
public async Task QueryAsArgument()
173+
{
174+
var test = /* lang=c#-test */ """
175+
#nullable enable
176+
using System;
177+
using System.Collections.Generic;
178+
using System.Linq;
179+
180+
IEnumerable<string> items = [];
181+
void Consume(IEnumerable<string> q) { }
182+
Consume(items.Where(x => int.Parse(x) > 0));
183+
""";
184+
185+
var expected = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "FormatException")
186+
.WithSpan(8, 15, 8, 43);
187+
188+
var expected2 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "OverflowException")
189+
.WithSpan(8, 15, 8, 43);
190+
191+
var expected3 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
192+
.WithArguments("FormatException")
193+
.WithSpan(8, 30, 8, 38);
194+
195+
var expected4 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
196+
.WithArguments("OverflowException")
197+
.WithSpan(8, 30, 8, 38);
198+
199+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
200+
{
201+
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4);
202+
}, executable: true);
203+
}
204+
205+
[Fact]
206+
public async Task EnumerableAsArgument()
207+
{
208+
var test = /* lang=c#-test */ """
209+
#nullable enable
210+
using System;
211+
using System.Collections.Generic;
212+
using System.Linq;
213+
214+
IEnumerable<string> items = [];
215+
void Consume(IEnumerable<string> q) { }
216+
var query = items.Where(x => int.Parse(x) > 0);
217+
Consume(query);
218+
""";
219+
220+
var expected = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
221+
.WithArguments("FormatException")
222+
.WithSpan(8, 34, 8, 42);
223+
224+
var expected2 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
225+
.WithArguments("OverflowException")
226+
.WithSpan(8, 34, 8, 42);
227+
228+
var expected3 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "FormatException")
229+
.WithSpan(9, 9, 9, 14);
230+
231+
var expected4 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "OverflowException")
232+
.WithSpan(9, 9, 9, 14);
233+
234+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
235+
{
236+
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4);
237+
}, executable: true);
238+
}
239+
240+
[Fact]
241+
public async Task ReturnQuery()
242+
{
243+
var test = /* lang=c#-test */ """
244+
#nullable enable
245+
using System;
246+
using System.Collections.Generic;
247+
using System.Linq;
248+
249+
IEnumerable<string> items = [];
250+
IEnumerable<string> Get()
251+
{
252+
return items.Where(x => int.Parse(x) > 0);
253+
}
254+
""";
255+
256+
var expected = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "FormatException")
257+
.WithSpan(9, 18, 9, 46);
258+
259+
var expected2 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "OverflowException")
260+
.WithSpan(9, 18, 9, 46);
261+
262+
var expected3 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
263+
.WithArguments("FormatException")
264+
.WithSpan(9, 33, 9, 41);
265+
266+
var expected4 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
267+
.WithArguments("OverflowException")
268+
.WithSpan(9, 33, 9, 41);
269+
270+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
271+
{
272+
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4);
273+
}, executable: true);
274+
}
275+
276+
[Fact]
277+
public async Task ReturnEnumerable()
278+
{
279+
var test = /* lang=c#-test */ """
280+
#nullable enable
281+
using System;
282+
using System.Collections.Generic;
283+
using System.Linq;
284+
285+
IEnumerable<string> items = [];
286+
IEnumerable<string> Get()
287+
{
288+
var query = items.Where(x => int.Parse(x) > 0);
289+
return query;
290+
}
291+
""";
292+
293+
var expected = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
294+
.WithArguments("FormatException")
295+
.WithSpan(9, 38, 9, 46);
296+
297+
var expected2 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
298+
.WithArguments("OverflowException")
299+
.WithSpan(9, 38, 9, 46);
300+
301+
var expected3 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "FormatException")
302+
.WithSpan(10, 12, 10, 17);
303+
304+
var expected4 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "OverflowException")
305+
.WithSpan(10, 12, 10, 17);
306+
307+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
308+
{
309+
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4);
162310
}, executable: true);
163311
}
164312
}

CheckedExceptions/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ THROW013 | Control flow | Warning | CheckedExceptionsAnalyzer
1818
THROW014 | Control flow | Warning | CheckedExceptionsAnalyzer
1919
THROW015 | Control flow | Warning | CheckedExceptionsAnalyzer
2020
THROW016 | Control flow | Info | CheckedExceptionsAnalyzer
21+
THROW017 | Control flow | Warning | CheckedExceptionsAnalyzer
2122
THROW020 | Control flow | Warning | CheckedExceptionsAnalyzer
2223
IDE001 | Control flow | Hidden | CheckedExceptionsAnalyzer

CheckedExceptions/AnalyzerSettings.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ public partial class AnalyzerSettings
2222
[JsonIgnore]
2323
internal bool IsLinqImplicitlyDeclaredExceptionsEnabled => !DisableLinqImplicitlyDeclaredExceptions;
2424

25+
[JsonPropertyName("disableLinqEnumerableBoundaryWarnings")]
26+
public bool DisableLinqEnumerableBoundaryWarnings { get; set; } = false;
27+
28+
[JsonIgnore]
29+
internal bool IsLinqEnumerableBoundaryWarningsEnabled => !DisableLinqEnumerableBoundaryWarnings;
30+
2531
[JsonPropertyName("disableControlFlowAnalysis")]
2632
public bool DisableControlFlowAnalysis { get; set; } = false;
2733

CheckedExceptions/CheckedExceptionsAnalyzer.Linq.cs

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,69 @@ private static void CollectLinqExceptions(
1818
AnalyzerSettings settings,
1919
CancellationToken ct = default)
2020
{
21+
// Skip if part of return statement
22+
var returnStatement = invocationSyntax.FirstAncestorOrSelf<ReturnStatementSyntax>();
23+
if (returnStatement is not null)
24+
return;
25+
26+
// Skip if part of argument
27+
var argument = invocationSyntax.FirstAncestorOrSelf<ArgumentSyntax>();
28+
if (argument is not null)
29+
return;
30+
2131
if (semanticModel.GetOperation(invocationSyntax, ct) is not IInvocationOperation termOp) return;
2232
if (!IsLinqExtension(termOp.TargetMethod)) return;
2333

2434
var name = termOp.TargetMethod.Name;
25-
if (!LinqKnowledge.TerminalOps.Contains(name)) return;
35+
var isTerminal = LinqKnowledge.TerminalOps.Contains(name);
2636

27-
// NEW: harvest predicate/selector on this terminal op
28-
CollectThrowsFromFunctionalArguments(termOp, exceptionTypes, compilation, semanticModel, settings, ct);
37+
// If this is neither a terminal operator nor crosses a boundary (argument/return), ignore
38+
if (!isTerminal && !IsBoundary(termOp))
39+
return;
2940

30-
// Existing: add built-ins for the terminal
31-
if (LinqKnowledge.BuiltIns.TryGetValue(name, out var builtInFactory))
32-
foreach (var t in builtInFactory(semanticModel.Compilation, termOp.TargetMethod))
33-
if (t is not null) exceptionTypes.Add(t);
41+
if (isTerminal)
42+
{
43+
// harvest predicate/selector on this terminal op
44+
CollectThrowsFromFunctionalArguments(termOp, exceptionTypes, compilation, semanticModel, settings, ct);
3445

35-
// Backtrack upstream
36-
var source = GetLinqSourceOperation(termOp);
37-
if (source is null) return;
46+
// add built-ins for the terminal
47+
if (LinqKnowledge.BuiltIns.TryGetValue(name, out var builtInFactory))
48+
foreach (var t in builtInFactory(semanticModel.Compilation, termOp.TargetMethod))
49+
if (t is not null) exceptionTypes.Add(t);
3850

39-
CollectDeferredChainExceptions(source, exceptionTypes, semanticModel.Compilation, semanticModel, settings);
51+
// Backtrack upstream
52+
var source = GetLinqSourceOperation(termOp);
53+
if (source is null) return;
54+
55+
CollectDeferredChainExceptions(source, exceptionTypes, semanticModel.Compilation, semanticModel, settings);
56+
}
57+
else
58+
{
59+
// Deferred query passed across a boundary – collect upstream exceptions
60+
CollectDeferredChainExceptions_ForEnumeration(termOp, exceptionTypes, compilation, semanticModel, settings, ct);
61+
}
4062
}
4163

4264
// --- helpers ---
4365

66+
private static bool IsBoundary(IOperation op)
67+
{
68+
for (var parent = op.Parent; parent is not null; parent = parent.Parent)
69+
{
70+
switch (parent)
71+
{
72+
case IArgumentOperation:
73+
case IReturnOperation:
74+
return true;
75+
case IConversionOperation or IParenthesizedOperation:
76+
continue;
77+
default:
78+
return false;
79+
}
80+
}
81+
return false;
82+
}
83+
4484
private static bool IsLinqExtension(IMethodSymbol method)
4585
{
4686
if (method is null || !method.IsExtensionMethod) return false;
@@ -94,7 +134,7 @@ private static void CollectDeferredChainExceptions(
94134
if (LinqKnowledge.TerminalOps.Contains(name))
95135
{
96136
// NEW: harvest lambdas/method groups on terminal op too
97-
CollectThrowsFromFunctionalArguments(inv, exceptionTypes, compilation, semanticModel, default);
137+
CollectThrowsFromFunctionalArguments(inv, exceptionTypes, compilation, semanticModel, settings, default);
98138

99139
if (LinqKnowledge.BuiltIns.TryGetValue(name, out var builtInFactory))
100140
foreach (var t in builtInFactory(compilation, inv.TargetMethod))
@@ -105,7 +145,7 @@ private static void CollectDeferredChainExceptions(
105145
}
106146

107147
// Unknown op: still inspect functional args
108-
CollectThrowsFromFunctionalArguments(inv, exceptionTypes, compilation, semanticModel, default);
148+
CollectThrowsFromFunctionalArguments(inv, exceptionTypes, compilation, semanticModel, settings, default);
109149
current = GetLinqSourceOperation(inv);
110150
continue;
111151

0 commit comments

Comments
 (0)