Skip to content

Commit b9cb90c

Browse files
authored
Merge pull request #22541 from michaelnebel/csharp/linqinoutret
C#: Don't suggest LINQ re-writes that would capture `in`, `out`, or `ref` parameters in a lambda.
2 parents 4a19c87 + d4843fa commit b9cb90c

9 files changed

Lines changed: 221 additions & 59 deletions

File tree

csharp/ql/lib/Linq/Helpers.qll

Lines changed: 122 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -116,28 +116,79 @@ class ForEachStmtEnumerable extends ForEachStmt {
116116
}
117117
}
118118

119+
bindingset[e]
120+
private predicate acceptableForLinqCapture(Expr e) {
121+
not exists(ParameterAccess pa, Parameter p |
122+
p = pa.getTarget() and
123+
pa = e.getAChildExpr*()
124+
|
125+
p.isOutOrRef() or p.isIn() or p.isReadonlyRef()
126+
)
127+
}
128+
129+
private signature predicate linqCandidateSig(Stmt s, Expr e);
130+
131+
private module LinqFilterOpportunity<linqCandidateSig/2 linqCandidate> {
132+
predicate missed(ForEachStmtGenericEnumerable fes, Stmt s) {
133+
s = firstStmt(fes) and
134+
// The linq candidate expression accesses the loop variable, and the
135+
// candidate doesn't access an in, out, or ref parameter.
136+
exists(Expr candidate | linqCandidate(s, candidate) |
137+
fes.getVariable().getAnAccess() = candidate.getAChildExpr*() and
138+
acceptableForLinqCapture(candidate)
139+
)
140+
}
141+
}
142+
143+
private module LinqMapOpportunity<linqCandidateSig/2 linqCandidate> {
144+
predicate missed(ForEachStmt fes, Stmt s) {
145+
s = firstStmt(fes) and
146+
// The linq candidate (and only the candidate) expression accesses the loop variable and the
147+
// candidate doesn't access an in, out, or ref parameter.
148+
exists(Expr candidate | linqCandidate(s, candidate) |
149+
forex(VariableAccess va | va = fes.getVariable().getAnAccess() |
150+
va = candidate.getAChildExpr*()
151+
) and
152+
acceptableForLinqCapture(candidate)
153+
)
154+
}
155+
}
156+
157+
private predicate linqAllCandidate(Stmt s, Expr e) {
158+
s =
159+
any(IfStmt is |
160+
e = is.getCondition() and
161+
not exists(is.getElse()) and // The then case of the if assigns false to something and breaks out of the loop.
162+
exists(Assignment a, BoolLiteral bl |
163+
a = is.getThen().getAChild*() and
164+
bl = a.getRightOperand() and
165+
bl.toString() = "false"
166+
) and
167+
is.getThen().getAChild*() instanceof BreakStmt
168+
)
169+
}
170+
119171
/**
120172
* Holds if `foreach` statement `fes` could be converted to a `.All()` call.
121173
* That is, the `ForEachStmt` contains a single `if` with a condition that
122174
* accesses the loop variable and with a body that assigns `false` to a variable
123175
* and `break`s out of the `foreach`.
124176
*/
125177
predicate missedAllOpportunity(ForEachStmtGenericEnumerable fes) {
126-
exists(IfStmt is |
127-
// The loop contains an if statement with no else case, and nothing else.
128-
is = firstStmt(fes) and
129-
numStmts(fes) = 1 and
130-
not exists(is.getElse()) and
131-
// The if statement accesses the loop variable.
132-
is.getCondition().getAChildExpr*() = fes.getVariable().getAnAccess() and
133-
// The then case of the if assigns false to something and breaks out of the loop.
134-
exists(Assignment a, BoolLiteral bl |
135-
a = is.getThen().getAChild*() and
136-
bl = a.getRightOperand() and
137-
bl.toString() = "false"
138-
) and
139-
is.getThen().getAChild*() instanceof BreakStmt
140-
)
178+
// The loop contains an if statement with no else case, and nothing else.
179+
LinqFilterOpportunity<linqAllCandidate/2>::missed(fes, _) and
180+
numStmts(fes) = 1
181+
}
182+
183+
private predicate linqCastCandidate(Stmt s, Expr e) {
184+
s =
185+
any(LocalVariableDeclStmt lvds |
186+
exists(CastExpr ce |
187+
ce = lvds.getAVariableDeclExpr().getInitializer() and
188+
e = ce.getExpr() and
189+
e instanceof LocalVariableAccess
190+
)
191+
)
141192
}
142193

