Skip to content

Commit bf03c0f

Browse files
committed
Port InlineExpectationsTest for the Java analysis
1 parent bf401c7 commit bf03c0f

File tree

3 files changed

+356
-0
lines changed

3 files changed

+356
-0
lines changed

config/identical-files.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
],
357357
"Inline Test Expectations": [
358358
"cpp/ql/test/TestUtilities/InlineExpectationsTest.qll",
359+
"java/ql/test/TestUtilities/InlineExpectationsTest.qll",
359360
"python/ql/test/TestUtilities/InlineExpectationsTest.qll"
360361
],
361362
"C++ ExternalAPIs": [
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
/**
2+
* Provides a library for writing QL tests whose success or failure is based on expected results
3+
* embedded in the test source code as comments, rather than a `.expected` file.
4+
*
5+
* To add this framework to a new language:
6+
* - Add a file `InlineExpectationsTestPrivate.qll` that defines a `LineComment` class. This class
7+
* must support a `getContents` method that returns the contents of the given comment, _excluding_
8+
* the comment indicator itself. It should also define `toString` and `getLocation` as usual.
9+
*
10+
* To create a new inline expectations test:
11+
* - Declare a class that extends `InlineExpectationsTest`. In the characteristic predicate of the
12+
* new class, bind `this` to a unique string (usually the name of the test).
13+
* - Override the `hasActualResult()` predicate to produce the actual results of the query. For each
14+
* result, specify a `Location`, a text description of the element for which the result was
15+
* reported, a short string to serve as the tag to identify expected results for this test, and the
16+
* expected value of the result.
17+
* - Override `getARelevantTag()` to return the set of tags that can be produced by
18+
* `hasActualResult()`. Often this is just a single tag.
19+
*
20+
* Example:
21+
* ```ql
22+
* class ConstantValueTest extends InlineExpectationsTest {
23+
* ConstantValueTest() { this = "ConstantValueTest" }
24+
*
25+
* override string getARelevantTag() {
26+
* // We only use one tag for this test.
27+
* result = "const"
28+
* }
29+
*
30+
* override predicate hasActualResult(
31+
* Location location, string element, string tag, string value
32+
* ) {
33+
* exists(Expr e |
34+
* tag = "const" and // The tag for this test.
35+
* value = e.getValue() and // The expected value. Will only hold for constant expressions.
36+
* location = e.getLocation() and // The location of the result to be reported.
37+
* element = e.toString() // The display text for the result.
38+
* )
39+
* }
40+
* }
41+
* ```
42+
*
43+
* There is no need to write a `select` clause or query predicate. All of the differences between
44+
* expected results and actual results will be reported in the `failures()` query predicate.
45+
*
46+
* To annotate the test source code with an expected result, place a comment starting with a `$` on the
47+
* same line as the expected result, with text of the following format as the body of the comment:
48+
*
49+
* `tag=expected-value`
50+
*
51+
* Where `tag` is the value of the `tag` parameter from `hasActualResult()`, and `expected-value` is
52+
* the value of the `value` parameter from `hasActualResult()`. The `=expected-value` portion may be
53+
* omitted, in which case `expected-value` is treated as the empty string. Multiple expectations may
54+
* be placed in the same comment. Any actual result that
55+
* appears on a line that does not contain a matching expected result comment will be reported with
56+
* a message of the form "Unexpected result: tag=value". Any expected result comment for which there
57+
* is no matching actual result will be reported with a message of the form
58+
* "Missing result: tag=expected-value".
59+
*
60+
* Example:
61+
* ```cpp
62+
* int i = x + 5; // $const=5
63+
* int j = y + (7 - 3) // $const=7 const=3 const=4 // The result of the subtraction is a constant.
64+
* ```
65+
*
66+
* For tests that contain known missing and spurious results, it is possible to further
67+
* annotate that a particular expected result is known to be spurious, or that a particular
68+
* missing result is known to be missing:
69+
*
70+
* `$ SPURIOUS: tag=expected-value` // Spurious result
71+
* `$ MISSING: tag=expected-value` // Missing result
72+
*
73+
* A spurious expectation is treated as any other expected result, except that if there is no
74+
* matching actual result, the message will be of the form "Fixed spurious result: tag=value". A
75+
* missing expectation is treated as if there were no expected result, except that if a
76+
* matching expected result is found, the message will be of the form
77+
* "Fixed missing result: tag=value".
78+
*
79+
* A single line can contain all the expected, spurious and missing results of that line. For instance:
80+
* `$ tag1=value1 SPURIOUS: tag2=value2 MISSING: tag3=value3`.
81+
*
82+
* If the same result value is expected for two or more tags on the same line, there is a shorthand
83+
* notation available:
84+
*
85+
* `tag1,tag2=expected-value`
86+
*
87+
* is equivalent to:
88+
*
89+
* `tag1=expected-value tag2=expected-value`
90+
*/
91+
92+
private import InlineExpectationsTestPrivate
93+
94+
/**
95+
* Base class for tests with inline expectations. The test extends this class to provide the actual
96+
* results of the query, which are then compared with the expected results in comments to produce a
97+
* list of failure messages that point out where the actual results differ from the expected
98+
* results.
99+
*/
100+
abstract class InlineExpectationsTest extends string {
101+
bindingset[this]
102+
InlineExpectationsTest() { any() }
103+
104+
/**
105+
* Returns all tags that can be generated by this test. Most tests will only ever produce a single
106+
* tag. Any expected result comments for a tag that is not returned by the `getARelevantTag()`
107+
* predicate for an active test will be ignored. This makes it possible to write multiple tests in
108+
* different `.ql` files that all query the same source code.
109+
*/
110+
abstract string getARelevantTag();
111+
112+
/**
113+
* Returns the actual results of the query that is being tested. Each result consist of the
114+
* following values:
115+
* - `location` - The source code location of the result. Any expected result comment must appear
116+
* on the start line of this location.
117+
* - `element` - Display text for the element on which the result is reported.
118+
* - `tag` - The tag that marks this result as coming from this test. This must be one of the tags
119+
* returned by `getARelevantTag()`.
120+
* - `value` - The value of the result, which will be matched against the value associated with
121+
* `tag` in any expected result comment on that line.
122+
*/
123+
abstract predicate hasActualResult(Location location, string element, string tag, string value);
124+
125+
final predicate hasFailureMessage(FailureLocatable element, string message) {
126+
exists(ActualResult actualResult |
127+
actualResult.getTest() = this and
128+
element = actualResult and
129+
(
130+
exists(FalseNegativeExpectation falseNegative |
131+
falseNegative.matchesActualResult(actualResult) and
132+
message = "Fixed missing result:" + falseNegative.getExpectationText()
133+
)
134+
or
135+
not exists(ValidExpectation expectation | expectation.matchesActualResult(actualResult)) and
136+
message = "Unexpected result: " + actualResult.getExpectationText()
137+
)
138+
)
139+
or
140+
exists(ValidExpectation expectation |
141+
not exists(ActualResult actualResult | expectation.matchesActualResult(actualResult)) and
142+
expectation.getTag() = getARelevantTag() and
143+
element = expectation and
144+
(
145+
expectation instanceof GoodExpectation and
146+
message = "Missing result:" + expectation.getExpectationText()
147+
or
148+
expectation instanceof FalsePositiveExpectation and
149+
message = "Fixed spurious result:" + expectation.getExpectationText()
150+
)
151+
)
152+
or
153+
exists(InvalidExpectation expectation |
154+
element = expectation and
155+
message = "Invalid expectation syntax: " + expectation.getExpectation()
156+
)
157+
}
158+
}
159+
160+
/**
161+
* RegEx pattern to match a comment containing one or more expected results. The comment must have
162+
* `$` as its first non-whitespace character. Any subsequent character
163+
* is treated as part of the expected results, except that the comment may contain a `//` sequence
164+
* to treat the remainder of the line as a regular (non-interpreted) comment.
165+
*/
166+
private string expectationCommentPattern() { result = "\\s*\\$((?:[^/]|/[^/])*)(?://.*)?" }
167+
168+
/**
169+
* The possible columns in an expectation comment. The `TDefaultColumn` branch represents the first
170+
* column in a comment. This column is not precedeeded by a name. `TNamedColumn(name)` represents a
171+
* column containing expected results preceeded by the string `name:`.
172+
*/
173+
private newtype TColumn =
174+
TDefaultColumn() or
175+
TNamedColumn(string name) { name = ["MISSING", "SPURIOUS"] }
176+
177+
bindingset[start, content]
178+
private int getEndOfColumnPosition(int start, string content) {
179+
result =
180+
min(string name, int cand |
181+
exists(TNamedColumn(name)) and
182+
cand = content.indexOf(name + ":") and
183+
cand > start
184+
|
185+
cand
186+
)
187+
or
188+
not exists(string name |
189+
exists(TNamedColumn(name)) and
190+
content.indexOf(name + ":") > start
191+
) and
192+
result = content.length()
193+
}
194+
195+
private predicate getAnExpectation(
196+
LineComment comment, TColumn column, string expectation, string tags, string value
197+
) {
198+
exists(string content |
199+
content = comment.getContents().regexpCapture(expectationCommentPattern(), 1) and
200+
(
201+
column = TDefaultColumn() and
202+
exists(int end |
203+
end = getEndOfColumnPosition(0, content) and
204+
expectation = content.prefix(end).regexpFind(expectationPattern(), _, _).trim()
205+
)
206+
or
207+
exists(string name, int start, int end |
208+
column = TNamedColumn(name) and
209+
start = content.indexOf(name + ":") + name.length() + 1 and
210+
end = getEndOfColumnPosition(start, content) and
211+
expectation = content.substring(start, end).regexpFind(expectationPattern(), _, _).trim()
212+
)
213+
)
214+
) and
215+
tags = expectation.regexpCapture(expectationPattern(), 1) and
216+
if exists(expectation.regexpCapture(expectationPattern(), 2))
217+
then value = expectation.regexpCapture(expectationPattern(), 2)
218+
else value = ""
219+
}
220+
221+
private string getColumnString(TColumn column) {
222+
column = TDefaultColumn() and result = ""
223+
or
224+
column = TNamedColumn(result)
225+
}
226+
227+
/**
228+
* RegEx pattern to match a single expected result, not including the leading `$`. It consists of one or
229+
* more comma-separated tags containing only letters, digits, `-` and `_` (note that the first character
230+
* must not be a digit), optionally followed by `=` and the expected value.
231+
*/
232+
private string expectationPattern() {
233+
exists(string tag, string tags, string value |
234+
tag = "[A-Za-z-_][A-Za-z-_0-9]*" and
235+
tags = "((?:" + tag + ")(?:\\s*,\\s*" + tag + ")*)" and
236+
value = "((?:\"[^\"]*\"|'[^']*'|\\S+)*)" and
237+
result = tags + "(?:=" + value + ")?"
238+
)
239+
}
240+
241+
private newtype TFailureLocatable =
242+
TActualResult(
243+
InlineExpectationsTest test, Location location, string element, string tag, string value
244+
) {
245+
test.hasActualResult(location, element, tag, value)
246+
} or
247+
TValidExpectation(LineComment comment, string tag, string value, string knownFailure) {
248+
exists(TColumn column, string tags |
249+
getAnExpectation(comment, column, _, tags, value) and
250+
tag = tags.splitAt(",") and
251+
knownFailure = getColumnString(column)
252+
)
253+
} or
254+
TInvalidExpectation(LineComment comment, string expectation) {
255+
getAnExpectation(comment, _, expectation, _, _) and
256+
not expectation.regexpMatch(expectationPattern())
257+
}
258+
259+
class FailureLocatable extends TFailureLocatable {
260+
string toString() { none() }
261+
262+
Location getLocation() { none() }
263+
264+
final string getExpectationText() { result = getTag() + "=" + getValue() }
265+
266+
string getTag() { none() }
267+
268+
string getValue() { none() }
269+
}
270+
271+
class ActualResult extends FailureLocatable, TActualResult {
272+
InlineExpectationsTest test;
273+
Location location;
274+
string element;
275+
string tag;
276+
string value;
277+
278+
ActualResult() { this = TActualResult(test, location, element, tag, value) }
279+
280+
override string toString() { result = element }
281+
282+
override Location getLocation() { result = location }
283+
284+
InlineExpectationsTest getTest() { result = test }
285+
286+
override string getTag() { result = tag }
287+
288+
override string getValue() { result = value }
289+
}
290+
291+
abstract private class Expectation extends FailureLocatable {
292+
LineComment comment;
293+
294+
override string toString() { result = comment.toString() }
295+
296+
override Location getLocation() { result = comment.getLocation() }
297+
}
298+
299+
private class ValidExpectation extends Expectation, TValidExpectation {
300+
string tag;
301+
string value;
302+
string knownFailure;
303+
304+
ValidExpectation() { this = TValidExpectation(comment, tag, value, knownFailure) }
305+
306+
override string getTag() { result = tag }
307+
308+
override string getValue() { result = value }
309+
310+
string getKnownFailure() { result = knownFailure }
311+
312+
predicate matchesActualResult(ActualResult actualResult) {
313+
getLocation().getStartLine() = actualResult.getLocation().getStartLine() and
314+
getLocation().getFile() = actualResult.getLocation().getFile() and
315+
getTag() = actualResult.getTag() and
316+
getValue() = actualResult.getValue()
317+
}
318+
}
319+
320+
/* Note: These next three classes correspond to all the possible values of type `TColumn`. */
321+
class GoodExpectation extends ValidExpectation {
322+
GoodExpectation() { getKnownFailure() = "" }
323+
}
324+
325+
class FalsePositiveExpectation extends ValidExpectation {
326+
FalsePositiveExpectation() { getKnownFailure() = "SPURIOUS" }
327+
}
328+
329+
class FalseNegativeExpectation extends ValidExpectation {
330+
FalseNegativeExpectation() { getKnownFailure() = "MISSING" }
331+
}
332+
333+
class InvalidExpectation extends Expectation, TInvalidExpectation {
334+
string expectation;
335+
336+
InvalidExpectation() { this = TInvalidExpectation(comment, expectation) }
337+
338+
string getExpectation() { result = expectation }
339+
}
340+
341+
query predicate failures(FailureLocatable element, string message) {
342+
exists(InlineExpectationsTest test | test.hasFailureMessage(element, message))
343+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import java
2+
3+
/**
4+
* A class representing line comments in Java, which is simply Javadoc restricted
5+
* to EOL comments, with an extra accessor used by the InlineExpectations core code
6+
*/
7+
class LineComment extends Javadoc {
8+
LineComment() { isEolComment(this) }
9+
10+
/** Gets the contents of the given comment, _without_ the preceding comment marker (`//`). */
11+
string getContents() { result = this.getChild(0).toString() }
12+
}

0 commit comments

Comments
 (0)