Skip to content

Commit 98fd5c3

Browse files
sonar-nigel[bot]Sonar Vibe Botclaudejoke1196guillaume-dequenne
authored andcommitted
SONARPY-3928 Rule S8517 "sorted()" should not be used with indexing to find minimum or maximum values (#969)
Co-authored-by: Sonar Vibe Bot <vibe-bot@sonarsource.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: joke1196 <david.kunzmann@sonarsource.com> Co-authored-by: Guillaume Dequenne <guillaume.dequenne@sonarsource.com> GitOrigin-RevId: 4a75f134de001187116ffc77e881edb6f8c2ccdc
1 parent af01a05 commit 98fd5c3

File tree

7 files changed

+373
-1
lines changed

7 files changed

+373
-1
lines changed

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
@@ -398,6 +398,7 @@ public Stream<Class<?>> getChecks() {
398398
SkippedTestNoReasonCheck.class,
399399
SkLearnEstimatorDontInitializeEstimatedValuesCheck.class,
400400
SleepZeroInAsyncCheck.class,
401+
SortedMinMaxCheck.class,
401402
SpecialMethodParamListCheck.class,
402403
SpecialMethodReturnTypeCheck.class,
403404
SQLQueriesCheck.class,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
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 javax.annotation.Nullable;
20+
import org.sonar.check.Rule;
21+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
22+
import org.sonar.plugins.python.api.SubscriptionContext;
23+
import org.sonar.plugins.python.api.tree.CallExpression;
24+
import org.sonar.plugins.python.api.tree.Expression;
25+
import org.sonar.plugins.python.api.tree.NumericLiteral;
26+
import org.sonar.plugins.python.api.tree.RegularArgument;
27+
import org.sonar.plugins.python.api.tree.SubscriptionExpression;
28+
import org.sonar.plugins.python.api.tree.Tree;
29+
import org.sonar.plugins.python.api.tree.UnaryExpression;
30+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
31+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
32+
import org.sonar.python.checks.hotspots.CommonValidationUtils;
33+
import org.sonar.python.checks.utils.Expressions;
34+
import org.sonar.python.tree.TreeUtils;
35+
36+
@Rule(key = "S8517")
37+
public class SortedMinMaxCheck extends PythonSubscriptionCheck {
38+
39+
private static final TypeMatcher IS_SORTED = TypeMatchers.isType("sorted");
40+
41+
@Override
42+
public void initialize(Context context) {
43+
context.registerSyntaxNodeConsumer(Tree.Kind.SUBSCRIPTION, SortedMinMaxCheck::check);
44+
}
45+
46+
private static void check(SubscriptionContext ctx) {
47+
SubscriptionExpression subscriptionExpression = (SubscriptionExpression) ctx.syntaxNode();
48+
49+
if (subscriptionExpression.subscripts().expressions().size() != 1) {
50+
return;
51+
}
52+
53+
Expression subscript = subscriptionExpression.subscripts().expressions().get(0);
54+
Boolean isFirstElement = isIndexZeroOrMinusOne(subscript);
55+
if (isFirstElement == null) {
56+
return;
57+
}
58+
59+
Expression object = subscriptionExpression.object();
60+
if (!(object instanceof CallExpression callExpression)) {
61+
return;
62+
}
63+
64+
if (!IS_SORTED.isTrueFor(callExpression.callee(), ctx)) {
65+
return;
66+
}
67+
68+
Boolean isReversed = isReversed(callExpression);
69+
if (isReversed == null) {
70+
// Cannot determine reverse truthiness, skip to avoid wrong suggestions
71+
return;
72+
}
73+
74+
String replacement = getReplacementFunction(isFirstElement, isReversed);
75+
ctx.addIssue(subscriptionExpression, "Use \"" + replacement + "()\" instead of sorting to find this value.");
76+
}
77+
78+
/**
79+
* Returns true if the expression is the integer literal 0 (first element),
80+
* false if it is -1 (last element), or null if it is neither.
81+
*/
82+
@Nullable
83+
private static Boolean isIndexZeroOrMinusOne(Expression subscript) {
84+
if (subscript instanceof NumericLiteral numericLiteral && CommonValidationUtils.isEqualTo(numericLiteral, 0)) {
85+
return true;
86+
}
87+
if (subscript instanceof UnaryExpression unaryExpression
88+
&& unaryExpression.is(Tree.Kind.UNARY_MINUS)
89+
&& unaryExpression.expression() instanceof NumericLiteral numericLiteral
90+
&& CommonValidationUtils.isEqualTo(numericLiteral, 1)) {
91+
return false;
92+
}
93+
return null;
94+
}
95+
96+
/**
97+
* Returns {@code Boolean.TRUE} if the reverse argument is truthy, {@code Boolean.FALSE} if it is absent or falsy,
98+
* or {@code null} if the reverse argument is present but its truthiness cannot be determined.
99+
*/
100+
@Nullable
101+
private static Boolean isReversed(CallExpression callExpression) {
102+
RegularArgument reverseArg = TreeUtils.argumentByKeyword("reverse", callExpression.arguments());
103+
if (reverseArg == null) {
104+
return Boolean.FALSE;
105+
}
106+
Expression value = reverseArg.expression();
107+
if (Expressions.isTruthy(value)) {
108+
return Boolean.TRUE;
109+
}
110+
if (Expressions.isFalsy(value)) {
111+
return Boolean.FALSE;
112+
}
113+
return null;
114+
}
115+
116+
private static String getReplacementFunction(boolean isFirstElement, boolean isReversed) {
117+
// sorted()[0] -> min (smallest first without reverse)
118+
// sorted()[-1] -> max (largest last without reverse)
119+
// sorted(reverse=True)[0] -> max (largest first with reverse)
120+
// sorted(reverse=True)[-1] -> min (smallest last with reverse)
121+
if (isFirstElement) {
122+
return isReversed ? "max" : "min";
123+
} else {
124+
return isReversed ? "min" : "max";
125+
}
126+
}
127+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<p>This rule raises an issue when <code>sorted()</code> with indexing (<code>[0]</code> or <code>[-1]</code>) is used to find the minimum or maximum
2+
value in a collection, including variants with <code>reverse=True</code>.</p>
3+
<h2>Why is this an issue?</h2>
4+
<p>When you need to find the smallest or largest element in a collection, you might be tempted to sort the entire collection and then access the first
5+
or last element. However, this approach is inefficient and makes your code harder to understand.</p>
6+
<p>The <code>sorted()</code> function creates a new sorted list from all elements, an O(n log n) operation in time, plus O(n) in memory. After
7+
sorting, you only use one element and discard the rest.</p>
8+
<p>Python provides built-in <code>min()</code> and <code>max()</code> functions that find the smallest or largest element in a single pass (O(n) time)
9+
with no extra memory allocation. They also make your intent immediately clear to anyone reading the code.</p>
10+
<p><code>min()</code>, <code>max()</code>, and <code>sorted()</code> all support an optional <code>key</code> parameter, so you can preserve custom
11+
comparison logic. Note that <code>min()</code> and <code>max()</code> do not have a <code>reverse</code> parameter, so when replacing
12+
<code>sorted(iterable, reverse=True)[0]</code>, you should use <code>max(iterable)</code> instead, and vice versa for <code>[-1]</code>.</p>
13+
<h3>What is the potential impact?</h3>
14+
<ul>
15+
<li><strong>Performance degradation</strong>: sorting takes O(n log n) operations while <code>min()</code> or <code>max()</code> take only O(n). For
16+
large collections, this difference results in noticeably slower execution.</li>
17+
<li><strong>Memory overhead</strong>: <code>sorted()</code> creates a new list containing all elements. <code>min()</code> and <code>max()</code>
18+
only store a single element during execution.</li>
19+
<li><strong>Reduced code clarity</strong>: using <code>sorted()[0]</code> to find a minimum value obscures the intent of the code.</li>
20+
</ul>
21+
<h2>How to fix it</h2>
22+
<p>Replace <code>sorted(iterable)[0]</code> with <code>min(iterable)</code> and <code>sorted(iterable)[-1]</code> with <code>max(iterable)</code>.
23+
When <code>reverse=True</code> is used, the mapping is inverted: <code>sorted(iterable, reverse=True)[0]</code> becomes <code>max(iterable)</code> and
24+
<code>sorted(iterable, reverse=True)[-1]</code> becomes <code>min(iterable)</code>.</p>
25+
<h3>Code examples</h3>
26+
<h4>Noncompliant code example</h4>
27+
<pre data-diff-id="1" data-diff-type="noncompliant">
28+
numbers = [42, 17, 93, 8, 51]
29+
smallest = sorted(numbers)[0] # Noncompliant
30+
</pre>
31+
<h4>Compliant solution</h4>
32+
<pre data-diff-id="1" data-diff-type="compliant">
33+
numbers = [42, 17, 93, 8, 51]
34+
smallest = min(numbers)
35+
</pre>
36+
<h4>Noncompliant code example</h4>
37+
<pre data-diff-id="2" data-diff-type="noncompliant">
38+
numbers = [42, 17, 93, 8, 51]
39+
largest = sorted(numbers)[-1] # Noncompliant
40+
</pre>
41+
<h4>Compliant solution</h4>
42+
<pre data-diff-id="2" data-diff-type="compliant">
43+
numbers = [42, 17, 93, 8, 51]
44+
largest = max(numbers)
45+
</pre>
46+
<h4>Noncompliant code example</h4>
47+
<pre data-diff-id="3" data-diff-type="noncompliant">
48+
numbers = [42, 17, 93, 8, 51]
49+
largest = sorted(numbers, reverse=True)[0] # Noncompliant
50+
</pre>
51+
<h4>Compliant solution</h4>
52+
<pre data-diff-id="3" data-diff-type="compliant">
53+
numbers = [42, 17, 93, 8, 51]
54+
largest = max(numbers)
55+
</pre>
56+
<h2>Resources</h2>
57+
<h3>Documentation</h3>
58+
<ul>
59+
<li>Python Documentation - <a href="https://docs.python.org/3/library/functions.html#min"><code>min()</code> built-in function</a></li>
60+
<li>Python Documentation - <a href="https://docs.python.org/3/library/functions.html#max"><code>max()</code> built-in function</a></li>
61+
<li>Python Documentation - <a href="https://docs.python.org/3/library/functions.html#sorted"><code>sorted()</code> built-in function</a></li>
62+
<li>Python Wiki - <a href="https://wiki.python.org/moin/TimeComplexity">Time Complexity</a></li>
63+
</ul>
64+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "\"sorted()\" should not be used with indexing to find minimum or maximum values",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5 min"
8+
},
9+
"tags": [
10+
"performance",
11+
"pythonic"
12+
],
13+
"defaultSeverity": "Major",
14+
"ruleSpecification": "RSPEC-8517",
15+
"sqKey": "S8517",
16+
"scope": "All",
17+
"quickfix": "targeted",
18+
"code": {
19+
"impacts": {
20+
"MAINTAINABILITY": "MEDIUM"
21+
},
22+
"attribute": "EFFICIENT"
23+
}
24+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@
327327
"S8412",
328328
"S8413",
329329
"S8414",
330-
"S8415"
330+
"S8415",
331+
"S8517"
331332
]
332333
}
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) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
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 SortedMinMaxCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/sortedMinMax.py", new SortedMinMaxCheck());
27+
}
28+
29+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
numbers = [42, 17, 93, 8, 51]
2+
words = ["banana", "fig", "apple", "date"]
3+
4+
# Issues detected
5+
smallest = sorted(numbers)[0] # Noncompliant {{Use "min()" instead of sorting to find this value.}}
6+
# ^^^^^^^^^^^^^^^^^^
7+
8+
largest = sorted(numbers)[-1] # Noncompliant {{Use "max()" instead of sorting to find this value.}}
9+
# ^^^^^^^^^^^^^^^^^^^
10+
11+
also_largest = sorted(numbers, reverse=True)[0] # Noncompliant {{Use "max()" instead of sorting to find this value.}}
12+
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
13+
14+
also_smallest = sorted(numbers, reverse=True)[-1] # Noncompliant {{Use "min()" instead of sorting to find this value.}}
15+
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
16+
17+
# reverse=False is treated the same as no reverse argument
18+
x = sorted(numbers, reverse=False)[0] # Noncompliant {{Use "min()" instead of sorting to find this value.}}
19+
y = sorted(numbers, reverse=False)[-1] # Noncompliant {{Use "max()" instead of sorting to find this value.}}
20+
21+
# key parameter does not suppress the issue
22+
shortest = sorted(words, key=len)[0] # Noncompliant {{Use "min()" instead of sorting to find this value.}}
23+
# ^^^^^^^^^^^^^^^^^^^^^^^^^
24+
25+
longest_by_len = sorted(words, key=len, reverse=True)[0] # Noncompliant {{Use "max()" instead of sorting to find this value.}}
26+
27+
# inline collection literal
28+
inline_min = sorted([5, 3, 8, 1])[0] # Noncompliant
29+
30+
# used in a boolean expression
31+
def get_min(data):
32+
if sorted(data)[0] < 0: # Noncompliant
33+
return True
34+
35+
36+
# Compliant cases
37+
38+
# use min() and max() directly
39+
min_val = min(numbers)
40+
max_val = max(numbers)
41+
min_by_len = min(words, key=len)
42+
max_by_len = max(words, key=len)
43+
44+
# sorted() without indexing
45+
sorted_numbers = sorted(numbers)
46+
47+
# index other than 0 or -1
48+
second = sorted(numbers)[1]
49+
second_to_last = sorted(numbers)[-2]
50+
51+
# slice, not a single index
52+
first_three = sorted(numbers)[0:3]
53+
54+
# indexing a non-sorted() object
55+
first = numbers[0]
56+
last = numbers[-1]
57+
58+
# sorted result stored in a variable first
59+
ordered = sorted(numbers)
60+
first_ordered = ordered[0]
61+
last_ordered = ordered[-1]
62+
63+
# custom (shadowed) sorted function
64+
def case_custom_sorted():
65+
def sorted(iterable):
66+
return list(iterable)
67+
result = sorted(numbers)[0]
68+
69+
# indirect call through a variable
70+
def case_indirect():
71+
sort_fn = sorted
72+
result = sort_fn(numbers)[0] # Noncompliant
73+
# ^^^^^^^^^^^^^^^^^^^
74+
75+
# sorted result wrapped in another call
76+
def identity(x):
77+
return x
78+
79+
wrapped = identity(sorted(numbers))[0]
80+
81+
# multiple subscripts (tuple index) - not a single subscript
82+
multi_subscript = sorted(numbers)[0, 1]
83+
84+
# qualified name callee - TypeMatchers resolves builtins.sorted correctly
85+
import builtins
86+
qualified_sorted = builtins.sorted(numbers)[0] # Noncompliant {{Use "min()" instead of sorting to find this value.}}
87+
88+
# unary plus index - not UNARY_MINUS, so not -1
89+
unary_plus_index = sorted(numbers)[+1]
90+
91+
# unary minus applied to a variable - not a NumericLiteral operand
92+
neg_var = 1
93+
unary_minus_var_index = sorted(numbers)[-neg_var]
94+
95+
# reverse argument is a truthy integer literal
96+
reverse_int_literal = sorted(numbers, reverse=1)[0] # Noncompliant {{Use "max()" instead of sorting to find this value.}}
97+
98+
# reverse argument is a falsy integer literal
99+
reverse_zero = sorted(numbers, reverse=0)[0] # Noncompliant {{Use "min()" instead of sorting to find this value.}}
100+
101+
# reverse argument is a variable assigned to True - resolved through single assignment
102+
rev_flag = True
103+
reverse_var = sorted(numbers, reverse=rev_flag)[0] # Noncompliant {{Use "max()" instead of sorting to find this value.}}
104+
105+
# reverse argument is an unknown variable - cannot determine truthiness, skip
106+
def some_function(flag):
107+
unknown_reverse = sorted(numbers, reverse=flag)[0]
108+
109+
# sorted used inside a lambda expression (the sorted()[index] is still flagged)
110+
get_last = lambda lst: sorted(lst, key=lambda m: m)[-1] # Noncompliant {{Use "max()" instead of sorting to find this value.}}
111+
112+
# sorted result used as subscript key into another collection
113+
d = {"a": 1, "b": 2, "c": 3}
114+
value = d[sorted(d.keys())[-1]] # Noncompliant {{Use "max()" instead of sorting to find this value.}}
115+
116+
# negated key lambda - sorted(key=lambda x: -f(x))[0] is still noncompliant
117+
items = ["hi", "hello", "hey"]
118+
negated_key = sorted(items, key=lambda s: -len(s))[0] # Noncompliant {{Use "min()" instead of sorting to find this value.}}
119+
120+
# multi-line sorted call - same rule applies regardless of formatting
121+
multi_line_min = sorted( # Noncompliant {{Use "min()" instead of sorting to find this value.}}
122+
words,
123+
key=len,
124+
)[0]
125+
126+
test_float = sorted(items)[0.5]

0 commit comments

Comments
 (0)