143194
/**
@@ -147,14 +198,18 @@ predicate missedAllOpportunity(ForEachStmtGenericEnumerable fes) {
147198
* local variable declaration statement `s`.
148199
*/
149200
predicate missedCastOpportunity(ForEachStmtEnumerable fes, LocalVariableDeclStmt s) {
150-
s = firstStmt(fes) and
151-
forex(VariableAccess va | va = fes.getVariable().getAnAccess() |
152-
va = s.getAVariableDeclExpr().getAChildExpr*()
153-
) and
154-
exists(CastExpr ce |
155-
ce = s.getAVariableDeclExpr().getInitializer() and
156-
ce.getExpr() = fes.getVariable().getAnAccess()
157-
)
201+
LinqMapOpportunity<linqCastCandidate/2>::missed(fes, s)
202+
}
203+
204+
private predicate linqOfTypeCandidate(Stmt s, Expr e) {
205+
s =
206+
any(LocalVariableDeclStmt lvds |
207+
exists(AsExpr ae |
208+
ae = lvds.getAVariableDeclExpr().getInitializer() and
209+
e = ae.getExpr() and
210+
e instanceof LocalVariableAccess
211+
)
212+
)
158213
}
159214

160215
/**
@@ -164,14 +219,16 @@ predicate missedCastOpportunity(ForEachStmtEnumerable fes, LocalVariableDeclStmt
164219
* is a local variable declaration statement `s`.
165220
*/
166221
predicate missedOfTypeOpportunity(ForEachStmtEnumerable fes, LocalVariableDeclStmt s) {
167-
s = firstStmt(fes) and
168-
forex(VariableAccess va | va = fes.getVariable().getAnAccess() |
169-
va = s.getAVariableDeclExpr().getAChildExpr*()
170-
) and
171-
exists(AsExpr ae |
172-
ae = s.getAVariableDeclExpr().getInitializer() and
173-
ae.getExpr() = fes.getVariable().getAnAccess()
174-
)
222+
LinqMapOpportunity<linqOfTypeCandidate/2>::missed(fes, s)
223+
}
224+
225+
private predicate linqSelectCandidate(Stmt s, Expr e) {
226+
s =
227+
any(LocalVariableDeclStmt lvds |
228+
e = lvds.getAVariableDeclExpr().getInitializer() and
229+
not e instanceof Cast and
230+
not e.getAChildExpr*() instanceof AwaitExpr
231+
)
175232
}
176233

177234
/**
@@ -182,12 +239,24 @@ predicate missedOfTypeOpportunity(ForEachStmtEnumerable fes, LocalVariableDeclSt
182239
* contain an `await` expression (since `Select` does not support async lambdas).
183240
*/
184241
predicate missedSelectOpportunity(ForEachStmtGenericEnumerable fes, LocalVariableDeclStmt s) {
185-
s = firstStmt(fes) and
186-
forex(VariableAccess va | va = fes.getVariable().getAnAccess() |
187-
va = s.getAVariableDeclExpr().getAChildExpr*()
188-
) and
189-
not s.getAVariableDeclExpr().getInitializer() instanceof Cast and
190-
not s.getAVariableDeclExpr().getInitializer().getAChildExpr*() instanceof AwaitExpr
242+
LinqMapOpportunity<linqSelectCandidate/2>::missed(fes, s)
243+
}
244+
245+
private predicate linqWhereCandidateCase1(Stmt s, Expr e) {
246+
s =
247+
any(IfStmt is |
248+
e = is.getCondition() and
249+
is.getThen() instanceof ContinueStmt
250+
)
251+
}
252+
253+
private predicate linqWhereCandidateCase2(Stmt s, Expr e) {
254+
s =
255+
any(IfStmt is |
256+
e = is.getCondition() and
257+
not exists(is.getElse()) and
258+
not terminatesCallable(is.getThen())
259+
)
191260
}
192261

193262
/**
@@ -197,20 +266,21 @@ predicate missedSelectOpportunity(ForEachStmtGenericEnumerable fes, LocalVariabl
197266
* else in the loop than the `if`.
198267
*/
199268
predicate missedWhereOpportunity(ForEachStmtGenericEnumerable fes, IfStmt is) {
200-
// The very first thing the foreach loop does is test its iteration variable.
201-
is = firstStmt(fes) and
202-
exists(VariableAccess va |
203-
va.getTarget() = fes.getVariable() and
204-
va = is.getCondition().getAChildExpr*()
205-
) and
206-
// It then either (a) continues, or (b) performs the entire body of the loop within the condition.
207-
(
208-
is.getThen() instanceof ContinueStmt
209-
or
210-
not exists(is.getElse()) and
211-
numStmts(fes) = 1 and
212-
not terminatesCallable(is.getThen())
213-
)
269+
// The body of the `if` is a continue.
270+
LinqFilterOpportunity<linqWhereCandidateCase1/2>::missed(fes, is)
271+
or
272+
// There's nothing else in the loop than the `if`.
273+
LinqFilterOpportunity<linqWhereCandidateCase2/2>::missed(fes, is) and
274+
numStmts(fes) = 1
275+
}
276+
277+
private predicate linqFirstOrDefaultCandidate(Stmt s, Expr e) {
278+
s =
279+
any(IfStmt is |
280+
e = is.getCondition() and
281+
not exists(is.getElse()) and
282+
not e.getAChildExpr*() instanceof AwaitExpr
283+
)
214284
}
215285

