Skip to content

Commit 6fb1a2c

Browse files
sonar-nigel[bot]sonartech
authored andcommitted
SONARPY-3971 Rule S8518 "enumerate()" values should be unpacked instead of using index lookups (#1010)
GitOrigin-RevId: 00eba0c1ece4f51bda64e9a76c74a5445ffe476f
1 parent e317068 commit 6fb1a2c

6 files changed

Lines changed: 352 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* You can redistribute and/or modify this program under the terms of
7+
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import org.sonar.check.Rule;
20+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
21+
import org.sonar.plugins.python.api.SubscriptionContext;
22+
import org.sonar.plugins.python.api.symbols.v2.SymbolV2;
23+
import org.sonar.plugins.python.api.tree.AnnotatedAssignment;
24+
import org.sonar.plugins.python.api.tree.AssignmentStatement;
25+
import org.sonar.plugins.python.api.tree.CallExpression;
26+
import org.sonar.plugins.python.api.tree.CompoundAssignmentStatement;
27+
import org.sonar.plugins.python.api.tree.DelStatement;
28+
import org.sonar.plugins.python.api.tree.ExpressionList;
29+
import org.sonar.plugins.python.api.tree.ForStatement;
30+
import org.sonar.plugins.python.api.tree.Name;
31+
import org.sonar.plugins.python.api.tree.NumericLiteral;
32+
import org.sonar.plugins.python.api.tree.RegularArgument;
33+
import org.sonar.plugins.python.api.tree.SubscriptionExpression;
34+
import org.sonar.plugins.python.api.tree.Tree;
35+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
36+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
37+
import org.sonar.python.tree.TreeUtils;
38+
39+
@Rule(key = "S8518")
40+
public class EnumerateUnpackingCheck extends PythonSubscriptionCheck {
41+
42+
private static final String MESSAGE = "Unpack the value from 'enumerate()' directly instead of using an index lookup.";
43+
private static final TypeMatcher ENUMERATE_MATCHER = TypeMatchers.isType("enumerate");
44+
45+
@Override
46+
public void initialize(Context context) {
47+
context.registerSyntaxNodeConsumer(Tree.Kind.FOR_STMT, EnumerateUnpackingCheck::check);
48+
}
49+
50+
private static void check(SubscriptionContext ctx) {
51+
ForStatement forStatement = (ForStatement) ctx.syntaxNode();
52+
53+
if (forStatement.testExpressions().size() != 1) {
54+
return;
55+
}
56+
if (!(forStatement.testExpressions().get(0) instanceof CallExpression call)) {
57+
return;
58+
}
59+
if (!ENUMERATE_MATCHER.isTrueFor(call.callee(), ctx)) {
60+
return;
61+
}
62+
if (forStatement.expressions().size() != 2) {
63+
return;
64+
}
65+
if (!(forStatement.expressions().get(0) instanceof Name indexName)) {
66+
return;
67+
}
68+
SymbolV2 indexSymbol = indexName.symbolV2();
69+
if (indexSymbol == null) {
70+
return;
71+
}
72+
73+
RegularArgument startArg = TreeUtils.nthArgumentOrKeyword(1, "start", call.arguments());
74+
if (startArg != null && !(startArg.expression() instanceof NumericLiteral num && "0".equals(num.valueAsString()))) {
75+
return;
76+
}
77+
78+
RegularArgument iterableArg = TreeUtils.nthArgumentOrKeyword(0, "iterable", call.arguments());
79+
if (iterableArg == null || !(iterableArg.expression() instanceof Name iterableName)) {
80+
return;
81+
}
82+
SymbolV2 iterableSymbol = iterableName.symbolV2();
83+
if (iterableSymbol == null) {
84+
return;
85+
}
86+
87+
boolean hasSubscriptWriteTarget = TreeUtils.hasDescendant(forStatement.body(),
88+
node -> isMatchingSubscript(node, indexSymbol, iterableSymbol)
89+
&& isSubscriptWriteTarget((SubscriptionExpression) node));
90+
91+
if (hasSubscriptWriteTarget) {
92+
return;
93+
}
94+
95+
boolean hasRedundantSubscript = TreeUtils.hasDescendant(forStatement.body(),
96+
node -> isMatchingSubscript(node, indexSymbol, iterableSymbol));
97+
98+
if (hasRedundantSubscript) {
99+
ctx.addIssue(call, MESSAGE);
100+
}
101+
}
102+
103+
private static boolean isMatchingSubscript(Tree node, SymbolV2 indexSymbol, SymbolV2 iterableSymbol) {
104+
if (!(node instanceof SubscriptionExpression subscription)) {
105+
return false;
106+
}
107+
if (subscription.subscripts().expressions().size() != 1) {
108+
return false;
109+
}
110+
if (!(subscription.subscripts().expressions().get(0) instanceof Name subscriptName)) {
111+
return false;
112+
}
113+
if (!indexSymbol.equals(subscriptName.symbolV2())) {
114+
return false;
115+
}
116+
if (!(subscription.object() instanceof Name objectName)) {
117+
return false;
118+
}
119+
return iterableSymbol.equals(objectName.symbolV2());
120+
}
121+
122+
private static boolean isSubscriptWriteTarget(SubscriptionExpression subscription) {
123+
Tree parent = subscription.parent();
124+
if (parent instanceof DelStatement) {
125+
return true;
126+
}
127+
if (parent instanceof CompoundAssignmentStatement compound) {
128+
return compound.lhsExpression() == subscription;
129+
}
130+
if (parent instanceof AnnotatedAssignment annotated) {
131+
return annotated.variable() == subscription;
132+
}
133+
return parent instanceof ExpressionList exprList
134+
&& exprList.parent() instanceof AssignmentStatement;
135+
}
136+
}

python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ public Stream<Class<?>> getChecks() {
217217
EmptyFunctionCheck.class,
218218
EmptyNestedBlockCheck.class,
219219
EmptyStringRepetitionCheck.class,
220+
EnumerateUnpackingCheck.class,
220221
ExceptionGroupCheck.class,
221222
ExceptionCauseTypeCheck.class,
222223
ExceptionNotThrownCheck.class,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<p>This rule raises an issue when a loop iterates over <code>enumerate()</code> but accesses list elements by index instead of unpacking the value directly from the enumeration.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>The <code>enumerate()</code> function provides both the index and the value of each element when iterating over a sequence. When you only unpack the index and then use it to subscript the original list, the value is already available from <code>enumerate()</code> and the subscript operation is redundant.</p>
4+
<p>This pattern results in unnecessary operations and reduced readability.</p>
5+
<h3>Code examples</h3>
6+
<h4>Noncompliant code example</h4>
7+
<pre data-diff-id="1" data-diff-type="noncompliant">
8+
fruits = ['apple', 'banana', 'cherry']
9+
10+
for i, _ in enumerate(fruits):
11+
print(f"Index {i}: {fruits[i]}") # Noncompliant
12+
</pre>
13+
<h4>Compliant solution</h4>
14+
<pre data-diff-id="1" data-diff-type="compliant">
15+
fruits = ['apple', 'banana', 'cherry']
16+
17+
for i, fruit in enumerate(fruits):
18+
print(f"Index {i}: {fruit}")
19+
</pre>
20+
<h2>Resources</h2>
21+
<h3>Documentation</h3>
22+
<ul>
23+
<li>Python Documentation - <a href="https://docs.python.org/3/library/functions.html#enumerate">enumerate()</a></li>
24+
</ul>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"title": "\"enumerate()\" values should be unpacked instead of using index lookups",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "2min"
8+
},
9+
"tags": [],
10+
"defaultSeverity": "Minor",
11+
"ruleSpecification": "RSPEC-8518",
12+
"sqKey": "S8518",
13+
"scope": "All",
14+
"quickfix": "unknown",
15+
"code": {
16+
"impacts": {
17+
"MAINTAINABILITY": "LOW"
18+
},
19+
"attribute": "CONVENTIONAL"
20+
}
21+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* You can redistribute and/or modify this program under the terms of
7+
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class EnumerateUnpackingCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/enumerateUnpacking.py", new EnumerateUnpackingCheck());
27+
}
28+
29+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
2+
def noncompliant_basic():
3+
lst = ['apple', 'banana', 'cherry']
4+
for i, _ in enumerate(lst): # Noncompliant {{Unpack the value from 'enumerate()' directly instead of using an index lookup.}}
5+
# ^^^^^^^^^^^^^^
6+
print(lst[i])
7+
8+
9+
def noncompliant_start_zero_keyword():
10+
colors = ['red', 'green', 'blue']
11+
for i, _ in enumerate(colors, start=0): # Noncompliant
12+
print(colors[i])
13+
14+
15+
def noncompliant_rhs_of_augmented_assignment():
16+
items = "abcdef"
17+
result = ""
18+
for ind, _ in enumerate(items): # Noncompliant
19+
result += items[ind]
20+
21+
22+
def compliant_proper_unpacking():
23+
fruits = ['apple', 'banana', 'cherry']
24+
for i, fruit in enumerate(fruits):
25+
print(f"Index {i}: {fruit}")
26+
27+
28+
def compliant_multiple_iterables():
29+
lst = [1, 2, 3]
30+
for i in lst, lst:
31+
print(i)
32+
33+
34+
def compliant_plain_iterable_not_call():
35+
pairs = [(1, 'a'), (2, 'b'), (3, 'c')]
36+
for i, v in pairs:
37+
print(i, v)
38+
39+
40+
def compliant_non_enumerate_call():
41+
items = [1, 2, 3]
42+
others = [4, 5, 6]
43+
for i, v in zip(items, others):
44+
print(items[i])
45+
46+
47+
def compliant_single_loop_variable():
48+
items = ['a', 'b', 'c']
49+
for item in enumerate(items):
50+
print(item)
51+
52+
53+
def compliant_tuple_unpacking_first_var():
54+
pairs = [(1, 'a'), (2, 'b')]
55+
for (a, b), value in enumerate(pairs):
56+
print(a, b, value)
57+
58+
59+
def compliant_nonzero_start():
60+
items = ['a', 'b', 'c']
61+
for i, _ in enumerate(items, start=1):
62+
print(items[i - 1])
63+
64+
65+
def compliant_variable_start():
66+
items = ['a', 'b', 'c']
67+
n = 0
68+
for i, _ in enumerate(items, n):
69+
print(items[i])
70+
71+
72+
def compliant_no_arguments():
73+
for i, _ in enumerate():
74+
print(i)
75+
76+
77+
def compliant_inline_literal_iterable():
78+
for i, _ in enumerate([1, 2, 3]):
79+
pass
80+
81+
82+
def compliant_undeclared_iterable():
83+
for i, _ in enumerate(undeclared_var):
84+
print(undeclared_var[i])
85+
86+
87+
def compliant_write_assignment():
88+
items = ['a', 'b', 'c']
89+
for i, item in enumerate(items):
90+
items[i] = item.upper()
91+
92+
93+
def compliant_write_augmented_assignment():
94+
counts = [0, 0, 0]
95+
for i, val in enumerate(counts):
96+
counts[i] += 1
97+
98+
99+
def compliant_write_del():
100+
items = ['a', 'b', 'c']
101+
for i, item in enumerate(items):
102+
if item == 'b':
103+
del items[i]
104+
break
105+
106+
107+
def compliant_different_list():
108+
keys = ['x', 'y', 'z']
109+
values = [1, 2, 3]
110+
for i, key in enumerate(keys):
111+
print(values[i])
112+
113+
114+
def compliant_different_index_var():
115+
lst = ['a', 'b', 'c']
116+
j = 0
117+
for i, _ in enumerate(lst):
118+
print(lst[j])
119+
120+
121+
def compliant_modified_index():
122+
items = ['a', 'b', 'c']
123+
for i, _ in enumerate(items):
124+
if i + 1 < len(items):
125+
print(items[i + 1])
126+
127+
128+
def compliant_multi_index_subscript():
129+
import numpy as np
130+
matrix = np.array([[1, 2], [3, 4]])
131+
items = [10, 20]
132+
for i, _ in enumerate(items):
133+
print(matrix[i, 0])
134+
135+
136+
def compliant_subscript_object_not_name():
137+
def get_list():
138+
return ['x', 'y', 'z']
139+
lst = ['a', 'b', 'c']
140+
for i, _ in enumerate(lst):
141+
print(get_list()[i])

0 commit comments

Comments
 (0)