Skip to content

Commit 0df1438

Browse files
authored
Add Checkstyle rule for broken switch cases (#88739)
We use `between(x, y)` calls with `switch` statements in tests to randomize test behaviour. However, some usages define `case` statements that can never execute, because the `case` value is outside the range defined by the `between` call. Write a rule that inspects the switches and the cases, and fails on the broken cases. This rule checks `between`, `randomIntBetween` and `randomInt`.
1 parent de5d56a commit 0df1438

File tree

4 files changed

+195
-1
lines changed

4 files changed

+195
-1
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
package org.elasticsearch.gradle.internal.checkstyle;
10+
11+
import com.puppycrawl.tools.checkstyle.api.DetailAST;
12+
13+
class AstUtils {
14+
15+
/**
16+
* Dumps a tree of the provided AST to the console. Numeric types can get checked
17+
* against {@link com.puppycrawl.tools.checkstyle.grammar.java.JavaLanguageLexer}
18+
* @param ast the AST to dump
19+
*/
20+
public static void dumpAst(DetailAST ast) {
21+
dumpAst(0, ast);
22+
}
23+
24+
private static void dumpAst(int depth, DetailAST ast) {
25+
System.err.println("AST: " + (" ".repeat(depth)) + ast.getType() + "[" + ast.getText() + "]");
26+
if (ast.hasChildren()) {
27+
for (DetailAST child = ast.getFirstChild(); child != null; child = child.getNextSibling()) {
28+
dumpAst(depth + 1, child);
29+
}
30+
}
31+
}
32+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
package org.elasticsearch.gradle.internal.checkstyle;
10+
11+
import com.puppycrawl.tools.checkstyle.StatelessCheck;
12+
import com.puppycrawl.tools.checkstyle.api.AbstractCheck;
13+
import com.puppycrawl.tools.checkstyle.api.DetailAST;
14+
import com.puppycrawl.tools.checkstyle.api.TokenTypes;
15+
16+
/**
17+
* This rule checks for switch statements that use a {@code between(x, y)} to generate some kind
18+
* of random behaviour in tests and which have a case that falls outside the random range.
19+
* <p>
20+
* Consider this example:
21+
* <pre>{@code
22+
* switch (between(0, 3)) {
23+
* case 0 -> name += randomAlphaOfLength(5);
24+
* case 1 -> requiredSize += between(1, 100);
25+
* case 2 -> minDocCount += between(1, 100);
26+
* case 3 -> subsetSize += between(1, 100);
27+
* case 4 -> supersetSize += between(1, 100);
28+
* }
29+
* }</pre>
30+
* <p>The "4" case is an error, because it can never execute.
31+
*/
32+
@StatelessCheck
33+
public class SwitchBetweenCheck extends AbstractCheck {
34+
35+
public static final String SWITCH_RANDOM_INT_MSG_KEY = "forbidden.switch.randomInt";
36+
public static final String SWITCH_BETWEEN_MSG_KEY = "forbidden.switch.between";
37+
38+
@Override
39+
public int[] getDefaultTokens() {
40+
return getRequiredTokens();
41+
}
42+
43+
@Override
44+
public int[] getAcceptableTokens() {
45+
return getRequiredTokens();
46+
}
47+
48+
@Override
49+
public int[] getRequiredTokens() {
50+
return new int[] { TokenTypes.LITERAL_SWITCH };
51+
}
52+
53+
@Override
54+
public void visitToken(DetailAST ast) {
55+
checkSwitchBetween(ast);
56+
}
57+
58+
private void checkSwitchBetween(DetailAST ast) {
59+
// First dig out the switch expression
60+
final DetailAST switchExprAst = ast.findFirstToken(TokenTypes.EXPR);
61+
if (switchExprAst == null) {
62+
return;
63+
}
64+
65+
// Check if it's a method call
66+
final DetailAST methodCallAst = switchExprAst.getFirstChild();
67+
if (methodCallAst.getType() != TokenTypes.METHOD_CALL) {
68+
return;
69+
}
70+
71+
// And check if the method call is a `between` or `randomIntBetween` call
72+
final DetailAST methodIdentAst = methodCallAst.findFirstToken(TokenTypes.IDENT);
73+
if (methodIdentAst == null) {
74+
return;
75+
}
76+
final String switchMethodName = methodIdentAst.getText();
77+
switch (switchMethodName) {
78+
case "between":
79+
case "randomIntBetween":
80+
case "randomInt":
81+
// these are ok
82+
break;
83+
default:
84+
return;
85+
}
86+
87+
// The method name is good, so dig out the arguments to the method. We only handle simple,
88+
// integer literal arguments
89+
final DetailAST argListAst = methodCallAst.findFirstToken(TokenTypes.ELIST);
90+
int min;
91+
int max;
92+
if (switchMethodName.equals("randomInt")) {
93+
if (argListAst.getChildCount() != 1) { // 1 arg
94+
return;
95+
}
96+
97+
try {
98+
// Get first or last child, which is an EXPR, then get the argument itself
99+
final String maxStr = argListAst.getLastChild().getFirstChild().getText();
100+
min = 0;
101+
max = Integer.parseInt(maxStr);
102+
} catch (NumberFormatException e) {
103+
return;
104+
}
105+
} else {
106+
if (argListAst.getChildCount() != 3) { // 2 args + COMMA
107+
return;
108+
}
109+
110+
try {
111+
// Get first or last child, which is an EXPR, then get the argument itself
112+
final String minStr = argListAst.getFirstChild().getFirstChild().getText();
113+
final String maxStr = argListAst.getLastChild().getFirstChild().getText();
114+
min = Integer.parseInt(minStr);
115+
max = Integer.parseInt(maxStr);
116+
} catch (NumberFormatException e) {
117+
return;
118+
}
119+
}
120+
121+
// Now check all the cases of the switch and look for values outside the possible range.
122+
// We ignore anything that doesn't parse as an integer, so it's possible we could miss
123+
// some cases.
124+
for (DetailAST caseAst = ast.getFirstChild(); caseAst != null; caseAst = caseAst.getNextSibling()) {
125+
if (caseAst.getType() != TokenTypes.CASE_GROUP && caseAst.getType() != TokenTypes.SWITCH_RULE) {
126+
continue;
127+
}
128+
129+
final DetailAST literalCaseAst = caseAst.getFirstChild();
130+
if (literalCaseAst.getType() == TokenTypes.LITERAL_DEFAULT) {
131+
continue;
132+
}
133+
134+
final DetailAST exprAst = literalCaseAst.getFirstChild();
135+
if (exprAst.getType() != TokenTypes.EXPR) {
136+
continue;
137+
}
138+
139+
try {
140+
int value = Integer.parseInt(exprAst.getFirstChild().getText());
141+
if (value < min || value > max) {
142+
if (switchMethodName.equals("randomInt")) {
143+
log(caseAst, SWITCH_RANDOM_INT_MSG_KEY, value, switchMethodName, max);
144+
} else {
145+
log(caseAst, SWITCH_BETWEEN_MSG_KEY, value, switchMethodName, min, max);
146+
}
147+
}
148+
} catch (NumberFormatException e) {
149+
// Ignore
150+
}
151+
}
152+
}
153+
}

build-tools-internal/src/main/resources/checkstyle.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@
140140
value="''{0}'' format specifier is unsafe inside ''.formatted'' calls, as it uses the default locale. Use ''String.format'' for numeric formatting with ''Locale.ROOT'' instead." />
141141
</module>
142142

143+
<module name="org.elasticsearch.gradle.internal.checkstyle.SwitchBetweenCheck">
144+
<message
145+
key="forbidden.switch.between"
146+
value="Case ''{0}'' is outside the range of the ''{1}({2}, {3})'' call of the enclosing switch." />
147+
<message
148+
key="forbidden.switch.randomInt"
149+
value="Case ''{0}'' is outside the range of the ''{1}({2})'' call of the enclosing switch." />
150+
</module>
151+
143152
<!-- We don't use Java's builtin serialization and we suppress all warning
144153
about it. The flip side of that coin is that we shouldn't _try_ to use
145154
it. We can't outright ban it with ForbiddenApis because it complain about

server/src/test/java/org/elasticsearch/search/runtime/StringScriptFieldPrefixQueryTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ protected StringScriptFieldPrefixQuery mutate(StringScriptFieldPrefixQuery orig)
4040
String fieldName = orig.fieldName();
4141
String prefix = orig.prefix();
4242
boolean caseInsensitive = orig.caseInsensitive();
43-
switch (randomInt(2)) {
43+
switch (randomInt(3)) {
4444
case 0 -> script = randomValueOtherThan(script, this::randomScript);
4545
case 1 -> fieldName += "modified";
4646
case 2 -> prefix += "modified";

0 commit comments

Comments
 (0)