Skip to content

Commit 8626d32

Browse files
SONARPY-1027: Add quick fix for S1940 (BooleanCheckNotInvertedCheck) (#1143)
1 parent 49d363d commit 8626d32

File tree

6 files changed

+256
-25
lines changed

6 files changed

+256
-25
lines changed

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

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
*/
2020
package org.sonar.python.checks;
2121

22+
import java.util.List;
2223
import org.sonar.check.Rule;
2324
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
2425
import org.sonar.plugins.python.api.SubscriptionContext;
@@ -28,8 +29,12 @@
2829
import org.sonar.plugins.python.api.tree.IsExpression;
2930
import org.sonar.plugins.python.api.tree.ParenthesizedExpression;
3031
import org.sonar.plugins.python.api.tree.Token;
32+
import org.sonar.plugins.python.api.tree.Tree;
3133
import org.sonar.plugins.python.api.tree.Tree.Kind;
3234
import org.sonar.plugins.python.api.tree.UnaryExpression;
35+
import org.sonar.python.quickfix.IssueWithQuickFix;
36+
import org.sonar.python.quickfix.PythonQuickFix;
37+
import org.sonar.python.quickfix.PythonTextEdit;
3338

3439
@Rule(key = "S1940")
3540
public class BooleanCheckNotInvertedCheck extends PythonSubscriptionCheck {
@@ -46,56 +51,77 @@ private static void checkNotExpression(SubscriptionContext ctx, UnaryExpression
4651
while (negatedExpr.is(Kind.PARENTHESIZED)) {
4752
negatedExpr = ((ParenthesizedExpression) negatedExpr).expression();
4853
}
49-
if(negatedExpr.is(Kind.COMPARISON)) {
54+
if (negatedExpr.is(Kind.COMPARISON)) {
5055
BinaryExpression binaryExp = (BinaryExpression) negatedExpr;
5156
// Don't raise warning with "not a == b == c" because a == b != c is not equivalent
52-
if(!binaryExp.leftOperand().is(Kind.COMPARISON)) {
53-
ctx.addIssue(original, String.format(MESSAGE, oppositeOperator(binaryExp.operator())));
57+
if (!binaryExp.leftOperand().is(Kind.COMPARISON)) {
58+
String oppositeOperator = oppositeOperator(binaryExp.operator());
59+
60+
IssueWithQuickFix issue = ((IssueWithQuickFix) ctx.addIssue(original, String.format(MESSAGE, oppositeOperator)));
61+
createQuickFix(issue, oppositeOperator, binaryExp, negatedExpr);
5462
}
55-
} else if(negatedExpr.is(Kind.IN, Kind.IS) ) {
63+
} else if (negatedExpr.is(Kind.IN, Kind.IS)) {
5664
BinaryExpression isInExpr = (BinaryExpression) negatedExpr;
57-
ctx.addIssue(original, String.format(MESSAGE, oppositeOperator(isInExpr.operator(), isInExpr)));
65+
String oppositeOperator = oppositeOperator(isInExpr.operator(), isInExpr);
66+
67+
IssueWithQuickFix issue = ((IssueWithQuickFix) ctx.addIssue(original, String.format(MESSAGE, oppositeOperator)));
68+
createQuickFix(issue, oppositeOperator, isInExpr, negatedExpr);
5869
}
5970
}
6071

61-
private static String oppositeOperator(Token operator){
72+
private static String oppositeOperator(Token operator) {
6273
return oppositeOperatorString(operator.value());
6374
}
6475

65-
private static String oppositeOperator(Token operator, Expression expr){
76+
private static String oppositeOperator(Token operator, Expression expr) {
6677
String s = operator.value();
67-
if(expr.is(Kind.IS) && ((IsExpression) expr).notToken() != null){
78+
if (expr.is(Kind.IS) && ((IsExpression) expr).notToken() != null) {
6879
s = s + " not";
69-
} else if(expr.is(Kind.IN) && ((InExpression) expr).notToken() != null){
80+
} else if (expr.is(Kind.IN) && ((InExpression) expr).notToken() != null) {
7081
s = "not " + s;
7182
}
7283
return oppositeOperatorString(s);
7384
}
7485

75-
static String oppositeOperatorString(String stringOperator){
76-
switch (stringOperator){
77-
case ">" :
86+
static String oppositeOperatorString(String stringOperator) {
87+
switch (stringOperator) {
88+
case ">":
7889
return "<=";
79-
case ">=" :
90+
case ">=":
8091
return "<";
81-
case "<" :
92+
case "<":
8293
return ">=";
83-
case "<=" :
94+
case "<=":
8495
return ">";
85-
case "==" :
96+
case "==":
8697
return "!=";
87-
case "!=" :
98+
case "!=":
8899
return "==";
89-
case "is" :
100+
case "is":
90101
return "is not";
91102
case "is not":
92103
return "is";
93-
case "in" :
104+
case "in":
94105
return "not in";
95106
case "not in":
96107
return "in";
97-
default :
108+
default:
98109
throw new IllegalArgumentException("Unknown comparison operator : " + stringOperator);
99110
}
100111
}
112+
113+
private static void createQuickFix(IssueWithQuickFix issue, String oppositeOperator, BinaryExpression toUse, Expression toReplace) {
114+
PythonTextEdit replaceEdit = getReplaceEdit(toUse, toReplace, oppositeOperator);
115+
116+
PythonQuickFix quickFix = PythonQuickFix.newQuickFix(String.format("Use %s instead", oppositeOperator))
117+
.addTextEdit(replaceEdit)
118+
.build();
119+
issue.addQuickFix(quickFix);
120+
}
121+
122+
private static PythonTextEdit getReplaceEdit(BinaryExpression toUse, Expression toReplace, String oppositeOperator) {
123+
List<Tree> children = toReplace.parent().parent().children();
124+
return PythonTextEdit.replaceRange(children.get(0), children.get(children.size() - 1),
125+
toUse.leftOperand().firstToken().value() + " " + oppositeOperator + " " + toUse.rightOperand().firstToken().value());
126+
}
101127
}

python-checks/src/test/java/org/sonar/python/checks/BooleanCheckNotInvertedCheckTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import org.assertj.core.api.Assertions;
2323
import org.junit.Test;
24+
import org.sonar.python.checks.quickfix.PythonQuickFixVerifier;
2425
import org.sonar.python.checks.utils.PythonCheckVerifier;
2526

2627
import static org.assertj.core.api.Assertions.assertThat;
@@ -48,4 +49,35 @@ public void operatorStringTest() {
4849
Assertions.assertThatThrownBy(() -> BooleanCheckNotInvertedCheck.oppositeOperatorString("-")).isInstanceOf(IllegalArgumentException.class);
4950
Assertions.assertThatThrownBy(() -> BooleanCheckNotInvertedCheck.oppositeOperatorString("*")).isInstanceOf(IllegalArgumentException.class);
5051
}
52+
53+
@Test
54+
public void test_quickfix() {
55+
String codeWithIssue = "a = not(b == c)";
56+
String codeFixed = "a = b != c";
57+
PythonQuickFixVerifier.verify(new BooleanCheckNotInvertedCheck(), codeWithIssue, codeFixed);
58+
59+
codeWithIssue = "a = not (b != c)";
60+
codeFixed = "a = b == c";
61+
PythonQuickFixVerifier.verify(new BooleanCheckNotInvertedCheck(), codeWithIssue, codeFixed);
62+
63+
codeWithIssue = "a = not (b < c)";
64+
codeFixed = "a = b >= c";
65+
PythonQuickFixVerifier.verify(new BooleanCheckNotInvertedCheck(), codeWithIssue, codeFixed);
66+
67+
codeWithIssue = "a = not (b is c)";
68+
codeFixed = "a = b is not c";
69+
PythonQuickFixVerifier.verify(new BooleanCheckNotInvertedCheck(), codeWithIssue, codeFixed);
70+
71+
codeWithIssue = "a = not (b is not c)";
72+
codeFixed = "a = b is c";
73+
PythonQuickFixVerifier.verify(new BooleanCheckNotInvertedCheck(), codeWithIssue, codeFixed);
74+
75+
codeWithIssue = "a = not (b in c)";
76+
codeFixed = "a = b not in c";
77+
PythonQuickFixVerifier.verify(new BooleanCheckNotInvertedCheck(), codeWithIssue, codeFixed);
78+
79+
codeWithIssue = "a = not (b not in c)";
80+
codeFixed = "a = b in c";
81+
PythonQuickFixVerifier.verify(new BooleanCheckNotInvertedCheck(), codeWithIssue, codeFixed);
82+
}
5183
}

python-checks/src/test/java/org/sonar/python/checks/quickfix/PythonQuickFixVerifier.java

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
import com.sonar.sslr.api.AstNode;
2323
import java.net.URI;
2424
import java.util.Arrays;
25+
import java.util.ArrayList;
2526
import java.util.Collections;
27+
import java.util.Comparator;
2628
import java.util.List;
2729
import java.util.stream.Collectors;
2830
import org.sonar.plugins.python.api.PythonCheck;
@@ -61,13 +63,13 @@ public static void verify(PythonCheck check, String codeWithIssue, String... cod
6163
.hasSize(codesFixed.length);
6264

6365
List<String> appliedQuickFix = issue.getQuickFixes().stream()
64-
.map(quickFix -> applyQuickFix(codeWithIssue, quickFix))
66+
.map(quickFix -> applyQuickFix(codeWithIssue, quickFix))
6567
.collect(Collectors.toList());
6668

6769
assertThat(appliedQuickFix)
6870
.as("Application of the quickfix")
6971
.overridingErrorMessage("The code with the quickfix applied is not the expected result.\n" +
70-
"Applied QuickFixes are:\n%s\nExpected result:\n%s", Arrays.asList(codesFixed), appliedQuickFix)
72+
"Applied QuickFixes are:\n%s\nExpected result:\n%s", appliedQuickFix, Arrays.asList(codesFixed))
7173
.isEqualTo(Arrays.asList(codesFixed));
7274

7375
}
@@ -94,13 +96,96 @@ private static List<PreciseIssue> getIssuesWithQuickFix(PythonCheck check, Strin
9496
}
9597

9698
private static String applyQuickFix(String codeWithIssue, PythonQuickFix quickFix) {
97-
PythonTextEdit textEdit = quickFix.getTextEdits().get(0);
99+
List<PythonTextEdit> sortedEdits = sortTextEdits(quickFix.getTextEdits());
100+
String codeBeingFixed = codeWithIssue;
101+
for (PythonTextEdit edit : sortedEdits) {
102+
codeBeingFixed = applyTextEdit(codeBeingFixed, edit);
103+
}
104+
return codeBeingFixed;
105+
}
106+
107+
private static String applyTextEdit(String codeWithIssue, PythonTextEdit textEdit) {
98108
String replacement = textEdit.replacementText();
99109
int start = convertPositionToIndex(codeWithIssue, textEdit.startLine(), textEdit.startLineOffset());
100110
int end = convertPositionToIndex(codeWithIssue, textEdit.endLine(), textEdit.endLineOffset());
101111
return codeWithIssue.substring(0, start) + replacement + codeWithIssue.substring(end);
102112
}
103113

114+
private static List<PythonTextEdit> sortTextEdits(List<PythonTextEdit> pythonTextEdits) {
115+
checkNoCollision(pythonTextEdits);
116+
ArrayList<PythonTextEdit> list = new ArrayList<>(pythonTextEdits);
117+
list.sort(Comparator.comparingInt(PythonTextEdit::startLine).thenComparing(PythonTextEdit::startLineOffset));
118+
Collections.reverse(list);
119+
return Collections.unmodifiableList(list);
120+
}
121+
122+
private static void checkNoCollision(List<PythonTextEdit> pythonTextEdits) throws IllegalArgumentException {
123+
for (int i = 0; i < pythonTextEdits.size(); i++) {
124+
PythonTextEdit edit = pythonTextEdits.get(i);
125+
for (int j = i + 1; j < pythonTextEdits.size(); j++) {
126+
PythonTextEdit edit2 = pythonTextEdits.get(j);
127+
if (oneEnclosedByTheOther(edit2, edit)) {
128+
throw new IllegalArgumentException("There is a collision between the range of the quickfixes.");
129+
}
130+
}
131+
}
132+
}
133+
134+
private static boolean oneEnclosedByTheOther(PythonTextEdit toCheck, PythonTextEdit reference) {
135+
if (onSameLine(toCheck, reference)) {
136+
// If on same line, we need to check that the bounds of toCheck are not contained in reference bounds
137+
return !(toCheck.endLineOffset() < reference.startLineOffset() || toCheck.startLineOffset() > reference.endLineOffset());
138+
} else {
139+
if (compactOnDifferentLines(toCheck, reference)) {
140+
return false;
141+
} else if (isCompact(toCheck)) {
142+
return isSecondInFirst(toCheck, reference);
143+
} else if (isCompact(reference)) {
144+
return isSecondInFirst(reference, toCheck);
145+
} else {
146+
// Both edits exploded on different lines
147+
if (noLineIntersection(toCheck, reference)) {
148+
return false;
149+
} else {
150+
// There is an intersection between edits, only need to check valid case
151+
if (reference.startLine() == toCheck.endLine()) {
152+
return !(toCheck.endLineOffset() < reference.startLineOffset());
153+
} else if (reference.endLine() == toCheck.startLine()) {
154+
return !(reference.endLineOffset() < toCheck.startLineOffset());
155+
}
156+
}
157+
}
158+
}
159+
// All other cases are invalid and will cause an intersection
160+
return true;
161+
}
162+
163+
private static boolean onSameLine(PythonTextEdit check, PythonTextEdit ref) {
164+
return ref.startLine() == ref.endLine() && ref.endLine() == check.startLine() && check.startLine() == check.endLine();
165+
}
166+
167+
private static boolean compactOnDifferentLines(PythonTextEdit check, PythonTextEdit ref) {
168+
return ref.startLine() == ref.endLine() && check.startLine() == check.endLine() && ref.endLine() != check.startLine();
169+
}
170+
171+
private static boolean isCompact(PythonTextEdit check) {
172+
return check.startLine() == check.endLine();
173+
}
174+
175+
private static boolean isSecondInFirst(PythonTextEdit second, PythonTextEdit first) {
176+
if (first.startLine() == second.startLine()) {
177+
return first.startLineOffset() <= second.endLineOffset();
178+
} else if (first.endLine() == second.startLine()) {
179+
return second.startLineOffset() < first.endLineOffset();
180+
}
181+
// No intersection
182+
return false;
183+
}
184+
185+
private static boolean noLineIntersection(PythonTextEdit check, PythonTextEdit ref) {
186+
return check.endLine() < ref.startLine() || check.startLine() > ref.endLine();
187+
}
188+
104189
private static int convertPositionToIndex(String fileContent, int line, int lineOffset) {
105190
int currentLine = 1;
106191
int currentIndex = 0;

python-checks/src/test/java/org/sonar/python/checks/quickfix/PythonQuickFixVerifierTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ public void one_issue_one_qf_wrong_fix() {
6464
.isInstanceOf(AssertionError.class)
6565
.hasMessageContaining("[Application of the quickfix] The code with the quickfix applied is not the expected result.\n" +
6666
"Applied QuickFixes are:\n" +
67-
"[a==10]\n" +
67+
"[a!=10]\n" +
6868
"Expected result:\n" +
69-
"[a!=10]");
69+
"[a==10]");
7070
}
7171

7272
@Test

python-frontend/src/main/java/org/sonar/python/quickfix/PythonTextEdit.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@ public static PythonTextEdit insertAfter(Tree tree, String textToInsert) {
4949
return new PythonTextEdit(textToInsert, token.line(), token.column() + lengthToken, token.line(), token.column() + lengthToken);
5050
}
5151

52+
public static PythonTextEdit replace(Tree toReplace, String replacementText) {
53+
Token token = toReplace.firstToken();
54+
Token last = token.lastToken();
55+
return new PythonTextEdit(replacementText, token.line(), token.column(), last.line(), last.column() + last.value().length());
56+
}
57+
58+
public static PythonTextEdit replaceRange(Tree start, Tree end, String replacementText) {
59+
Token first =start.firstToken();
60+
Token last = end.lastToken();
61+
return new PythonTextEdit(replacementText, first.line(), first.column(), last.line(), last.column() + last.value().length());
62+
}
63+
64+
public static PythonTextEdit remove(Tree toRemove) {
65+
return replace(toRemove, "");
66+
}
67+
5268
public String replacementText() {
5369
return message;
5470
}

python-frontend/src/test/java/org/sonar/python/quickfix/PythonTextEditTest.java

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,76 @@ public void insert_after() {
6868
assertThat(textEdit.endLine()).isEqualTo(1);
6969
assertThat(textEdit.endLineOffset()).isEqualTo(12);
7070
}
71+
72+
@Test
73+
public void replace() {
74+
String tokenValue = "token";
75+
String replacementText = "This is a replacement text";
76+
77+
Token token = Mockito.mock(Token.class);
78+
when(token.line()).thenReturn(1);
79+
when(token.column()).thenReturn(7);
80+
when(token.firstToken()).thenReturn(token);
81+
when(token.lastToken()).thenReturn(token);
82+
83+
when(token.value()).thenReturn(tokenValue);
84+
85+
PythonTextEdit textEdit = PythonTextEdit.replace(token, replacementText);
86+
87+
assertThat(textEdit.replacementText()).isEqualTo(replacementText);
88+
assertThat(textEdit.startLine()).isEqualTo(1);
89+
assertThat(textEdit.startLineOffset()).isEqualTo(7);
90+
assertThat(textEdit.endLine()).isEqualTo(1);
91+
assertThat(textEdit.endLineOffset()).isEqualTo(12);
92+
}
93+
94+
@Test
95+
public void remove() {
96+
String tokenValue = "token";
97+
98+
Token token = Mockito.mock(Token.class);
99+
when(token.line()).thenReturn(1);
100+
when(token.column()).thenReturn(7);
101+
when(token.firstToken()).thenReturn(token);
102+
when(token.lastToken()).thenReturn(token);
103+
104+
when(token.value()).thenReturn(tokenValue);
105+
106+
PythonTextEdit textEdit = PythonTextEdit.remove(token);
107+
108+
assertThat(textEdit.replacementText()).isEmpty();
109+
assertThat(textEdit.startLine()).isEqualTo(1);
110+
assertThat(textEdit.startLineOffset()).isEqualTo(7);
111+
assertThat(textEdit.endLine()).isEqualTo(1);
112+
assertThat(textEdit.endLineOffset()).isEqualTo(12);
113+
}
114+
115+
@Test
116+
public void replaceChildren() {
117+
// Parsing 'a = (b and c)'
118+
String tokenValue1 = "(";
119+
String tokenValue2 = ")";
120+
121+
Token token1 = Mockito.mock(Token.class);
122+
Token token2 = Mockito.mock(Token.class);
123+
124+
when(token1.line()).thenReturn(1);
125+
when(token1.column()).thenReturn(4);
126+
when(token2.line()).thenReturn(1);
127+
when(token2.column()).thenReturn(12);
128+
129+
when(token1.firstToken()).thenReturn(token1);
130+
when(token2.lastToken()).thenReturn(token2);
131+
132+
when(token1.value()).thenReturn(tokenValue1);
133+
when(token2.value()).thenReturn(tokenValue2);
134+
135+
PythonTextEdit textEdit = PythonTextEdit.replaceRange(token1, token2, "b and c");
136+
137+
assertThat(textEdit.replacementText()).isEqualTo("b and c");
138+
assertThat(textEdit.startLine()).isEqualTo(1);
139+
assertThat(textEdit.startLineOffset()).isEqualTo(4);
140+
assertThat(textEdit.endLine()).isEqualTo(1);
141+
assertThat(textEdit.endLineOffset()).isEqualTo(13);
142+
}
71143
}

0 commit comments

Comments
 (0)