@descent-vtt/spec-guard 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +213 -27
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +22 -4
- package/dist/cli.js.map +1 -1
- package/dist/comments.d.ts +78 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +241 -0
- package/dist/comments.js.map +1 -0
- package/dist/engine.d.ts +97 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +365 -100
- package/dist/engine.js.map +1 -1
- package/dist/glob.d.ts +34 -0
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +47 -2
- package/dist/glob.js.map +1 -1
- package/dist/imports.d.ts +94 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +492 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +24 -2
- package/dist/parser.js.map +1 -1
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +37 -2
- package/dist/reporter.js.map +1 -1
- package/dist/runner.d.ts +12 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +168 -12
- package/dist/runner.js.map +1 -1
- package/dist/types.d.ts +34 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
package/dist/engine.js
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
18
|
import { promises as fs } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
|
-
import {
|
|
20
|
+
import { createCommentMask } from './comments.js';
|
|
21
|
+
import { createExcludeMatcher, createGlobMatcher, toPosix, walkFiles } from './glob.js';
|
|
21
22
|
/** Files larger than this are skipped by both engines, keeping them in sync. */
|
|
22
23
|
export const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
|
23
24
|
/** Hard cap on collected locations; the reporter only ever shows a handful. */
|
|
@@ -31,11 +32,31 @@ function truncate(value) {
|
|
|
31
32
|
const trimmed = value.replace(/\r?\n$/, '');
|
|
32
33
|
return trimmed.length > MAX_SNIPPET_LENGTH ? `${trimmed.slice(0, MAX_SNIPPET_LENGTH)}…` : trimmed;
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Converts ripgrep's byte column into a character column.
|
|
37
|
+
*
|
|
38
|
+
* ripgrep counts bytes; the scanner counts characters, and so does every editor
|
|
39
|
+
* that a reader will paste the location into. On an ASCII line the two agree,
|
|
40
|
+
* which is why the difference went unnoticed - it only appears once a line
|
|
41
|
+
* holds a non-ASCII character before the match, and then the two engines report
|
|
42
|
+
* different columns for the same match.
|
|
43
|
+
*/
|
|
44
|
+
export function byteColumnToCharacter(line, byteOffset) {
|
|
45
|
+
// One byte per character, so the offset is already a character count. This
|
|
46
|
+
// also covers the case where ripgrep gave us no line text to measure.
|
|
47
|
+
if (line.length === Buffer.byteLength(line))
|
|
48
|
+
return byteOffset + 1;
|
|
49
|
+
return Buffer.from(line, 'utf8').subarray(0, byteOffset).toString('utf8').length + 1;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Orders matches by path, then by line. ripgrep searches in parallel and emits
|
|
53
|
+
* files in no fixed order, so this is what makes snippet output stable.
|
|
54
|
+
*/
|
|
55
|
+
export function sortLocations(locations) {
|
|
35
56
|
return locations.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1));
|
|
36
57
|
}
|
|
37
58
|
function emptyTally() {
|
|
38
|
-
return { count: 0, locations: [] };
|
|
59
|
+
return { count: 0, locations: [], commentCount: 0 };
|
|
39
60
|
}
|
|
40
61
|
/**
|
|
41
62
|
* True when no two of these literals can ever match overlapping text, which is
|
|
@@ -75,6 +96,8 @@ function sharesOnePass(requests) {
|
|
|
75
96
|
request.options.ignoreCase === first.options.ignoreCase &&
|
|
76
97
|
request.options.globs.length === first.options.globs.length &&
|
|
77
98
|
request.options.globs.every((glob, index) => glob === first.options.globs[index]) &&
|
|
99
|
+
request.options.excludeGlobs.length === first.options.excludeGlobs.length &&
|
|
100
|
+
request.options.excludeGlobs.every((glob, index) => glob === first.options.excludeGlobs[index]) &&
|
|
78
101
|
request.options.excludeFiles === first.options.excludeFiles);
|
|
79
102
|
}
|
|
80
103
|
/** Runs requests through the batch API when the engine has one. */
|
|
@@ -131,6 +154,10 @@ export function buildRipgrepArgs(request, patterns = [request.symbol]) {
|
|
|
131
154
|
args.push('--ignore-case');
|
|
132
155
|
for (const glob of options.globs)
|
|
133
156
|
args.push('--glob', glob);
|
|
157
|
+
// ripgrep reads a leading "!" as an exclusion, with gitignore semantics that
|
|
158
|
+
// createExcludeMatcher mirrors for the JavaScript engine.
|
|
159
|
+
for (const glob of options.excludeGlobs)
|
|
160
|
+
args.push('--glob', `!${glob}`);
|
|
134
161
|
for (const pattern of patterns)
|
|
135
162
|
args.push('--regexp', pattern);
|
|
136
163
|
args.push('--');
|
|
@@ -147,6 +174,10 @@ class RipgrepEngine {
|
|
|
147
174
|
this.binary = binary;
|
|
148
175
|
}
|
|
149
176
|
async search(request) {
|
|
177
|
+
if (request.options.ignoreComments) {
|
|
178
|
+
const [result] = await this.commentAware(request, [request.symbol], [request]);
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
150
181
|
const tallies = await this.run(request, [request.symbol]);
|
|
151
182
|
return this.toResult(tallies.get(request.symbol));
|
|
152
183
|
}
|
|
@@ -155,14 +186,21 @@ class RipgrepEngine {
|
|
|
155
186
|
if (!first)
|
|
156
187
|
return [];
|
|
157
188
|
const patterns = [...new Set(requests.map((request) => request.symbol))];
|
|
158
|
-
|
|
159
|
-
|
|
189
|
+
// Comment-aware counting needs the file's text, which ripgrep's match
|
|
190
|
+
// stream does not carry. Rather than re-implement classification twice,
|
|
191
|
+
// ripgrep is used for what it is unmatched at - telling us which handful of
|
|
192
|
+
// files out of thousands contain the symbol at all - and those files are
|
|
193
|
+
// then counted by the scanner, which already knows about comments. Both
|
|
194
|
+
// engines therefore produce comment-aware counts through one code path.
|
|
195
|
+
if (first.options.ignoreComments) {
|
|
196
|
+
return this.commentAware(first, patterns, requests);
|
|
197
|
+
}
|
|
198
|
+
if (!shouldBatchPatterns(patterns, first.options)) {
|
|
160
199
|
return Promise.all(requests.map((request) => this.search(request)));
|
|
161
200
|
}
|
|
162
201
|
try {
|
|
163
202
|
const tallies = await this.run(first, patterns);
|
|
164
203
|
return requests.map((request) => this.toResult(tallies.get(request.symbol)));
|
|
165
|
-
/* c8 ignore next 4 */
|
|
166
204
|
}
|
|
167
205
|
catch (error) {
|
|
168
206
|
if (!(error instanceof UnattributableBatch))
|
|
@@ -170,9 +208,63 @@ class RipgrepEngine {
|
|
|
170
208
|
return Promise.all(requests.map((request) => this.search(request)));
|
|
171
209
|
}
|
|
172
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Two-phase search, used whenever comments must be classified.
|
|
213
|
+
*
|
|
214
|
+
* The result still names ripgrep: it did the searching, and classification is
|
|
215
|
+
* a post-step both engines share rather than a different engine.
|
|
216
|
+
*/
|
|
217
|
+
async commentAware(request, patterns, requests) {
|
|
218
|
+
const files = await this.filesWithMatches(request, patterns);
|
|
219
|
+
const results = await javascriptEngine.searchFiles(files, requests);
|
|
220
|
+
return results.map((result) => ({ ...result, engine: this.name }));
|
|
221
|
+
}
|
|
222
|
+
/** Phase one: which files contain any of these patterns at all. */
|
|
223
|
+
filesWithMatches(request, patterns) {
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
const args = buildRipgrepArgs(request, patterns).map((argument) => argument === '--json' ? '--files-with-matches' : argument);
|
|
226
|
+
const child = spawn(this.binary, args, {
|
|
227
|
+
cwd: request.root,
|
|
228
|
+
windowsHide: true,
|
|
229
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
230
|
+
});
|
|
231
|
+
let stdout = '';
|
|
232
|
+
let stderr = '';
|
|
233
|
+
child.stdout.setEncoding('utf8');
|
|
234
|
+
child.stdout.on('data', (chunk) => (stdout += chunk));
|
|
235
|
+
child.stderr.setEncoding('utf8');
|
|
236
|
+
child.stderr.on('data', (chunk) => (stderr += chunk));
|
|
237
|
+
child.once('error', reject);
|
|
238
|
+
child.once('close', (code) => {
|
|
239
|
+
// 0 = matches, 1 = none, anything else is a real failure.
|
|
240
|
+
if (code !== 0 && code !== 1) {
|
|
241
|
+
reject(new Error(`ripgrep exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const files = [];
|
|
245
|
+
for (const line of stdout.split('\n')) {
|
|
246
|
+
const trimmed = line.trim();
|
|
247
|
+
if (trimmed.length === 0)
|
|
248
|
+
continue;
|
|
249
|
+
const absolutePath = path.resolve(request.root, trimmed);
|
|
250
|
+
if (request.options.excludeFiles.has(absolutePath))
|
|
251
|
+
continue;
|
|
252
|
+
files.push({ absolutePath, relativePath: toPosix(path.relative(request.root, absolutePath)) });
|
|
253
|
+
}
|
|
254
|
+
files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
|
|
255
|
+
resolve(files);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
173
259
|
toResult(tally) {
|
|
174
260
|
const resolved = tally ?? emptyTally();
|
|
175
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
count: resolved.count,
|
|
263
|
+
commentMatches: resolved.commentCount,
|
|
264
|
+
unclassifiedFiles: 0,
|
|
265
|
+
matches: sortLocations(resolved.locations),
|
|
266
|
+
engine: this.name,
|
|
267
|
+
};
|
|
176
268
|
}
|
|
177
269
|
/** One ripgrep pass; returns a tally per pattern. */
|
|
178
270
|
run(request, patterns) {
|
|
@@ -182,62 +274,15 @@ class RipgrepEngine {
|
|
|
182
274
|
windowsHide: true,
|
|
183
275
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
184
276
|
});
|
|
185
|
-
const
|
|
186
|
-
const tallies = new Map(patterns.map((pattern) => [pattern, emptyTally()]));
|
|
277
|
+
const sink = createRipgrepSink(request, patterns);
|
|
187
278
|
let pending = '';
|
|
188
279
|
let stderr = '';
|
|
189
|
-
let failure = null;
|
|
190
|
-
const handleEvent = (line) => {
|
|
191
|
-
if (line.length === 0 || failure)
|
|
192
|
-
return;
|
|
193
|
-
let event;
|
|
194
|
-
try {
|
|
195
|
-
event = JSON.parse(line);
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
/* c8 ignore next -- ripgrep only ever emits valid JSON lines */
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
if (event.type !== 'match' || !event.data)
|
|
202
|
-
return;
|
|
203
|
-
const data = event.data;
|
|
204
|
-
const submatches = data.submatches ?? [];
|
|
205
|
-
if (submatches.length === 0)
|
|
206
|
-
return;
|
|
207
|
-
// ripgrep echoes the path as given ("./src/a.ts" when the target is
|
|
208
|
-
// "."); normalise so both engines report the same relative path.
|
|
209
|
-
const absolute = path.resolve(request.root, decodeText(data.path));
|
|
210
|
-
if (request.options.excludeFiles.has(absolute))
|
|
211
|
-
return;
|
|
212
|
-
const file = toPosix(path.relative(request.root, absolute));
|
|
213
|
-
const lineNumber = data.line_number ?? 0;
|
|
214
|
-
const text = truncate(decodeText(data.lines));
|
|
215
|
-
for (const submatch of submatches) {
|
|
216
|
-
// With --fixed-strings and no --ignore-case the matched text is the
|
|
217
|
-
// pattern verbatim, which is what makes attribution exact.
|
|
218
|
-
const pattern = single ?? decodeText(submatch.match);
|
|
219
|
-
const tally = tallies.get(pattern);
|
|
220
|
-
/* c8 ignore next 4 -- belt and braces: --fixed-strings guarantees a hit */
|
|
221
|
-
if (!tally) {
|
|
222
|
-
failure = new UnattributableBatch(`ripgrep reported an unexpected match: ${pattern}`);
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
tally.count += 1;
|
|
226
|
-
const last = tally.locations.at(-1);
|
|
227
|
-
if (last && last.file === file && last.line === lineNumber) {
|
|
228
|
-
last.count += 1;
|
|
229
|
-
}
|
|
230
|
-
else if (tally.locations.length < MAX_COLLECTED_MATCHES) {
|
|
231
|
-
tally.locations.push({ file, line: lineNumber, column: (submatch.start ?? 0) + 1, text, count: 1 });
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
280
|
child.stdout.setEncoding('utf8');
|
|
236
281
|
child.stdout.on('data', (chunk) => {
|
|
237
282
|
pending += chunk;
|
|
238
283
|
let newline = pending.indexOf('\n');
|
|
239
284
|
while (newline !== -1) {
|
|
240
|
-
|
|
285
|
+
sink.line(pending.slice(0, newline));
|
|
241
286
|
pending = pending.slice(newline + 1);
|
|
242
287
|
newline = pending.indexOf('\n');
|
|
243
288
|
}
|
|
@@ -248,9 +293,9 @@ class RipgrepEngine {
|
|
|
248
293
|
});
|
|
249
294
|
child.once('error', reject);
|
|
250
295
|
child.once('close', (code) => {
|
|
251
|
-
|
|
252
|
-
if (failure) {
|
|
253
|
-
reject(failure);
|
|
296
|
+
sink.line(pending);
|
|
297
|
+
if (sink.failure) {
|
|
298
|
+
reject(sink.failure);
|
|
254
299
|
return;
|
|
255
300
|
}
|
|
256
301
|
// 0 = matches, 1 = no matches, 2 = an actual failure.
|
|
@@ -258,11 +303,93 @@ class RipgrepEngine {
|
|
|
258
303
|
reject(new Error(`ripgrep exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
259
304
|
return;
|
|
260
305
|
}
|
|
261
|
-
resolve(tallies);
|
|
306
|
+
resolve(sink.tallies);
|
|
262
307
|
});
|
|
263
308
|
});
|
|
264
309
|
}
|
|
265
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Whether a set of patterns may share one ripgrep pass.
|
|
313
|
+
*
|
|
314
|
+
* Kept separate from the engine so the decision can be asserted directly: from
|
|
315
|
+
* the outside a correct batch and a correct set of separate passes are
|
|
316
|
+
* indistinguishable by design, which makes this the only place the rule is
|
|
317
|
+
* observable.
|
|
318
|
+
*/
|
|
319
|
+
export function shouldBatchPatterns(patterns, options) {
|
|
320
|
+
if (patterns.length <= 1)
|
|
321
|
+
return false;
|
|
322
|
+
if (options.regex || options.ignoreCase)
|
|
323
|
+
return false;
|
|
324
|
+
return canBatchLiterals(patterns);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Parses ripgrep's newline-delimited JSON into per-pattern tallies.
|
|
328
|
+
*
|
|
329
|
+
* This is deliberately pure and separate from the subprocess: every interesting
|
|
330
|
+
* case here - a base64 path, a match with no submatches, an excluded file, an
|
|
331
|
+
* unattributable match - is trivial to exercise as data and nearly impossible
|
|
332
|
+
* to provoke from a real ripgrep on demand.
|
|
333
|
+
*/
|
|
334
|
+
export function createRipgrepSink(request, patterns) {
|
|
335
|
+
const single = patterns.length === 1 ? patterns[0] : null;
|
|
336
|
+
const tallies = new Map(patterns.map((pattern) => [pattern, emptyTally()]));
|
|
337
|
+
const sink = {
|
|
338
|
+
tallies,
|
|
339
|
+
failure: null,
|
|
340
|
+
line(text) {
|
|
341
|
+
if (text.length === 0 || sink.failure)
|
|
342
|
+
return;
|
|
343
|
+
let event;
|
|
344
|
+
try {
|
|
345
|
+
event = JSON.parse(text);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (event.type !== 'match' || !event.data)
|
|
351
|
+
return;
|
|
352
|
+
const data = event.data;
|
|
353
|
+
const submatches = data.submatches ?? [];
|
|
354
|
+
if (submatches.length === 0)
|
|
355
|
+
return;
|
|
356
|
+
// ripgrep echoes the path as given ("./src/a.ts" when the target is
|
|
357
|
+
// "."); normalise so both engines report the same relative path.
|
|
358
|
+
const absolute = path.resolve(request.root, decodeText(data.path));
|
|
359
|
+
if (request.options.excludeFiles.has(absolute))
|
|
360
|
+
return;
|
|
361
|
+
const file = toPosix(path.relative(request.root, absolute));
|
|
362
|
+
const lineNumber = data.line_number ?? 0;
|
|
363
|
+
const lineText = decodeText(data.lines);
|
|
364
|
+
const text_ = truncate(lineText);
|
|
365
|
+
for (const submatch of submatches) {
|
|
366
|
+
// With --fixed-strings and no --ignore-case the matched text is the
|
|
367
|
+
// pattern verbatim, which is what makes attribution exact.
|
|
368
|
+
const pattern = single ?? decodeText(submatch.match);
|
|
369
|
+
const tally = tallies.get(pattern);
|
|
370
|
+
if (!tally) {
|
|
371
|
+
sink.failure = new UnattributableBatch(`ripgrep reported an unexpected match: ${pattern}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
tally.count += 1;
|
|
375
|
+
const last = tally.locations.at(-1);
|
|
376
|
+
if (last && last.file === file && last.line === lineNumber) {
|
|
377
|
+
last.count += 1;
|
|
378
|
+
}
|
|
379
|
+
else if (tally.locations.length < MAX_COLLECTED_MATCHES) {
|
|
380
|
+
tally.locations.push({
|
|
381
|
+
file,
|
|
382
|
+
line: lineNumber,
|
|
383
|
+
column: byteColumnToCharacter(lineText, submatch.start ?? 0),
|
|
384
|
+
text: text_,
|
|
385
|
+
count: 1,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
return sink;
|
|
392
|
+
}
|
|
266
393
|
/* --------------------------------------------------------------- javascript */
|
|
267
394
|
/** Builds the matching RegExp shared by the JS engine. */
|
|
268
395
|
export function buildJsRegExp(symbol, options) {
|
|
@@ -299,12 +426,20 @@ class JavaScriptEngine {
|
|
|
299
426
|
if (!first)
|
|
300
427
|
return [];
|
|
301
428
|
const files = await this.collectFiles(first);
|
|
429
|
+
return this.searchFiles(files, requests);
|
|
430
|
+
}
|
|
431
|
+
/** Scans an already-enumerated file list. */
|
|
432
|
+
async searchFiles(files, requests) {
|
|
433
|
+
const [first] = requests;
|
|
434
|
+
if (!first)
|
|
435
|
+
return [];
|
|
302
436
|
const patterns = [...new Set(requests.map((request) => request.symbol))];
|
|
303
437
|
const regexps = new Map(patterns.map((pattern) => [pattern, buildJsRegExp(pattern, first.options)]));
|
|
304
438
|
const tallies = new Map(patterns.map((pattern) => [pattern, emptyTally()]));
|
|
305
439
|
const concurrency = Math.min(16, Math.max(1, files.length));
|
|
306
440
|
let cursor = 0;
|
|
307
441
|
const perFile = new Map();
|
|
442
|
+
let unclassifiedFiles = 0;
|
|
308
443
|
const worker = async () => {
|
|
309
444
|
while (cursor < files.length) {
|
|
310
445
|
const file = files[cursor++];
|
|
@@ -315,10 +450,19 @@ class JavaScriptEngine {
|
|
|
315
450
|
if (!buffer || isProbablyBinary(buffer))
|
|
316
451
|
continue;
|
|
317
452
|
const content = buffer.toString('utf8');
|
|
453
|
+
let mask;
|
|
454
|
+
const getMask = first.options.ignoreComments
|
|
455
|
+
? () => (mask ??= createCommentMask(content, file.relativePath))
|
|
456
|
+
: undefined;
|
|
318
457
|
const scanned = new Map();
|
|
319
458
|
for (const [pattern, regexp] of regexps) {
|
|
320
|
-
scanned.set(pattern, scanContent(content, file.relativePath, regexp));
|
|
459
|
+
scanned.set(pattern, scanContent(content, file.relativePath, regexp, getMask));
|
|
321
460
|
}
|
|
461
|
+
// A mask exists only if some pattern matched, since that is the only
|
|
462
|
+
// thing that calls getMask - so reaching here already means this file
|
|
463
|
+
// matched and its language was not understood.
|
|
464
|
+
if (mask && !mask.classified)
|
|
465
|
+
unclassifiedFiles += 1;
|
|
322
466
|
perFile.set(file.relativePath, scanned);
|
|
323
467
|
}
|
|
324
468
|
};
|
|
@@ -331,6 +475,7 @@ class JavaScriptEngine {
|
|
|
331
475
|
for (const [pattern, tally] of scanned) {
|
|
332
476
|
const total = tallies.get(pattern);
|
|
333
477
|
total.count += tally.count;
|
|
478
|
+
total.commentCount += tally.commentCount;
|
|
334
479
|
for (const location of tally.locations) {
|
|
335
480
|
if (total.locations.length < MAX_COLLECTED_MATCHES)
|
|
336
481
|
total.locations.push(location);
|
|
@@ -339,43 +484,84 @@ class JavaScriptEngine {
|
|
|
339
484
|
}
|
|
340
485
|
return requests.map((request) => {
|
|
341
486
|
const tally = tallies.get(request.symbol) ?? emptyTally();
|
|
342
|
-
return {
|
|
487
|
+
return {
|
|
488
|
+
count: tally.count,
|
|
489
|
+
commentMatches: tally.commentCount,
|
|
490
|
+
unclassifiedFiles,
|
|
491
|
+
matches: tally.locations,
|
|
492
|
+
engine: this.name,
|
|
493
|
+
};
|
|
343
494
|
});
|
|
344
495
|
}
|
|
345
496
|
/** Every file a request should look at, sorted by relative path. */
|
|
346
497
|
async collectFiles(request) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
498
|
+
return (await enumerateCandidates(request)).files;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Lists the files a request would search, sorted by relative path.
|
|
503
|
+
*
|
|
504
|
+
* With a budget, the walk abandons as soon as the tree proves bigger than the
|
|
505
|
+
* budget allows. That makes it usable as a cheap probe: enumeration is stat-only
|
|
506
|
+
* work, so finding out a tree is "too big" costs a bounded number of stats
|
|
507
|
+
* rather than a full traversal.
|
|
508
|
+
*/
|
|
509
|
+
export async function enumerateCandidates(request, budget) {
|
|
510
|
+
const matcher = createGlobMatcher(request.options.globs);
|
|
511
|
+
const excluded = createExcludeMatcher(request.options.excludeGlobs);
|
|
512
|
+
const admits = (relativePath) => matcher(relativePath) && !excluded(relativePath);
|
|
513
|
+
const found = new Map();
|
|
514
|
+
let bytes = 0;
|
|
515
|
+
let exceeded = false;
|
|
516
|
+
const admit = (absolutePath, relativePath, size) => {
|
|
517
|
+
found.set(absolutePath, { absolutePath, relativePath });
|
|
518
|
+
bytes += size;
|
|
519
|
+
if (budget && (found.size > budget.maxFiles || bytes > budget.maxBytes)) {
|
|
520
|
+
exceeded = true;
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
};
|
|
525
|
+
outer: for (const target of request.targets.length > 0 ? request.targets : ['.']) {
|
|
526
|
+
const absoluteTarget = path.resolve(request.root, target);
|
|
527
|
+
const stats = await fs.stat(absoluteTarget).catch(() => null);
|
|
528
|
+
if (!stats)
|
|
529
|
+
continue;
|
|
530
|
+
if (stats.isFile()) {
|
|
531
|
+
const relativePath = toPosix(path.relative(request.root, absoluteTarget));
|
|
532
|
+
if (stats.size <= MAX_FILE_SIZE && admits(relativePath)) {
|
|
533
|
+
if (!admit(absoluteTarget, relativePath, stats.size))
|
|
534
|
+
break outer;
|
|
535
|
+
}
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
for await (const file of walkFiles(absoluteTarget)) {
|
|
539
|
+
if (file.size > MAX_FILE_SIZE)
|
|
353
540
|
continue;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (stats.size <= MAX_FILE_SIZE && matcher(relativePath)) {
|
|
357
|
-
found.set(absoluteTarget, { absolutePath: absoluteTarget, relativePath });
|
|
358
|
-
}
|
|
541
|
+
const relativePath = toPosix(path.relative(request.root, file.absolutePath));
|
|
542
|
+
if (!admits(relativePath))
|
|
359
543
|
continue;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (file.size > MAX_FILE_SIZE)
|
|
363
|
-
continue;
|
|
364
|
-
const relativePath = toPosix(path.relative(request.root, file.absolutePath));
|
|
365
|
-
if (!matcher(relativePath))
|
|
366
|
-
continue;
|
|
367
|
-
found.set(file.absolutePath, { absolutePath: file.absolutePath, relativePath });
|
|
368
|
-
}
|
|
544
|
+
if (!admit(file.absolutePath, relativePath, file.size))
|
|
545
|
+
break outer;
|
|
369
546
|
}
|
|
370
|
-
for (const excluded of request.options.excludeFiles)
|
|
371
|
-
found.delete(excluded);
|
|
372
|
-
return [...found.values()].sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
|
|
373
547
|
}
|
|
548
|
+
for (const excluded of request.options.excludeFiles)
|
|
549
|
+
found.delete(excluded);
|
|
550
|
+
const files = [...found.values()].sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
|
|
551
|
+
return { files, exceeded, bytes };
|
|
374
552
|
}
|
|
375
553
|
/** Counts matches in one file and records per-line snippets. */
|
|
376
|
-
export function scanContent(content, relativePath, regexp
|
|
554
|
+
export function scanContent(content, relativePath, regexp,
|
|
555
|
+
/**
|
|
556
|
+
* Resolved lazily, and only once the file has produced a match. Classifying
|
|
557
|
+
* every file in scope would mean a comment scan of the whole tree; this way
|
|
558
|
+
* the cost is proportional to matches, which for an architecture assertion is
|
|
559
|
+
* usually zero.
|
|
560
|
+
*/
|
|
561
|
+
getMask) {
|
|
377
562
|
regexp.lastIndex = 0;
|
|
378
563
|
let count = 0;
|
|
564
|
+
let commentCount = 0;
|
|
379
565
|
const byLine = new Map();
|
|
380
566
|
// Lazily built line index: only paid for when the file actually matches.
|
|
381
567
|
let lineStarts = null;
|
|
@@ -400,6 +586,12 @@ export function scanContent(content, relativePath, regexp) {
|
|
|
400
586
|
};
|
|
401
587
|
let match;
|
|
402
588
|
while ((match = regexp.exec(content)) !== null) {
|
|
589
|
+
if (getMask?.().isComment(match.index)) {
|
|
590
|
+
commentCount += 1;
|
|
591
|
+
if (match[0].length === 0)
|
|
592
|
+
regexp.lastIndex += 1;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
403
595
|
count += 1;
|
|
404
596
|
const { line, column } = lineOf(match.index);
|
|
405
597
|
const existing = byLine.get(line);
|
|
@@ -420,44 +612,116 @@ export function scanContent(content, relativePath, regexp) {
|
|
|
420
612
|
if (match[0].length === 0)
|
|
421
613
|
regexp.lastIndex += 1;
|
|
422
614
|
}
|
|
423
|
-
|
|
615
|
+
// No sort: exec scans forward, so lines are discovered in increasing order and
|
|
616
|
+
// a Map preserves insertion order. Sorting here was dead code - mutation
|
|
617
|
+
// testing found it by reporting that neither reversing nor removing the
|
|
618
|
+
// comparator changed any result.
|
|
619
|
+
return { count, locations: [...byLine.values()], commentCount };
|
|
424
620
|
}
|
|
425
621
|
export const javascriptEngine = new JavaScriptEngine();
|
|
622
|
+
/**
|
|
623
|
+
* How much scanning the JavaScript engine may do before ripgrep is worth a
|
|
624
|
+
* process spawn.
|
|
625
|
+
*
|
|
626
|
+
* Measured with scripts/bench-engines.mjs. ripgrep's cost is dominated by
|
|
627
|
+
* process startup and is nearly flat in tree size; the JavaScript scanner grows
|
|
628
|
+
* linearly. The crossover is therefore wherever a spawn costs, and that differs
|
|
629
|
+
* by an order of magnitude between platforms:
|
|
630
|
+
*
|
|
631
|
+
* Windows 11, Node 24, rg 15 spawn floor ~130ms crossover ~575 files
|
|
632
|
+
* Linux (container), Node 22, rg 13 spawn floor ~15ms crossover ~25 files
|
|
633
|
+
*
|
|
634
|
+
* The budget is set just below each crossover, so choosing JavaScript is never
|
|
635
|
+
* the slower option by more than a few milliseconds, while a small tree on
|
|
636
|
+
* Windows avoids a spawn that would cost ten times the whole search.
|
|
637
|
+
*/
|
|
638
|
+
export const SMALL_TREE_BUDGET = process.platform === 'win32' ? { maxFiles: 512, maxBytes: 1024 * 1024 } : { maxFiles: 32, maxBytes: 64 * 1024 };
|
|
426
639
|
/** Spawn failures that mean "this binary is not installed", not "search failed". */
|
|
427
640
|
const MISSING_BINARY_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EINVAL', 'UNKNOWN']);
|
|
428
|
-
|
|
641
|
+
/** Exported so the code list can be asserted; EACCES and friends are not
|
|
642
|
+
* reproducible on demand from a real spawn. */
|
|
643
|
+
export function isMissingBinary(error) {
|
|
429
644
|
const code = error?.code;
|
|
430
645
|
return typeof code === 'string' && MISSING_BINARY_CODES.has(code);
|
|
431
646
|
}
|
|
432
647
|
/**
|
|
433
|
-
*
|
|
648
|
+
* Identity tokens for exclude sets.
|
|
434
649
|
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
650
|
+
* The enumeration cache key has to account for `excludeFiles`, because
|
|
651
|
+
* enumeration applies it - two groups over the same targets but with different
|
|
652
|
+
* exclusions are different questions. Hashing the paths would be wasteful (a
|
|
653
|
+
* run excludes every spec file), and the runner passes one set for the whole
|
|
654
|
+
* run, so identity is both cheap and sufficient.
|
|
439
655
|
*/
|
|
440
|
-
|
|
441
|
-
|
|
656
|
+
const excludeSetIds = new WeakMap();
|
|
657
|
+
let nextExcludeSetId = 0;
|
|
658
|
+
function excludeSetId(excludeFiles) {
|
|
659
|
+
let id = excludeSetIds.get(excludeFiles);
|
|
660
|
+
if (id === undefined) {
|
|
661
|
+
id = nextExcludeSetId++;
|
|
662
|
+
excludeSetIds.set(excludeFiles, id);
|
|
663
|
+
}
|
|
664
|
+
return id;
|
|
665
|
+
}
|
|
666
|
+
function enumerationKey(request) {
|
|
667
|
+
return JSON.stringify([
|
|
668
|
+
request.root,
|
|
669
|
+
request.targets,
|
|
670
|
+
request.options.globs,
|
|
671
|
+
request.options.excludeGlobs,
|
|
672
|
+
excludeSetId(request.options.excludeFiles),
|
|
673
|
+
]);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Picks an engine per search group instead of once per run.
|
|
677
|
+
*
|
|
678
|
+
* The probe is the work: it enumerates the target set under a budget, which is
|
|
679
|
+
* stat-only. If the tree fits, the file list is already in hand and the
|
|
680
|
+
* JavaScript scan runs against it with no spawn and no second walk. If the walk
|
|
681
|
+
* abandons, the tree is big enough that ripgrep will win comfortably, and the
|
|
682
|
+
* abandoned enumeration cost a bounded number of stats.
|
|
683
|
+
*
|
|
684
|
+
* Deciding per group rather than per run matters because one spec can assert
|
|
685
|
+
* against `src/` and a single file in the same run.
|
|
686
|
+
*/
|
|
687
|
+
class AdaptiveEngine {
|
|
688
|
+
usedRipgrep = false;
|
|
689
|
+
ripgrepMissing = false;
|
|
442
690
|
ripgrep = new RipgrepEngine(process.env.SPEC_GUARD_RG || 'rg');
|
|
691
|
+
enumerations = new Map();
|
|
443
692
|
get name() {
|
|
444
|
-
return this.
|
|
693
|
+
return this.usedRipgrep ? 'ripgrep' : 'javascript';
|
|
445
694
|
}
|
|
446
695
|
async search(request) {
|
|
447
696
|
const [result] = await this.searchBatch([request]);
|
|
448
697
|
return result;
|
|
449
698
|
}
|
|
450
699
|
async searchBatch(requests) {
|
|
451
|
-
|
|
700
|
+
const [first] = requests;
|
|
701
|
+
if (!first)
|
|
702
|
+
return [];
|
|
703
|
+
if (!this.ripgrepMissing) {
|
|
704
|
+
const key = enumerationKey(first);
|
|
705
|
+
let probe = this.enumerations.get(key);
|
|
706
|
+
if (!probe) {
|
|
707
|
+
probe = enumerateCandidates(first, SMALL_TREE_BUDGET);
|
|
708
|
+
this.enumerations.set(key, probe);
|
|
709
|
+
}
|
|
710
|
+
const enumeration = await probe;
|
|
711
|
+
if (!enumeration.exceeded) {
|
|
712
|
+
// Small tree: the walk already produced the file list, so scanning it
|
|
713
|
+
// here costs less than starting a process.
|
|
714
|
+
return javascriptEngine.searchFiles(enumeration.files, requests);
|
|
715
|
+
}
|
|
452
716
|
try {
|
|
453
717
|
const results = await this.ripgrep.searchBatch(requests);
|
|
454
|
-
this.
|
|
718
|
+
this.usedRipgrep = true;
|
|
455
719
|
return results;
|
|
456
720
|
}
|
|
457
721
|
catch (error) {
|
|
458
722
|
if (!isMissingBinary(error))
|
|
459
723
|
throw error;
|
|
460
|
-
this.
|
|
724
|
+
this.ripgrepMissing = true;
|
|
461
725
|
}
|
|
462
726
|
}
|
|
463
727
|
return javascriptEngine.searchBatch(requests);
|
|
@@ -474,7 +738,7 @@ export async function resolveEngine(preference = 'auto') {
|
|
|
474
738
|
}
|
|
475
739
|
return new RipgrepEngine(binary);
|
|
476
740
|
}
|
|
477
|
-
return new
|
|
741
|
+
return new AdaptiveEngine();
|
|
478
742
|
}
|
|
479
743
|
/**
|
|
480
744
|
* Wraps an engine with a de-duplicating cache plus an automatic fallback to the
|
|
@@ -491,6 +755,7 @@ export function createCachedEngine(engine) {
|
|
|
491
755
|
request.options.word,
|
|
492
756
|
request.options.ignoreCase,
|
|
493
757
|
request.options.globs,
|
|
758
|
+
request.options.excludeGlobs,
|
|
494
759
|
[...request.options.excludeFiles].sort(),
|
|
495
760
|
]);
|
|
496
761
|
const recordFallback = (error) => {
|