Skip to content

Commit 5333e28

Browse files
authoredOct 19, 2021
Added regex coverage (#3138)
1 parent 2e834c8 commit 5333e28

File tree

8 files changed

+371
-73
lines changed

8 files changed

+371
-73
lines changed
 

‎.github/workflows/test.yml

+13
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,16 @@ jobs:
5757
node-version: 14.x
5858
- run: npm ci
5959
- run: npm run lint:ci
60+
61+
coverage:
62+
63+
runs-on: ubuntu-latest
64+
65+
steps:
66+
- uses: actions/checkout@v2
67+
- name: Use Node.js 14.x
68+
uses: actions/setup-node@v1
69+
with:
70+
node-version: 14.x
71+
- run: npm ci
72+
- run: npm run regex-coverage

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"lint": "eslint . --cache",
1515
"lint:fix": "npm run lint -- --fix",
1616
"lint:ci": "eslint . --max-warnings 0",
17+
"regex-coverage": "mocha tests/coverage.js",
1718
"test:aliases": "mocha tests/aliases-test.js",
1819
"test:core": "mocha tests/core/**/*.js",
1920
"test:dependencies": "mocha tests/dependencies-test.js",

‎tests/coverage.js

+260
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
'use strict';
2+
3+
const TestDiscovery = require('./helper/test-discovery');
4+
const TestCase = require('./helper/test-case');
5+
const PrismLoader = require('./helper/prism-loader');
6+
const { BFS, BFSPathToPrismTokenPath } = require('./helper/util');
7+
const { assert } = require('chai');
8+
const components = require('../components.json');
9+
const ALL_LANGUAGES = [...Object.keys(components.languages).filter(k => k !== 'meta')];
10+
11+
12+
describe('Pattern test coverage', function () {
13+
/**
14+
* @type {Map<string, PatternData>}
15+
* @typedef PatternData
16+
* @property {RegExp} pattern
17+
* @property {string} language
18+
* @property {Set<string>} from
19+
* @property {RegExpExecArray[]} matches
20+
*/
21+
const patterns = new Map();
22+
23+
/**
24+
* @param {string | string[]} languages
25+
* @returns {import("./helper/prism-loader").Prism}
26+
*/
27+
function createInstance(languages) {
28+
const Prism = PrismLoader.createInstance(languages);
29+
30+
BFS(Prism.languages, (path, object) => {
31+
const { key, value } = path[path.length - 1];
32+
const tokenPath = BFSPathToPrismTokenPath(path);
33+
34+
if (Object.prototype.toString.call(value) == '[object RegExp]') {
35+
const regex = makeGlobal(value);
36+
object[key] = regex;
37+
38+
const patternKey = String(regex);
39+
let data = patterns.get(patternKey);
40+
if (!data) {
41+
data = {
42+
pattern: regex,
43+
language: path[1].key,
44+
from: new Set([tokenPath]),
45+
matches: []
46+
};
47+
patterns.set(patternKey, data);
48+
} else {
49+
data.from.add(tokenPath);
50+
}
51+
52+
regex.exec = string => {
53+
let match = RegExp.prototype.exec.call(regex, string);
54+
if (match) {
55+
data.matches.push(match);
56+
}
57+
return match;
58+
};
59+
}
60+
});
61+
62+
return Prism;
63+
}
64+
65+
describe('Register all patterns', function () {
66+
it('all', function () {
67+
this.slow(10 * 1000);
68+
// This will cause ALL regexes of Prism to be registered in the patterns map.
69+
// (Languages that don't have any tests can't be caught otherwise.)
70+
createInstance(ALL_LANGUAGES);
71+
});
72+
});
73+
74+
describe('Run all language tests', function () {
75+
// define tests for all tests in all languages in the test suite
76+
for (const [languageIdentifier, files] of TestDiscovery.loadAllTests()) {
77+
it(languageIdentifier, function () {
78+
this.timeout(10 * 1000);
79+
80+
for (const filePath of files) {
81+
try {
82+
TestCase.run({
83+
languageIdentifier,
84+
filePath,
85+
updateMode: 'none',
86+
createInstance
87+
});
88+
} catch (error) {
89+
// we don't case about whether the test succeeds,
90+
// we just want to gather usage data
91+
}
92+
}
93+
});
94+
}
95+
});
96+
97+
describe('Coverage', function () {
98+
for (const language of ALL_LANGUAGES) {
99+
describe(language, function () {
100+
it(`- should cover all patterns`, function () {
101+
const untested = getAllOf(language).filter(d => d.matches.length === 0);
102+
if (untested.length === 0) {
103+
return;
104+
}
105+
106+
const problems = untested.map(data => {
107+
return formatProblem(data, [
108+
'This pattern is completely untested. Add test files that match this pattern.'
109+
]);
110+
});
111+
112+
assert.fail([
113+
`${problems.length} pattern(s) are untested:\n`
114+
+ 'You can learn more about writing tests at https://prismjs.com/test-suite.html#writing-tests',
115+
...problems
116+
].join('\n\n'));
117+
});
118+
119+
it(`- should exhaustively cover all keywords in keyword lists`, function () {
120+
const problems = [];
121+
122+
for (const data of getAllOf(language)) {
123+
if (data.matches.length === 0) {
124+
// don't report the same pattern twice
125+
continue;
126+
}
127+
128+
const keywords = getKeywordList(data.pattern);
129+
if (!keywords) {
130+
continue;
131+
}
132+
const keywordCount = keywords.size;
133+
134+
data.matches.forEach(([m]) => {
135+
if (data.pattern.ignoreCase) {
136+
m = m.toUpperCase();
137+
}
138+
keywords.delete(m);
139+
});
140+
141+
if (keywords.size > 0) {
142+
problems.push(formatProblem(data, [
143+
`Add test files to test all keywords. The following keywords (${keywords.size}/${keywordCount}) are untested:`,
144+
...[...keywords].map(k => ` ${k}`)
145+
]));
146+
}
147+
}
148+
149+
if (problems.length === 0) {
150+
return;
151+
}
152+
153+
assert.fail([
154+
`${problems.length} keyword list(s) are not exhaustively tested:\n`
155+
+ 'You can learn more about writing tests at https://prismjs.com/test-suite.html#writing-tests',
156+
...problems
157+
].join('\n\n'));
158+
});
159+
});
160+
}
161+
});
162+
163+
/**
164+
* @param {string} language
165+
* @returns {PatternData[]}
166+
*/
167+
function getAllOf(language) {
168+
return [...patterns.values()].filter(d => d.language === language);
169+
}
170+
171+
/**
172+
* @param {string} string
173+
* @param {number} maxLength
174+
* @returns {string}
175+
*/
176+
function short(string, maxLength) {
177+
if (string.length > maxLength) {
178+
return string.slice(0, maxLength - 1) + '…';
179+
} else {
180+
return string;
181+
}
182+
}
183+
184+
/**
185+
* If the given pattern string describes a keyword list, all keyword will be returned. Otherwise, `null` will be
186+
* returned.
187+
*
188+
* @param {RegExp} pattern
189+
* @returns {Set<string> | null}
190+
*/
191+
function getKeywordList(pattern) {
192+
// Right now, only keyword lists of the form /\b(?:foo|bar)\b/ are supported.
193+
// In the future, we might want to convert these regexes to NFAs and iterate all words to cover more complex
194+
// keyword lists and even operator and punctuation lists.
195+
196+
let source = pattern.source.replace(/^\\b|\\b$/g, '');
197+
if (source.startsWith('(?:') && source.endsWith(')')) {
198+
source = source.slice('(?:'.length, source.length - ')'.length);
199+
}
200+
201+
if (/^\w+(?:\|\w+)*$/.test(source)) {
202+
if (pattern.ignoreCase) {
203+
source = source.toUpperCase();
204+
}
205+
return new Set(source.split(/\|/g));
206+
} else {
207+
return null;
208+
}
209+
}
210+
211+
/**
212+
* @param {Iterable<string>} occurrences
213+
* @returns {{ origin: string; otherOccurrences: string[] }}
214+
*/
215+
function splitOccurrences(occurrences) {
216+
const all = [...occurrences];
217+
return {
218+
origin: all[0],
219+
otherOccurrences: all.slice(1),
220+
};
221+
}
222+
223+
/**
224+
* @param {PatternData} data
225+
* @param {string[]} messageLines
226+
* @returns {string}
227+
*/
228+
function formatProblem(data, messageLines) {
229+
const { origin, otherOccurrences } = splitOccurrences(data.from);
230+
231+
const lines = [
232+
`${origin}:`,
233+
short(String(data.pattern), 100),
234+
'',
235+
...messageLines,
236+
];
237+
238+
if (otherOccurrences.length) {
239+
lines.push(
240+
'',
241+
'Other occurrences of this pattern:',
242+
...otherOccurrences.map(o => `- ${o}`)
243+
);
244+
}
245+
246+
return lines.join('\n ');
247+
}
248+
});
249+
250+
/**
251+
* @param {RegExp} regex
252+
* @returns {RegExp}
253+
*/
254+
function makeGlobal(regex) {
255+
if (regex.global) {
256+
return regex;
257+
} else {
258+
return RegExp(regex.source, regex.flags + 'g');
259+
}
260+
}

‎tests/helper/test-case.js

+39-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const fs = require('fs');
4+
const path = require('path');
45
const { assert } = require('chai');
56
const Prettier = require('prettier');
67
const PrismLoader = require('./prism-loader');
@@ -11,6 +12,12 @@ const TokenStreamTransformer = require('./token-stream-transformer');
1112
* @typedef {import("../../components/prism-core.js")} Prism
1213
*/
1314

15+
/**
16+
* @param {string[]} languages
17+
* @returns {Prism}
18+
*/
19+
const defaultCreateInstance = (languages) => PrismLoader.createInstance(languages);
20+
1421
/**
1522
* Handles parsing and printing of a test case file.
1623
*
@@ -297,6 +304,29 @@ class HighlightHTMLRunner {
297304
module.exports = {
298305
TestCaseFile,
299306

307+
/**
308+
* Runs the given test file and asserts the result.
309+
*
310+
* This function will determine what kind of test files the given file is and call the appropriate method to run the
311+
* test.
312+
*
313+
* @param {RunOptions} options
314+
* @returns {void}
315+
*
316+
* @typedef RunOptions
317+
* @property {string} languageIdentifier
318+
* @property {string} filePath
319+
* @property {"none" | "insert" | "update"} updateMode
320+
* @property {(languages: string[]) => Prism} [createInstance]
321+
*/
322+
run(options) {
323+
if (path.extname(options.filePath) === '.test') {
324+
this.runTestCase(options.languageIdentifier, options.filePath, options.updateMode, options.createInstance);
325+
} else {
326+
this.runTestsWithHooks(options.languageIdentifier, require(options.filePath), options.createInstance);
327+
}
328+
},
329+
300330
/**
301331
* Runs the given test case file and asserts the result
302332
*
@@ -312,27 +342,31 @@ module.exports = {
312342
* @param {string} languageIdentifier
313343
* @param {string} filePath
314344
* @param {"none" | "insert" | "update"} updateMode
345+
* @param {(languages: string[]) => Prism} [createInstance]
315346
*/
316-
runTestCase(languageIdentifier, filePath, updateMode) {
347+
runTestCase(languageIdentifier, filePath, updateMode, createInstance = defaultCreateInstance) {
348+
let runner;
317349
if (/\.html\.test$/i.test(filePath)) {
318-
this.runTestCaseWithRunner(languageIdentifier, filePath, updateMode, new HighlightHTMLRunner());
350+
runner = new HighlightHTMLRunner();
319351
} else {
320-
this.runTestCaseWithRunner(languageIdentifier, filePath, updateMode, new TokenizeJSONRunner());
352+
runner = new TokenizeJSONRunner();
321353
}
354+
this.runTestCaseWithRunner(languageIdentifier, filePath, updateMode, runner, createInstance);
322355
},
323356

324357
/**
325358
* @param {string} languageIdentifier
326359
* @param {string} filePath
327360
* @param {"none" | "insert" | "update"} updateMode
328361
* @param {Runner<T>} runner
362+
* @param {(languages: string[]) => Prism} createInstance
329363
* @template T
330364
*/
331-
runTestCaseWithRunner(languageIdentifier, filePath, updateMode, runner) {
365+
runTestCaseWithRunner(languageIdentifier, filePath, updateMode, runner, createInstance) {
332366
const testCase = TestCaseFile.readFromFile(filePath);
333367
const usedLanguages = this.parseLanguageNames(languageIdentifier);
334368

335-
const Prism = PrismLoader.createInstance(usedLanguages.languages);
369+
const Prism = createInstance(usedLanguages.languages);
336370

337371
// the first language is the main language to highlight
338372
const actualValue = runner.run(Prism, testCase.code, usedLanguages.mainLanguage);

‎tests/helper/test-discovery.js

+15-19
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,37 @@
33
const fs = require('fs');
44
const path = require('path');
55

6+
const LANGUAGES_DIR = path.join(__dirname, '..', 'languages');
7+
68
module.exports = {
79

810
/**
911
* Loads the list of all available tests
1012
*
11-
* @param {string} rootDir
12-
* @returns {Object<string, string[]>}
13+
* @param {string} [rootDir]
14+
* @returns {Map<string, string[]>}
1315
*/
1416
loadAllTests(rootDir) {
15-
/** @type {Object.<string, string[]>} */
16-
const testSuite = {};
17-
18-
for (const language of this.getAllDirectories(rootDir)) {
19-
testSuite[language] = this.getAllFiles(path.join(rootDir, language));
20-
}
17+
rootDir = rootDir || LANGUAGES_DIR;
2118

22-
return testSuite;
19+
return new Map(this.getAllDirectories(rootDir).map(language => {
20+
return [language, this.getAllFiles(path.join(rootDir, language))];
21+
}));
2322
},
2423

2524
/**
2625
* Loads the list of available tests that match the given languages
2726
*
28-
* @param {string} rootDir
2927
* @param {string|string[]} languages
30-
* @returns {Object<string, string[]>}
28+
* @param {string} [rootDir]
29+
* @returns {Map<string, string[]>}
3130
*/
32-
loadSomeTests(rootDir, languages) {
33-
/** @type {Object.<string, string[]>} */
34-
const testSuite = {};
35-
36-
for (const language of this.getSomeDirectories(rootDir, languages)) {
37-
testSuite[language] = this.getAllFiles(path.join(rootDir, language));
38-
}
31+
loadSomeTests(languages, rootDir) {
32+
rootDir = rootDir || LANGUAGES_DIR;
3933

40-
return testSuite;
34+
return new Map(this.getSomeDirectories(rootDir, languages).map(language => {
35+
return [language, this.getAllFiles(path.join(rootDir, language))];
36+
}));
4137
},
4238

4339

‎tests/helper/util.js

+26-4
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ module.exports = {
1919
* Performs a breadth-first search on the given start element.
2020
*
2121
* @param {any} start
22-
* @param {(path: { key: string, value: any }[]) => void} callback
22+
* @param {(path: { key: string, value: any }[], obj: Record<string, any>) => void} callback
2323
*/
2424
BFS(start, callback) {
2525
const visited = new Set();
@@ -28,8 +28,6 @@ module.exports = {
2828
[{ key: null, value: start }]
2929
];
3030

31-
callback(toVisit[0]);
32-
3331
while (toVisit.length > 0) {
3432
/** @type {{ key: string, value: any }[][]} */
3533
const newToVisit = [];
@@ -43,7 +41,7 @@ module.exports = {
4341
const value = obj[key];
4442

4543
path.push({ key, value });
46-
callback(path);
44+
callback(path, obj);
4745

4846
if (Array.isArray(value) || Object.prototype.toString.call(value) == '[object Object]') {
4947
newToVisit.push([...path]);
@@ -58,6 +56,30 @@ module.exports = {
5856
}
5957
},
6058

59+
/**
60+
* Given the `BFS` path given to `BFS` callbacks, this will return the Prism language token path of the current
61+
* value (e.g. `Prism.languages.xml.tag.pattern`).
62+
*
63+
* @param {readonly{ key: string, value: any }[]} path
64+
* @param {string} [root]
65+
* @returns {string}
66+
*/
67+
BFSPathToPrismTokenPath(path, root = 'Prism.languages') {
68+
let tokenPath = root;
69+
for (const { key } of path) {
70+
if (!key) {
71+
// do nothing
72+
} else if (/^\d+$/.test(key)) {
73+
tokenPath += `[${key}]`;
74+
} else if (/^[a-z]\w*$/i.test(key)) {
75+
tokenPath += `.${key}`;
76+
} else {
77+
tokenPath += `[${JSON.stringify(key)}]`;
78+
}
79+
}
80+
return tokenPath;
81+
},
82+
6183
/**
6284
* Returns the AST of a given pattern.
6385
*

‎tests/pattern-tests.js

+5-27
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { assert } = require('chai');
55
const PrismLoader = require('./helper/prism-loader');
66
const TestDiscovery = require('./helper/test-discovery');
77
const TestCase = require('./helper/test-case');
8-
const { BFS, parseRegex } = require('./helper/util');
8+
const { BFS, BFSPathToPrismTokenPath, parseRegex } = require('./helper/util');
99
const { languages } = require('../components.json');
1010
const { visitRegExpAST } = require('regexpp');
1111
const { transform, combineTransformers, getIntersectionWordSets, JS, Words, NFA, Transformers } = require('refa');
@@ -19,16 +19,16 @@ const RAA = require('regexp-ast-analysis');
1919
* @type {Map<string, string[]>}
2020
*/
2121
const testSnippets = new Map();
22-
const testSuite = TestDiscovery.loadAllTests(__dirname + '/languages');
23-
for (const languageIdentifier in testSuite) {
22+
const testSuite = TestDiscovery.loadAllTests();
23+
for (const [languageIdentifier, files] of testSuite) {
2424
const lang = TestCase.parseLanguageNames(languageIdentifier).mainLanguage;
2525
let snippets = testSnippets.get(lang);
2626
if (snippets === undefined) {
2727
snippets = [];
2828
testSnippets.set(lang, snippets);
2929
}
3030

31-
for (const file of testSuite[languageIdentifier]) {
31+
for (const file of files) {
3232
snippets.push(TestCase.TestCaseFile.readFromFile(file).code);
3333
}
3434
}
@@ -90,27 +90,6 @@ function testPatterns(Prism, mainLanguage) {
9090
.filter(lang => lang in Prism.languages);
9191
}
9292

93-
/**
94-
* @param {string} root
95-
* @param {Parameters<Parameters<typeof BFS>[1]>[0]} path
96-
* @returns {string}
97-
*/
98-
function BFSPathToString(root, path) {
99-
let pathStr = root;
100-
for (const { key } of path) {
101-
if (!key) {
102-
// do nothing
103-
} else if (/^\d+$/.test(key)) {
104-
pathStr += `[${key}]`;
105-
} else if (/^[a-z]\w*$/i.test(key)) {
106-
pathStr += `.${key}`;
107-
} else {
108-
pathStr += `[${JSON.stringify(key)}]`;
109-
}
110-
}
111-
return pathStr;
112-
}
113-
11493
/**
11594
* Invokes the given function on every pattern in `Prism.languages`.
11695
*
@@ -146,10 +125,9 @@ function testPatterns(Prism, mainLanguage) {
146125

147126
BFS(root, path => {
148127
const { key, value } = path[path.length - 1];
128+
const tokenPath = BFSPathToPrismTokenPath(path, rootStr);
149129
visited.add(value);
150130

151-
const tokenPath = BFSPathToString(rootStr, path);
152-
153131
if (Object.prototype.toString.call(value) == '[object RegExp]') {
154132
try {
155133
let ast;

‎tests/run.js

+12-18
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,23 @@ const { argv } = require('yargs');
88

99
const testSuite =
1010
(argv.language)
11-
? TestDiscovery.loadSomeTests(__dirname + '/languages', argv.language)
11+
? TestDiscovery.loadSomeTests(argv.language)
1212
// load complete test suite
13-
: TestDiscovery.loadAllTests(__dirname + '/languages');
13+
: TestDiscovery.loadAllTests();
1414

1515
const update = !!argv.update;
1616

1717
// define tests for all tests in all languages in the test suite
18-
for (const language in testSuite) {
19-
if (!testSuite.hasOwnProperty(language)) {
20-
continue;
21-
}
18+
for (const [languageIdentifier, files] of testSuite) {
19+
describe("Testing language '" + languageIdentifier + "'", function () {
20+
this.timeout(10000);
2221

23-
(function (language, testFiles) {
24-
describe("Testing language '" + language + "'", function () {
25-
this.timeout(10000);
22+
for (const filePath of files) {
23+
const fileName = path.basename(filePath, path.extname(filePath));
2624

27-
for (const filePath of testFiles) {
28-
const fileName = path.basename(filePath, path.extname(filePath));
29-
30-
it("– should pass test case '" + fileName + "'", function () {
31-
TestCase.runTestCase(language, filePath, update ? 'update' : 'insert');
32-
});
33-
}
34-
});
35-
}(language, testSuite[language]));
25+
it("– should pass test case '" + fileName + "'", function () {
26+
TestCase.runTestCase(languageIdentifier, filePath, update ? 'update' : 'insert');
27+
});
28+
}
29+
});
3630
}

0 commit comments

Comments
 (0)
Please sign in to comment.