216286
/**
@@ -220,15 +290,8 @@ predicate missedWhereOpportunity(ForEachStmtGenericEnumerable fes, IfStmt is) {
220290
*/
221291
predicate missedFirstOrDefaultOpportunity(ForEachStmtGenericEnumerable fes, IfStmt is) {
222292
// The loop only checks whether the current element is the first match.
223-
is = firstStmt(fes) and
224-
not exists(is.getElse()) and
293+
LinqFilterOpportunity<linqFirstOrDefaultCandidate/2>::missed(fes, is) and
225294
numStmts(fes) = 1 and
226-
// Condition relies on loop variable.
227-
exists(VariableAccess va |
228-
va.getTarget() = fes.getVariable() and
229-
va = is.getCondition().getAChildExpr*()
230-
) and
231-
not is.getCondition().getAChildExpr*() instanceof AwaitExpr and
232295
not fes.isAsync() and
233296
not fes.getVariable().isCaptured() and
234297
returnsLoopVariable(fes, is.getThen()) and
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* The `cs/linq/missed-*` queries no longer suggest rewrites that would capture `in`, `out`, or `ref` parameters in a lambda, fixing false-positive results for transformations that would not compile.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System;
2+
using System.Linq;
3+
using System.Collections.Generic;
4+
5+
class MissedAllOpportunity
6+
{
7+
public void M1(List<int> lst)
8+
{
9+
// BAD: Can be replaced with lst.All(e => e % 2 == 0)
10+
var allEven = true;
11+
foreach (int i in lst)
12+
{
13+
if (i % 2 != 0)
14+
{
15+
allEven = false;
16+
break;
17+
}
18+
} // $ Alert
19+
}
20+
21+
public void M2(NonEnumerableClass nec)
22+
{
23+
// GOOD: Linq can't be used here.
24+
var allEven = true;
25+
foreach (int i in nec)
26+
{
27+
if (i % 2 != 0)
28+
{
29+
allEven = false;
30+
break;
31+
}
32+
}
33+
}
34+
35+
public void M3(List<int> lst, ref int x)
36+
{
37+
// GOOD: Linq can't be used here because the condition uses a ref parameter.
38+
var allEven = true;
39+
foreach (int i in lst)
40+
{
41+
if (i % 2 != x)
42+
{
43+
allEven = false;
44+
break;
45+
}
46+
}
47+
}
48+
49+
public class NonEnumerableClass
50+
{
51+
public IEnumerator<int> GetEnumerator() => throw null;
52+
}
53+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
| MissedAllOpportunity.cs:11:9:18:9 | foreach (... ... in ...) ... | This foreach loop looks as if it might be testing whether every sequence element satisfies a predicate - consider using '.All(...)'. |
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
query: Linq/MissedAllOpportunity.ql
2+
postprocess: utils/test/InlineExpectationsTestQuery.ql
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
semmle-extractor-options: /nostdlib /noconfig
2+
semmle-extractor-options: --load-sources-from-project:${testdir}/../../../resources/stubs/_frameworks/Microsoft.NETCore.App/Microsoft.NETCore.App.csproj

csharp/ql/test/query-tests/Linq/MissedFirstOrDefaultOpportunity/MissedFirstOrDefaultOpportunity.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ public Operation M14(IEnumerable<Operation> operations, Func<string, bool>[] pre
179179
return null;
180180
}
181181

182+
public int M15(IEnumerable<int> values, ref readonly int x)
183+
{
184+
// GOOD: FirstOrDefault does not support a predicate that captures a ref parameter.
185+
foreach (var value in values)
186+
{
187+
if (value > x)
188+
{
189+
return value;
190+
}
191+
}
192+
193+
return default;
194+
}
195+
182196
private static Task<bool> IsMatch(Operation operation, string operationId) =>
183197
Task.FromResult(string.Equals(operation.OperationId, operationId, StringComparison.Ordinal));
184198
}

csharp/ql/test/query-tests/Linq/MissedSelectOpportunity/MissedSelectOpportunity.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ public async Task M2(IEnumerable<ICounter> counters)
2525
}
2626
}
2727

28+
public void M3(List<int> lst, out int x)
29+
{
30+
// GOOD: Linq can't be used here as the Select would capture an out parameter.
31+
x = 2;
32+
foreach (int i in lst)
33+
{
34+
int j = i * x;
35+
Console.WriteLine(j);
36+
}
37+
}
38+
2839
public interface ICounter
2940
{
3041
Task<int> CountAsync();

csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,18 @@ public void M12(IEnumerable<int> elements)
174174
}
175175
}
176176

177+
public void M13(List<int> lst, in int x)
178+
{
179+
// GOOD: Linq can't be used here because the condition uses an in parameter.
180+
foreach (int i in lst)
181+
{
182+
if (i % 2 != x)
183+
continue;
184+
Console.WriteLine(i);
185+
Console.WriteLine((i / 2));
186+
}
187+
}
188+
177189
public class NonEnumerableClass
178190
{
179191
public IEnumerator<int> GetEnumerator() => throw null;

0 commit comments

Comments
 (0)