Skip to content

Commit 4216962

Browse files
SONARPY-243 FP in UnusedLocalVariableCheck with literal string interpolation (SonarSource#126)
1 parent 7587c80 commit 4216962

4 files changed

Lines changed: 79 additions & 1 deletion

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,17 @@
2222
import com.sonar.sslr.api.AstNode;
2323
import com.sonar.sslr.api.Token;
2424
import java.util.List;
25+
import java.util.regex.Matcher;
26+
import java.util.regex.Pattern;
2527
import org.sonar.python.api.PythonGrammar;
2628
import org.sonar.python.api.PythonPunctuator;
2729
import org.sonar.python.api.PythonTokenType;
2830

2931
public class CheckUtils {
3032

33+
private static final Pattern STRING_INTERPOLATION_PREFIX = Pattern.compile("^[^'\"fF]*+[fF]");
34+
private static final Pattern STRING_LITERAL_QUOTE = Pattern.compile("[\"']");
35+
3136
private CheckUtils() {
3237

3338
}
@@ -98,4 +103,18 @@ public static boolean containsValue(List<Token> list, String value) {
98103
}
99104
return false;
100105
}
106+
107+
public static boolean isStringInterpolation(Token token) {
108+
return token.getType().equals(PythonTokenType.STRING) &&
109+
STRING_INTERPOLATION_PREFIX.matcher(token.getOriginalValue()).find();
110+
}
111+
112+
public static String stringLiteralContent(String stringLiteral) {
113+
Matcher quote = STRING_LITERAL_QUOTE.matcher(stringLiteral);
114+
if (!quote.find()) {
115+
throw new IllegalStateException("Invalid string literal: " + stringLiteral);
116+
}
117+
return stringLiteral.substring(quote.end(), stringLiteral.length() - 1);
118+
}
119+
101120
}

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@
2121

2222
import com.sonar.sslr.api.AstNode;
2323
import com.sonar.sslr.api.AstNodeType;
24+
import com.sonar.sslr.api.Token;
25+
import java.util.Arrays;
2426
import java.util.Collections;
2527
import java.util.Set;
28+
import java.util.regex.Pattern;
29+
import java.util.stream.Collectors;
2630
import org.sonar.check.Rule;
2731
import org.sonar.python.PythonCheck;
2832
import org.sonar.python.api.PythonGrammar;
@@ -31,6 +35,8 @@
3135
@Rule(key = "S1481")
3236
public class UnusedLocalVariableCheck extends PythonCheck {
3337

38+
private static final Pattern IDENTIFIER_SEPARATOR = Pattern.compile("[^a-zA-Z0-9_]+");
39+
3440
private static final String MESSAGE = "Remove the unused local variable \"%s\".";
3541

3642
@Override
@@ -44,8 +50,11 @@ public void visitNode(AstNode functionTree) {
4450
if (isCallingLocalsFunction(functionTree)) {
4551
return;
4652
}
53+
Set<String> interpolationIdentifiers = extractStringInterpolationIdentifiers(functionTree);
4754
for (Symbol symbol : getContext().symbolTable().symbols(functionTree)) {
48-
checkSymbol(symbol);
55+
if (!interpolationIdentifiers.contains(symbol.name())) {
56+
checkSymbol(symbol);
57+
}
4958
}
5059
}
5160

@@ -56,6 +65,16 @@ private static boolean isCallingLocalsFunction(AstNode functionTree) {
5665
.anyMatch(node -> "locals".equals(node.getTokenValue()));
5766
}
5867

68+
private static Set<String> extractStringInterpolationIdentifiers(AstNode functionTree) {
69+
return functionTree.getTokens().stream()
70+
.filter(CheckUtils::isStringInterpolation)
71+
.map(Token::getOriginalValue)
72+
.map(CheckUtils::stringLiteralContent)
73+
.map(IDENTIFIER_SEPARATOR::split)
74+
.flatMap(Arrays::stream)
75+
.collect(Collectors.toSet());
76+
}
77+
5978
private void checkSymbol(Symbol symbol) {
6079
if (symbol.readUsages().isEmpty()) {
6180
for (AstNode writeUsage : symbol.writeUsages()) {

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,27 @@
2222
import com.google.common.collect.ImmutableSet;
2323
import com.sonar.sslr.api.AstNode;
2424
import com.sonar.sslr.api.AstNodeType;
25+
import com.sonar.sslr.api.Grammar;
26+
import com.sonar.sslr.impl.Parser;
2527
import java.lang.reflect.Constructor;
28+
import java.nio.charset.StandardCharsets;
2629
import java.util.Set;
30+
import java.util.function.Function;
2731
import org.junit.Test;
2832
import org.sonar.python.PythonCheck;
33+
import org.sonar.python.PythonConfiguration;
2934
import org.sonar.python.api.PythonGrammar;
3035
import org.sonar.python.checks.utils.PythonCheckVerifier;
36+
import org.sonar.python.parser.PythonParser;
3137

3238
import static org.assertj.core.api.Assertions.assertThat;
3339
import static org.junit.Assert.assertFalse;
3440
import static org.junit.Assert.assertTrue;
3541

3642
public class CheckUtilsTest {
3743

44+
private static final Parser<Grammar> PARSER = PythonParser.create(new PythonConfiguration(StandardCharsets.UTF_8));
45+
3846
@Test
3947
public void private_constructor() throws Exception {
4048
Constructor constructor = CheckUtils.class.getDeclaredConstructor();
@@ -113,4 +121,29 @@ public void visitNode(AstNode astNode) {
113121
}
114122
}
115123

124+
@Test
125+
public void string_interpolation() throws Exception {
126+
Function<String, Boolean> isStringInterpolation = (source) -> CheckUtils.isStringInterpolation(PARSER.parse(source).getTokens().get(0));
127+
assertThat(isStringInterpolation.apply("\"abc\"")).isFalse();
128+
assertThat(isStringInterpolation.apply("r'abc'")).isFalse();
129+
assertThat(isStringInterpolation.apply("f'abc'")).isTrue();
130+
assertThat(isStringInterpolation.apply("Rf'abc'")).isTrue();
131+
assertThat(isStringInterpolation.apply("rF'abc'")).isTrue();
132+
assertThat(isStringInterpolation.apply("fr\"\"")).isTrue();
133+
}
134+
135+
@Test
136+
public void string_literal_content() throws Exception {
137+
Function<String, String> stringLiteralContent = (source) ->
138+
CheckUtils.stringLiteralContent(PARSER.parse(source).getTokens().get(0).getOriginalValue());
139+
assertThat(stringLiteralContent.apply("\"abc\"")).isEqualTo("abc");
140+
assertThat(stringLiteralContent.apply("r''")).isEqualTo("");
141+
assertThat(stringLiteralContent.apply("fr\"abc abc\"")).isEqualTo("abc abc");
142+
}
143+
144+
@Test(expected = IllegalStateException.class)
145+
public void invalid_string_literal_content() throws Exception {
146+
CheckUtils.stringLiteralContent(PARSER.parse("2").getTokens().get(0).getOriginalValue());
147+
}
148+
116149
}

python-checks/src/test/resources/checks/unusedLocalVariable.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@ def using_locals(a, b):
1515
c = a + b
1616
# "locals" will include the "c" value
1717
return locals()
18+
19+
def string_interpolation():
20+
value1 = 1
21+
value2 = 2
22+
value3 = 3 # Noncompliant
23+
value4 = 4 # false-negative, value4 is not used as a variable in the string interpolation, see SONARPY-245
24+
return f'{value1}, {2*value2}, value3bis, value4'

0 commit comments

Comments
 (0)