Skip to content

Commit 86b1305

Browse files
authored
Merge pull request #6883 from hvitved/csharp/inline-expectations
C#: Adopt inline test expectations framework
2 parents 4de1dee + 083214f commit 86b1305

File tree

17 files changed

+1696
-346
lines changed

17 files changed

+1696
-346
lines changed

config/identical-files.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@
367367
],
368368
"Inline Test Expectations": [
369369
"cpp/ql/test/TestUtilities/InlineExpectationsTest.qll",
370+
"csharp/ql/test/TestUtilities/InlineExpectationsTest.qll",
370371
"java/ql/test/TestUtilities/InlineExpectationsTest.qll",
371372
"python/ql/test/TestUtilities/InlineExpectationsTest.qll"
372373
],
@@ -469,4 +470,4 @@
469470
"javascript/ql/lib/tutorial.qll",
470471
"python/ql/lib/tutorial.qll"
471472
]
472-
}
473+
}

csharp/ql/lib/semmle/code/csharp/Comments.qll

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

0 commit comments

Comments
 (0)