Skip to content

Commit f8abf38

Browse files
Add ToArray code fix for unmaterialized enumerables #280 (#292)
* Add ToArray code fix for unmaterialized enumerables * Add PR number to CHANGELOG.md
1 parent 82ece29 commit f8abf38

File tree

5 files changed

+159
-1
lines changed

5 files changed

+159
-1
lines changed

CHANGELOG.md

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

1111
### Added
1212

13+
- PR [#292](https://github.com/marinasundstrom/CheckedExceptions/pull/292) Materialize deferred enumerables with ToArray
1314
- PR [#290](https://github.com/marinasundstrom/CheckedExceptions/pull/290) Handle LINQ methods on `AsyncEnumerable`
1415
- PR [#291](https://github.com/marinasundstrom/CheckedExceptions/pull/291) LINQ support for `IQueryable` enabled by default with option to disable via `disableLinqQueryableSupport`
1516

CheckedExceptions.CodeFixes/CodeFixReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
### New Rules
44

5+
- THROW017: Materialize deferred enumeration with ToArray
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
4+
using Microsoft.CodeAnalysis;
5+
using Microsoft.CodeAnalysis.CodeActions;
6+
using Microsoft.CodeAnalysis.CodeFixes;
7+
using Microsoft.CodeAnalysis.CSharp;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
9+
using Microsoft.CodeAnalysis.Editing;
10+
11+
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
12+
13+
namespace Sundstrom.CheckedExceptions;
14+
15+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MaterializeEnumerableCodeFixProvider)), Shared]
16+
public class MaterializeEnumerableCodeFixProvider : CodeFixProvider
17+
{
18+
private const string Title = "Materialize enumeration with ToArray";
19+
20+
public sealed override ImmutableArray<string> FixableDiagnosticIds =>
21+
[CheckedExceptionsAnalyzer.DiagnosticIdDeferredMustBeHandled];
22+
23+
public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
24+
25+
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
26+
{
27+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
28+
var diagnostic = context.Diagnostics.First();
29+
var node = root?.FindNode(diagnostic.Location.SourceSpan) as ExpressionSyntax;
30+
if (node is null)
31+
return;
32+
33+
context.RegisterCodeFix(
34+
CodeAction.Create(
35+
Title,
36+
c => AddToArrayAsync(context.Document, node, c),
37+
Title),
38+
context.Diagnostics);
39+
}
40+
41+
private static async Task<Document> AddToArrayAsync(Document document, ExpressionSyntax expression, CancellationToken cancellationToken)
42+
{
43+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false) as CompilationUnitSyntax;
44+
if (root is null)
45+
return document;
46+
47+
var exprWithoutTrivia = expression.WithoutTrivia();
48+
var target = exprWithoutTrivia is IdentifierNameSyntax or MemberAccessExpressionSyntax or InvocationExpressionSyntax or ElementAccessExpressionSyntax
49+
? exprWithoutTrivia
50+
: ParenthesizedExpression(exprWithoutTrivia);
51+
52+
var newExpression = InvocationExpression(
53+
MemberAccessExpression(
54+
SyntaxKind.SimpleMemberAccessExpression,
55+
target,
56+
IdentifierName("ToArray")),
57+
ArgumentList())
58+
.WithTriviaFrom(expression);
59+
60+
var newRoot = root.ReplaceNode((SyntaxNode)expression, newExpression);
61+
62+
if (!newRoot.Usings.Any(u => u.Name is IdentifierNameSyntax id && id.Identifier.Text == "System.Linq" ||
63+
u.Name is QualifiedNameSyntax q && q.ToString() == "System.Linq"))
64+
{
65+
newRoot = newRoot.AddUsings(UsingDirective(IdentifierName("System.Linq")));
66+
}
67+
68+
return document.WithSyntaxRoot(newRoot);
69+
}
70+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
namespace Sundstrom.CheckedExceptions.Tests.CodeFixes;
2+
3+
using System.Threading.Tasks;
4+
5+
using Microsoft.CodeAnalysis.Testing;
6+
7+
using Xunit;
8+
9+
using Verifier = CSharpCodeFixVerifier<CheckedExceptionsAnalyzer, MaterializeEnumerableCodeFixProvider, Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
10+
11+
public class MaterializeEnumerableCodeFixProviderTests
12+
{
13+
[Fact]
14+
public async Task ReturnEnumerable()
15+
{
16+
var testCode = /* lang=c#-test */ """
17+
#nullable enable
18+
using System;
19+
using System.Collections.Generic;
20+
using System.Linq;
21+
22+
IEnumerable<string> items = [];
23+
IEnumerable<string> Get()
24+
{
25+
var query = items.Where(x => int.Parse(x) > 0);
26+
return query;
27+
}
28+
""";
29+
30+
var fixedCode = /* lang=c#-test */ """
31+
#nullable enable
32+
using System;
33+
using System.Collections.Generic;
34+
using System.Linq;
35+
36+
IEnumerable<string> items = [];
37+
IEnumerable<string> Get()
38+
{
39+
var query = items.Where(x => int.Parse(x) > 0);
40+
return query.ToArray();
41+
}
42+
""";
43+
44+
var expected = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdDeferredMustBeHandled)
45+
.WithArguments("IEnumerable<string>", "FormatException")
46+
.WithSpan(10, 12, 10, 17);
47+
var expected2 = Verifier.Diagnostic(CheckedExceptionsAnalyzer.DiagnosticIdDeferredMustBeHandled)
48+
.WithArguments("IEnumerable<string>", "OverflowException")
49+
.WithSpan(10, 12, 10, 17);
50+
51+
await Verifier.VerifyCodeFixAsync(testCode, new[] { expected, expected2 }, fixedCode, executable: true, setup: opt =>
52+
{
53+
opt.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdImplicitlyDeclaredException);
54+
});
55+
}
56+
}

docs/codefix-specification.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This document describes the available **code fixes** and which diagnostics they
1616
| **THROW013** | Redundant catch-all clause | 🧹 Remove redundant catch clause
1717
| **THROW014** | Catch clause has no remaining exceptions to handle | 🧹 Remove redundant catch clause
1818
| **THROW015** | Redundant catch clause | 🧹 Remove redundant catch clause
19+
| **THROW017** | Deferred enumeration crossing boundary | 🧺 Materialize enumeration with ToArray
1920
---
2021

2122
## What is a Throwing Site?
@@ -560,4 +561,33 @@ public string Data
560561
get => throw new IOException();
561562
set => throw new IOException();
562563
}
563-
```
564+
```
565+
---
566+
567+
## 🧺 `Materialize enumeration`
568+
569+
**Applies to:**
570+
571+
* `THROW017`*Deferred enumeration crossing boundary*
572+
573+
Adds `.ToArray()` to deferred sequences that leave the member, forcing materialization so exceptions surface within the current member.
574+
575+
### Example
576+
577+
```csharp
578+
// Before
579+
IEnumerable<string> items = [];
580+
IEnumerable<string> Get()
581+
{
582+
var query = items.Where(x => int.Parse(x) > 0);
583+
return query; // THROW017
584+
}
585+
586+
// After
587+
IEnumerable<string> items = [];
588+
IEnumerable<string> Get()
589+
{
590+
var query = items.Where(x => int.Parse(x) > 0);
591+
return query.ToArray();
592+
}
593+
```

0 commit comments

Comments
 (0)