Skip to content

Commit 1a25443

Browse files
sonar-nigel[bot]marc-jasper-sonarsource
authored andcommitted
SONARPY-3975 Rule S8519 "list(...)[0]" should not be used to get the first element (#1011)
Co-authored-by: Marc Jasper <marc.jasper@sonarsource.com> GitOrigin-RevId: 8e7c0b37c56887d7ed66e8693af4504223faa0de
1 parent 11925f4 commit 1a25443

File tree

7 files changed

+301
-0
lines changed

7 files changed

+301
-0
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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.quickfix.PythonQuickFix;
23+
import org.sonar.plugins.python.api.tree.CallExpression;
24+
import org.sonar.plugins.python.api.tree.NumericLiteral;
25+
import org.sonar.plugins.python.api.tree.RegularArgument;
26+
import org.sonar.plugins.python.api.tree.SubscriptionExpression;
27+
import org.sonar.plugins.python.api.tree.Tree;
28+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
29+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
30+
import org.sonar.python.quickfix.TextEditUtils;
31+
import org.sonar.python.tree.NumericLiteralImpl;
32+
import org.sonar.python.tree.TreeUtils;
33+
34+
@Rule(key = "S8519")
35+
public class ListIterableFirstElementCheck extends PythonSubscriptionCheck {
36+
37+
private static final String MESSAGE = "Replace \"list(...)[0]\" with \"next(iter(...))\" to avoid materializing the entire iterable.";
38+
private static final TypeMatcher IS_LIST = TypeMatchers.isType("list");
39+
40+
@Override
41+
public void initialize(Context context) {
42+
context.registerSyntaxNodeConsumer(Tree.Kind.SUBSCRIPTION, ListIterableFirstElementCheck::checkSubscription);
43+
}
44+
45+
private static void checkSubscription(SubscriptionContext ctx) {
46+
SubscriptionExpression subscriptionExpression = (SubscriptionExpression) ctx.syntaxNode();
47+
48+
var subscripts = subscriptionExpression.subscripts();
49+
if (!subscripts.commas().isEmpty() || subscripts.expressions().size() != 1) {
50+
return;
51+
}
52+
53+
var subscript = subscripts.expressions().get(0);
54+
if (!(subscript instanceof NumericLiteral numericLiteral)) {
55+
return;
56+
}
57+
if (!isIntegerLiteralSubscript(numericLiteral)) {
58+
return;
59+
}
60+
long indexValue;
61+
try {
62+
indexValue = numericLiteral.valueAsLong();
63+
} catch (NumberFormatException e) {
64+
return;
65+
}
66+
if (indexValue != 0L) {
67+
return;
68+
}
69+
70+
if (!(subscriptionExpression.object() instanceof CallExpression listCall)) {
71+
return;
72+
}
73+
74+
if (!IS_LIST.isTrueFor(listCall.callee(), ctx)) {
75+
return;
76+
}
77+
78+
if (listCall.arguments().size() != 1 || !(listCall.arguments().get(0) instanceof RegularArgument regularArg)) {
79+
return;
80+
}
81+
82+
PreciseIssue issue = ctx.addIssue(listCall.callee(), MESSAGE);
83+
84+
String argText = TreeUtils.treeToString(regularArg.expression(), false);
85+
if (argText != null && !argText.contains("\n")) {
86+
PythonQuickFix quickFix = PythonQuickFix.newQuickFix("Replace with \"next(iter(...))\"",
87+
TextEditUtils.replace(subscriptionExpression, "next(iter(" + argText + "))"));
88+
issue.addQuickFix(quickFix);
89+
}
90+
}
91+
92+
private static boolean isIntegerLiteralSubscript(NumericLiteral literal) {
93+
if (literal instanceof NumericLiteralImpl impl) {
94+
return impl.numericKind() == NumericLiteralImpl.NumericKind.INT;
95+
}
96+
return false;
97+
}
98+
}

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
@@ -306,6 +306,7 @@ public Stream<Class<?>> getChecks() {
306306
LambdaAssignmentCheck.class,
307307
LdapAuthenticationCheck.class,
308308
LineLengthCheck.class,
309+
ListIterableFirstElementCheck.class,
309310
LocalVariableAndParameterNameConventionCheck.class,
310311
LoggersConfigurationCheck.class,
311312
LongIntegerWithLowercaseSuffixUsageCheck.class,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<p>This rule raises an issue when code uses <code>list(iterable)[0]</code> to retrieve the first element of an iterable.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Using <code>list(iterable)[0]</code> to get the first element of an iterable is inefficient because it materializes the entire iterable into a list
4+
in memory before accessing the first element.</p>
5+
<p>This approach has two main problems:</p>
6+
<ul>
7+
<li><strong>Memory overhead</strong>: The entire iterable is converted to a list, consuming memory proportional to its size. For large iterables or
8+
generators, this can waste significant memory.</li>
9+
<li><strong>Time complexity</strong>: Creating the list is an O(n) operation, where n is the number of elements in the iterable. You pay the cost of
10+
iterating through all elements even though you only need the first one.</li>
11+
</ul>
12+
<p>The pattern is particularly problematic with:</p>
13+
<ul>
14+
<li>large datasets or database query results</li>
15+
<li>generator expressions and generator functions</li>
16+
<li>file readers or network streams</li>
17+
<li>infinite iterables, which will cause the program to hang or crash</li>
18+
</ul>
19+
<h3>What is the potential impact?</h3>
20+
<ul>
21+
<li>applications may become unresponsive or crash due to out-of-memory errors when processing large datasets</li>
22+
<li>execution time increases unnecessarily, degrading user experience in performance-critical paths</li>
23+
<li>infinite iterables cannot be consumed at all, causing the program to hang</li>
24+
<li>infrastructure costs grow as data size increases due to wasted memory and CPU cycles</li>
25+
</ul>
26+
<h2>How to fix it</h2>
27+
<p>Replace <code>list(iterable)[0]</code> with <code>next(iter(iterable))</code> to retrieve only the first element without materializing the entire
28+
iterable into a list. The <code>iter()</code> function creates an iterator, and <code>next()</code> retrieves the next item from that iterator, in
29+
O(1) time and without allocating memory for the entire collection.</p>
30+
<p>Note that the two expressions differ in the exception raised when the iterable is empty: <code>list(iterable)[0]</code> raises
31+
<code>IndexError</code>, while <code>next(iter(iterable))</code> raises <code>StopIteration</code>. If the surrounding code catches
32+
<code>IndexError</code>, update the handler after applying this fix.</p>
33+
<h3>Code examples</h3>
34+
<h4>Noncompliant code example</h4>
35+
<pre data-diff-id="1" data-diff-type="noncompliant">
36+
def get_first_user(users):
37+
return list(users)[0] # Noncompliant
38+
</pre>
39+
<h4>Compliant solution</h4>
40+
<pre data-diff-id="1" data-diff-type="compliant">
41+
def get_first_user(users):
42+
return next(iter(users))
43+
</pre>
44+
<h2>Resources</h2>
45+
<h3>Documentation</h3>
46+
<ul>
47+
<li>Python Documentation - <a href="https://docs.python.org/3/library/functions.html#next"><code>next()</code> built-in function</a></li>
48+
<li>Python Documentation - <a href="https://docs.python.org/3/library/functions.html#iter"><code>iter()</code> built-in function</a></li>
49+
<li>Python Documentation - <a href="https://docs.python.org/3/glossary.html#term-iterator">Iterator</a></li>
50+
</ul>
51+
<h3>External coding guidelines</h3>
52+
<ul>
53+
<li>Ruff - <a href="https://docs.astral.sh/ruff/rules/unnecessary-iterable-allocation-for-first-element/">RUF015 - Unnecessary iterable allocation
54+
for first element</a></li>
55+
</ul>
56+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "\"list(...)[0]\" should not be used to get the first element",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"performance",
11+
"pythonic"
12+
],
13+
"defaultSeverity": "Major",
14+
"ruleSpecification": "RSPEC-8519",
15+
"sqKey": "S8519",
16+
"scope": "All",
17+
"quickfix": "targeted",
18+
"code": {
19+
"impacts": {
20+
"RELIABILITY": "MEDIUM"
21+
},
22+
"attribute": "EFFICIENT"
23+
}
24+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@
334334
"S8504",
335335
"S8507",
336336
"S8517",
337+
"S8519",
337338
"S8520",
338339
"S8521",
339340
"S8493"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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 java.util.stream.Stream;
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.params.ParameterizedTest;
22+
import org.junit.jupiter.params.provider.Arguments;
23+
import org.junit.jupiter.params.provider.MethodSource;
24+
import org.sonar.python.checks.quickfix.PythonQuickFixVerifier;
25+
import org.sonar.python.checks.utils.PythonCheckVerifier;
26+
27+
class ListIterableFirstElementCheckTest {
28+
29+
private static final ListIterableFirstElementCheck check = new ListIterableFirstElementCheck();
30+
31+
@Test
32+
void test() {
33+
PythonCheckVerifier.verify("src/test/resources/checks/listIterableFirstElement.py", check);
34+
}
35+
36+
static Stream<Arguments> quickFixTestCases() {
37+
return Stream.of(
38+
Arguments.of("list(get_users())[0]", "next(iter(get_users()))"),
39+
Arguments.of("list(users)[0]", "next(iter(users))"),
40+
Arguments.of("list(x for x in range(10))[0]", "next(iter(x for x in range(10)))"),
41+
Arguments.of("list(filter(None, items))[0]", "next(iter(filter(None, items)))")
42+
);
43+
}
44+
45+
@ParameterizedTest
46+
@MethodSource("quickFixTestCases")
47+
void test_quick_fix(String codeWithIssue, String correctCode) {
48+
PythonQuickFixVerifier.verify(check, codeWithIssue, correctCode);
49+
}
50+
51+
@Test
52+
void test_quick_fix_message() {
53+
String codeWithIssue = "list(get_users())[0]";
54+
55+
PythonQuickFixVerifier.verifyQuickFixMessages(check, codeWithIssue, "Replace with \"next(iter(...))\"");
56+
}
57+
58+
@Test
59+
void test_no_quick_fix_multiline_argument_expression() {
60+
String codeWithIssue = """
61+
list(
62+
get_items(
63+
arg
64+
)
65+
)[0]
66+
""";
67+
68+
PythonQuickFixVerifier.verifyNoQuickFixes(check, codeWithIssue);
69+
}
70+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
2+
def noncompliant_materialize_first(users):
3+
return list(users)[0] # Noncompliant {{Replace "list(...)[0]" with "next(iter(...))" to avoid materializing the entire iterable.}}
4+
# ^^^^
5+
6+
7+
def compliant_next_iter(users):
8+
return next(iter(users))
9+
10+
def compliant_nonzero_index(items):
11+
return list(items)[1]
12+
13+
def compliant_float_subscript(items):
14+
return list(items)[0.0]
15+
16+
def compliant_float_scientific_zero(items):
17+
return list(items)[0e0]
18+
19+
def compliant_complex_subscript(items):
20+
return list(items)[0j]
21+
22+
def compliant_negative_index(items):
23+
return list(items)[-1]
24+
25+
def compliant_slice(items):
26+
return list(items)[0:1]
27+
28+
def compliant_tuple_subscript(items):
29+
return list(items)[0, 1]
30+
31+
def compliant_starred(args):
32+
return list(*args)[0]
33+
34+
def compliant_multiple_args():
35+
return list([], [])[0]
36+
37+
def compliant_direct_index(items):
38+
return items[0]
39+
40+
def compliant_two_step_list(items):
41+
all_items = list(items)
42+
return all_items[0] # out of scope: [0] applies to a Name, not to list(...) call
43+
44+
def compliant_shadowed_list(items):
45+
list = lambda x: x
46+
return list(items)[0]
47+
48+
49+
def fn_subscript_not_literal_zero(items):
50+
zero = 0
51+
return list(items)[zero] # FN: subscript must be numeric literal 0

0 commit comments

Comments
 (0)