Skip to content

Commit 87d21af

Browse files
Follow up on return expression and arguments #280 (#286)
* Help deferred * Ignore THROW017 for materialized enumerables (#285) * Update test * Update CHANGELOG.md
1 parent 4472679 commit 87d21af

File tree

6 files changed

+153
-27
lines changed

6 files changed

+153
-27
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
### Added
1212

13-
- PR [#284](https://github.com/marinasundstrom/CheckedExceptions/pull/284) Report diagnostics on return expression and arguments
13+
- PR [#284](https://github.com/marinasundstrom/CheckedExceptions/pull/284) Report diagnostics on return expression and arguments
14+
- PR [#286](https://github.com/marinasundstrom/CheckedExceptions/pull/286) Follow up on return expression and arguments
15+
1416

1517
## [2.1.1] - 2025-08-14
1618

CheckedExceptions.Tests/CSharpAnalyzerVerifier.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ 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)
23+
public static DiagnosticResult UnhandledExceptionEnumerableBoundary(string collectionType, string exceptionType)
2424
=> AnalyzerVerifier<TAnalyzer, AnalyzerTest, TVerifier>.Diagnostic("THROW017")
2525
.WithArguments(collectionType, exceptionType);
2626

CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.Linq.cs

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,10 @@ void Consume(IEnumerable<string> q) { }
182182
Consume(items.Where(x => int.Parse(x) > 0));
183183
""";
184184

185-
var expected = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "FormatException")
185+
var expected = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "FormatException")
186186
.WithSpan(8, 15, 8, 43);
187187

188-
var expected2 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "OverflowException")
188+
var expected2 = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "OverflowException")
189189
.WithSpan(8, 15, 8, 43);
190190

191191
var expected3 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
@@ -225,10 +225,10 @@ void Consume(IEnumerable<string> q) { }
225225
.WithArguments("OverflowException")
226226
.WithSpan(8, 34, 8, 42);
227227

228-
var expected3 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "FormatException")
228+
var expected3 = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "FormatException")
229229
.WithSpan(9, 9, 9, 14);
230230

231-
var expected4 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "OverflowException")
231+
var expected4 = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "OverflowException")
232232
.WithSpan(9, 9, 9, 14);
233233

234234
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
@@ -237,6 +237,75 @@ await Verifier.VerifyAnalyzerAsync(test, setup: o =>
237237
}, executable: true);
238238
}
239239

240+
[Fact]
241+
public async Task MaterializeEnumerableAsArgument()
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+
void Consume(IEnumerable<string> q) { }
251+
var query = items.Where(x => int.Parse(x) > 0);
252+
Consume(query.ToArray());
253+
""";
254+
255+
var expected = Verifier.UnhandledException("FormatException")
256+
.WithSpan(9, 15, 9, 24);
257+
258+
var expected2 = Verifier.UnhandledException("OverflowException")
259+
.WithSpan(9, 15, 9, 24);
260+
261+
var expected3 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
262+
.WithArguments("FormatException")
263+
.WithSpan(8, 34, 8, 42);
264+
265+
var expected4 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
266+
.WithArguments("OverflowException")
267+
.WithSpan(8, 34, 8, 42);
268+
269+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
270+
{
271+
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4);
272+
}, executable: true);
273+
}
274+
275+
[Fact]
276+
public async Task MaterializeEnumerableInForeach()
277+
{
278+
var test = /* lang=c#-test */ """
279+
#nullable enable
280+
using System;
281+
using System.Collections.Generic;
282+
using System.Linq;
283+
284+
IEnumerable<string> items = [];
285+
var query = items.Where(x => int.Parse(x) > 0);
286+
foreach(var i in query.ToArray()) {}
287+
""";
288+
289+
var expected = Verifier.UnhandledException("FormatException")
290+
.WithSpan(8, 24, 8, 33);
291+
292+
var expected2 = Verifier.UnhandledException("OverflowException")
293+
.WithSpan(8, 24, 8, 33);
294+
295+
var expected3 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
296+
.WithArguments("FormatException")
297+
.WithSpan(7, 34, 7, 42);
298+
299+
var expected4 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
300+
.WithArguments("OverflowException")
301+
.WithSpan(7, 34, 7, 42);
302+
303+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
304+
{
305+
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4);
306+
}, executable: true);
307+
}
308+
240309
[Fact]
241310
public async Task ReturnQuery()
242311
{
@@ -253,10 +322,10 @@ IEnumerable<string> Get()
253322
}
254323
""";
255324

256-
var expected = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "FormatException")
325+
var expected = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "FormatException")
257326
.WithSpan(9, 18, 9, 46);
258327

259-
var expected2 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "OverflowException")
328+
var expected2 = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "OverflowException")
260329
.WithSpan(9, 18, 9, 46);
261330

262331
var expected3 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException)
@@ -298,10 +367,10 @@ IEnumerable<string> Get()
298367
.WithArguments("OverflowException")
299368
.WithSpan(9, 38, 9, 46);
300369

301-
var expected3 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "FormatException")
370+
var expected3 = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "FormatException")
302371
.WithSpan(10, 12, 10, 17);
303372

304-
var expected4 = Verifier.UnhandledExceptionBoundary("IEnumerable<string>", "OverflowException")
373+
var expected4 = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "OverflowException")
305374
.WithSpan(10, 12, 10, 17);
306375

307376
await Verifier.VerifyAnalyzerAsync(test, setup: o =>

CheckedExceptions/CheckedExceptionsAnalyzer.Linq.cs

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,27 @@ 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)
21+
if (semanticModel.GetOperation(invocationSyntax, ct) is not IInvocationOperation termOp)
2422
return;
25-
26-
// Skip if part of argument
27-
var argument = invocationSyntax.FirstAncestorOrSelf<ArgumentSyntax>();
28-
if (argument is not null)
23+
if (!IsLinqExtension(termOp.TargetMethod))
2924
return;
3025

31-
if (semanticModel.GetOperation(invocationSyntax, ct) is not IInvocationOperation termOp) return;
32-
if (!IsLinqExtension(termOp.TargetMethod)) return;
33-
3426
var name = termOp.TargetMethod.Name;
3527
var isTerminal = LinqKnowledge.TerminalOps.Contains(name);
3628

37-
// If this is neither a terminal operator nor crosses a boundary (argument/return), ignore
38-
if (!isTerminal && !IsBoundary(termOp))
39-
return;
29+
if (!isTerminal)
30+
{
31+
// Deferred invocation inside an argument/return is handled at the boundary.
32+
if (invocationSyntax.FirstAncestorOrSelf<ReturnStatementSyntax>() is not null)
33+
return;
34+
35+
if (invocationSyntax.FirstAncestorOrSelf<ArgumentSyntax>() is not null)
36+
return;
37+
38+
// If this is neither a terminal operator nor crosses a boundary, ignore
39+
if (!IsBoundary(termOp))
40+
return;
41+
}
4042

4143
if (isTerminal)
4244
{
@@ -266,6 +268,32 @@ private void CollectEnumerationExceptions(
266268
AnalyzerSettings settings,
267269
CancellationToken ct)
268270
{
271+
// If the collection is materialized (e.g., via ToArray()), the terminal invocation
272+
// itself will surface any exceptions. In that case, diagnostics are handled by
273+
// the invocation analysis, and we skip boundary reporting.
274+
// Peel conversions/parentheses to check for terminal LINQ materializers
275+
var inner = collection;
276+
while (true)
277+
{
278+
switch (inner)
279+
{
280+
case IConversionOperation conv:
281+
inner = conv.Operand; continue;
282+
case IParenthesizedOperation paren:
283+
inner = paren.Operand; continue;
284+
default:
285+
break;
286+
}
287+
break;
288+
}
289+
290+
if (inner is IInvocationOperation inv &&
291+
IsLinqExtension(inv.TargetMethod) &&
292+
LinqKnowledge.TerminalOps.Contains(inv.TargetMethod.Name))
293+
{
294+
return;
295+
}
296+
269297
// Walk upstream through the LINQ chain, harvesting [Throws] on deferred operators.
270298
CollectDeferredChainExceptions_ForEnumeration(collection, exceptionTypes, semanticModel.Compilation, semanticModel, settings, ct);
271299
}

CheckedExceptions/CheckedExceptionsAnalyzer.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,16 @@ private void AnalyzeArgument(SyntaxNodeAnalysisContext context)
286286
if (argumentSyntax.Expression is null)
287287
return;
288288

289+
// If the argument materializes the sequence (e.g., ToArray()),
290+
// the invocation analysis will handle diagnostics. Skip boundary reporting.
291+
if (argumentSyntax.Expression is InvocationExpressionSyntax invSyntax &&
292+
semanticModel.GetOperation(invSyntax) is IInvocationOperation invOp &&
293+
IsLinqExtension(invOp.TargetMethod) &&
294+
LinqKnowledge.TerminalOps.Contains(invOp.TargetMethod.Name))
295+
{
296+
return;
297+
}
298+
289299
// Collect exceptions that will surface when enumeration happens
290300
var exceptionTypes = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
291301

Test/LinqTest.cs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,23 +52,23 @@ private static void ImplicitlyDeclaredThrows()
5252
private static void WithMethodGroup()
5353
{
5454
IEnumerable<int> xs = [];
55-
Func<int, bool> pred = [Throws(typeof(FormatException))] (z) => int.Parse("10") == z;
55+
Func<int, bool> pred = z => int.Parse("10") == z;
5656
var q2 = xs.Where(pred).Where(x => x is 0);
5757
foreach (var x in q2) { }
5858
}
5959

6060
private static void NestedConversionExpression()
6161
{
6262
IEnumerable<object> xs2 = [];
63-
var q0 = xs2.Where([Throws(typeof(InvalidCastException))] (x) => x == (string)x);
63+
var q0 = xs2.Where(x => x == (string)x);
6464
foreach (var n in q0) { }
6565
}
6666

6767
private static void Cast()
6868
{
6969
IEnumerable<object> xs2 = [];
7070
var q0 = xs2
71-
.Where([Throws(typeof(FormatException), typeof(OverflowException))] (x) => x is not null)
71+
.Where(x => x is not null)
7272
.Cast<string>();
7373

7474
var x2 = q0.FirstOrDefault();
@@ -80,7 +80,7 @@ private static void Cast2()
8080
{
8181
IEnumerable<object> xs2 = [];
8282
var q0 = xs2
83-
.Where([Throws(typeof(FormatException), typeof(OverflowException))] (x) => x is not null)
83+
.Where(x => x is not null)
8484
.Cast<int>();
8585

8686
var x2 = q0.FirstOrDefault();
@@ -118,8 +118,25 @@ private static IEnumerable<int> Cast5()
118118
return Foo(q0);
119119
}
120120

121+
private static IEnumerable<int> Cast6()
122+
{
123+
IEnumerable<object> xs2 = [];
124+
var q0 = xs2
125+
.Where((x) => x is not null)
126+
.Cast<int>();
127+
128+
return Foo(q0.ToArray());
129+
}
130+
121131
private static IEnumerable<int> Foo(IEnumerable<int> q0)
122132
{
123133
throw new NotImplementedException();
124134
}
135+
136+
private static void Cast7()
137+
{
138+
IEnumerable<string> items = [];
139+
var query = items.Where(x => int.Parse(x) > 0);
140+
foreach (var i in query.ToArray()) { }
141+
}
125142
}

0 commit comments

Comments
 (0)