@descent-vtt/spec-guard 0.2.0 → 0.4.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 +150 -0
- package/README.md +262 -36
- package/dist/cli.d.ts +3 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +27 -3
- 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 +62 -40
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +236 -186
- package/dist/engine.js.map +1 -1
- package/dist/glob.d.ts +34 -6
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +50 -28
- 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 +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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 +67 -2
- package/dist/reporter.js.map +1 -1
- package/dist/runner.d.ts +23 -3
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +274 -73
- package/dist/runner.js.map +1 -1
- package/dist/scope.d.ts +108 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +115 -0
- package/dist/scope.js.map +1 -0
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -6
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
package/dist/engine.js
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Search engines.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* There is one set of semantics here, not two. The scanner reads files, counts
|
|
5
|
+
* matches, classifies comments, decides what is binary and keeps the ledger of
|
|
6
|
+
* what it could not inspect. ripgrep answers one question - which files under
|
|
7
|
+
* these targets contain this text at all - and everything downstream of that is
|
|
8
|
+
* the scanner's work, for both engines.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* It was not always so, and the reason for the rewrite is worth keeping: the
|
|
11
|
+
* two used to be independent implementations, and they drifted. On a tree with
|
|
12
|
+
* eight copies of one symbol the scanner found two and ripgrep found four,
|
|
13
|
+
* because one honoured .gitignore and the other did not, one skipped a
|
|
14
|
+
* hardcoded list of directory names and the other did not, and they disagreed
|
|
15
|
+
* about binary files. Nothing in the output said so. See ADR-0007.
|
|
11
16
|
*
|
|
12
17
|
* Both engines expose a batch API. Scanning a tree costs the same whether you
|
|
13
18
|
* look for one symbol or twenty, so assertions that share a target set and
|
|
14
|
-
* flags are answered by a single pass.
|
|
15
|
-
* eight assertions cost ~280ms as eight ripgrep passes and ~90ms as one.
|
|
19
|
+
* flags are answered by a single pass.
|
|
16
20
|
*/
|
|
17
21
|
import { spawn } from 'node:child_process';
|
|
18
22
|
import { promises as fs } from 'node:fs';
|
|
19
23
|
import path from 'node:path';
|
|
20
|
-
import {
|
|
24
|
+
import { createCommentMask } from './comments.js';
|
|
25
|
+
import { createExcludeMatcher, createGlobMatcher, toPosix, walkFiles } from './glob.js';
|
|
26
|
+
import { isBinary, LedgerBuilder, MAX_LEDGER_ENTRIES, UNCERTAIN_REASONS, } from './scope.js';
|
|
21
27
|
/** Files larger than this are skipped by both engines, keeping them in sync. */
|
|
22
28
|
export const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
|
23
29
|
/** Hard cap on collected locations; the reporter only ever shows a handful. */
|
|
@@ -31,35 +37,15 @@ function truncate(value) {
|
|
|
31
37
|
const trimmed = value.replace(/\r?\n$/, '');
|
|
32
38
|
return trimmed.length > MAX_SNIPPET_LENGTH ? `${trimmed.slice(0, MAX_SNIPPET_LENGTH)}…` : trimmed;
|
|
33
39
|
}
|
|
34
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Orders matches by path, then by line. ripgrep searches in parallel and emits
|
|
42
|
+
* files in no fixed order, so this is what makes snippet output stable.
|
|
43
|
+
*/
|
|
44
|
+
export function sortLocations(locations) {
|
|
35
45
|
return locations.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1));
|
|
36
46
|
}
|
|
37
47
|
function emptyTally() {
|
|
38
|
-
return { count: 0, locations: [] };
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* True when no two of these literals can ever match overlapping text, which is
|
|
42
|
-
* exactly when merging them into one ripgrep alternation is safe.
|
|
43
|
-
*
|
|
44
|
-
* Two ways an alternation loses a match that separate passes would find:
|
|
45
|
-
* containment - ["Primary", "PrimaryButton"] on "PrimaryButton"; and
|
|
46
|
-
* dovetailing - ["abc", "cd"] on "abcd", where the scan resumes past "cd".
|
|
47
|
-
* Rejecting both leaves batching indistinguishable from separate passes.
|
|
48
|
-
*/
|
|
49
|
-
export function canBatchLiterals(patterns) {
|
|
50
|
-
for (const a of patterns) {
|
|
51
|
-
for (const b of patterns) {
|
|
52
|
-
if (a === b)
|
|
53
|
-
continue;
|
|
54
|
-
if (a.includes(b) || b.includes(a))
|
|
55
|
-
return false;
|
|
56
|
-
for (let offset = 1; offset < a.length; offset++) {
|
|
57
|
-
if (b.startsWith(a.slice(offset)))
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return true;
|
|
48
|
+
return { count: 0, locations: [], commentCount: 0 };
|
|
63
49
|
}
|
|
64
50
|
/** Requests that may share one pass: same root, same targets, same options. */
|
|
65
51
|
function sharesOnePass(requests) {
|
|
@@ -75,6 +61,8 @@ function sharesOnePass(requests) {
|
|
|
75
61
|
request.options.ignoreCase === first.options.ignoreCase &&
|
|
76
62
|
request.options.globs.length === first.options.globs.length &&
|
|
77
63
|
request.options.globs.every((glob, index) => glob === first.options.globs[index]) &&
|
|
64
|
+
request.options.excludeGlobs.length === first.options.excludeGlobs.length &&
|
|
65
|
+
request.options.excludeGlobs.every((glob, index) => glob === first.options.excludeGlobs[index]) &&
|
|
78
66
|
request.options.excludeFiles === first.options.excludeFiles);
|
|
79
67
|
}
|
|
80
68
|
/** Runs requests through the batch API when the engine has one. */
|
|
@@ -110,19 +98,43 @@ export function findRipgrep() {
|
|
|
110
98
|
});
|
|
111
99
|
return ripgrepProbe;
|
|
112
100
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Builds the argv that makes ripgrep walk exactly what the scanner walks.
|
|
103
|
+
*
|
|
104
|
+
* Almost every flag here switches off an opinion. ripgrep's defaults are
|
|
105
|
+
* excellent for a developer grepping their own checkout and wrong for a rule
|
|
106
|
+
* about a repository:
|
|
107
|
+
*
|
|
108
|
+
* --hidden `.github`, `.husky` and `.claude-rules` hold real code, and
|
|
109
|
+
* leaving them out let an absence assertion pass while the
|
|
110
|
+
* forbidden thing sat in a workflow file.
|
|
111
|
+
* --no-ignore .gitignore says what git should carry, not what a rule
|
|
112
|
+
* covers - and it applies only inside a git repository, so the
|
|
113
|
+
* same tree answered differently depending on whether a .git
|
|
114
|
+
* directory happened to exist above it.
|
|
115
|
+
*
|
|
116
|
+
* What remains is spec-guard's own scope policy, passed as exclusions so that
|
|
117
|
+
* both engines skip the same four names for the same reasons. Those come last
|
|
118
|
+
* because ripgrep lets a later glob override an earlier one, and a policy skip
|
|
119
|
+
* must not be undone by a user's `glob="*.ts"`.
|
|
120
|
+
*/
|
|
123
121
|
export function buildRipgrepArgs(request, patterns = [request.symbol]) {
|
|
124
122
|
const { options } = request;
|
|
125
|
-
const args = [
|
|
123
|
+
const args = [
|
|
124
|
+
'--files-with-matches',
|
|
125
|
+
// The only separator a filename cannot contain.
|
|
126
|
+
'--null',
|
|
127
|
+
'--no-config',
|
|
128
|
+
'--hidden',
|
|
129
|
+
'--no-ignore',
|
|
130
|
+
// --text ripgrep treats a file named on the command line as text
|
|
131
|
+
// and a file it walked into as binary, so the same bytes
|
|
132
|
+
// were listed or not depending on how they were reached.
|
|
133
|
+
// The scanner decides what is binary, once, for both
|
|
134
|
+
// engines; ripgrep's job is only to say which files matched.
|
|
135
|
+
'--text',
|
|
136
|
+
`--max-filesize=${MAX_FILE_SIZE}`,
|
|
137
|
+
];
|
|
126
138
|
if (!options.regex)
|
|
127
139
|
args.push('--fixed-strings');
|
|
128
140
|
if (options.word)
|
|
@@ -131,15 +143,39 @@ export function buildRipgrepArgs(request, patterns = [request.symbol]) {
|
|
|
131
143
|
args.push('--ignore-case');
|
|
132
144
|
for (const glob of options.globs)
|
|
133
145
|
args.push('--glob', glob);
|
|
146
|
+
// ripgrep reads a leading "!" as an exclusion, with gitignore semantics that
|
|
147
|
+
// createExcludeMatcher mirrors for the JavaScript engine.
|
|
148
|
+
for (const glob of options.excludeGlobs)
|
|
149
|
+
args.push('--glob', `!${glob}`);
|
|
150
|
+
for (const name of options.scope.skippedDirectories.keys())
|
|
151
|
+
args.push('--glob', `!${name}/`);
|
|
134
152
|
for (const pattern of patterns)
|
|
135
153
|
args.push('--regexp', pattern);
|
|
136
154
|
args.push('--');
|
|
137
155
|
args.push(...(request.targets.length > 0 ? request.targets : ['.']));
|
|
138
156
|
return args;
|
|
139
157
|
}
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
158
|
+
/**
|
|
159
|
+
* ripgrep, used as a pre-filter rather than as a counter.
|
|
160
|
+
*
|
|
161
|
+
* The two engines used to be two implementations of the same semantics, and
|
|
162
|
+
* they drifted: ripgrep honoured .gitignore and the scanner did not, the
|
|
163
|
+
* scanner skipped a hardcoded list of directory names and ripgrep did not, and
|
|
164
|
+
* a binary file was searched by one and skipped by the other. Same tree, two
|
|
165
|
+
* answers, no warning.
|
|
166
|
+
*
|
|
167
|
+
* So ripgrep no longer decides anything. It answers one question - which files
|
|
168
|
+
* under these targets contain this text at all - and the scanner does the rest:
|
|
169
|
+
* counting, comment classification, binary handling, positions, the ledger.
|
|
170
|
+
* There is one implementation of the semantics, and ripgrep supplies the thing
|
|
171
|
+
* it is unmatched at, which is getting from thousands of files down to a
|
|
172
|
+
* handful very quickly.
|
|
173
|
+
*
|
|
174
|
+
* That also removes the batching problem. Attributing a match to the right
|
|
175
|
+
* pattern used to be delicate, because one ripgrep pass over several patterns
|
|
176
|
+
* cannot always say which one matched; as a pre-filter it does not need to,
|
|
177
|
+
* since including a file that turns out not to match is free.
|
|
178
|
+
*/
|
|
143
179
|
class RipgrepEngine {
|
|
144
180
|
binary;
|
|
145
181
|
name = 'ripgrep';
|
|
@@ -147,147 +183,83 @@ class RipgrepEngine {
|
|
|
147
183
|
this.binary = binary;
|
|
148
184
|
}
|
|
149
185
|
async search(request) {
|
|
150
|
-
const
|
|
151
|
-
return
|
|
186
|
+
const [result] = await this.searchBatch([request]);
|
|
187
|
+
return result;
|
|
152
188
|
}
|
|
153
189
|
async searchBatch(requests) {
|
|
154
190
|
const [first] = requests;
|
|
191
|
+
/* c8 ignore next -- runSearches never passes an empty list */
|
|
155
192
|
if (!first)
|
|
156
193
|
return [];
|
|
157
194
|
const patterns = [...new Set(requests.map((request) => request.symbol))];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return requests.map((request) => this.toResult(tallies.get(request.symbol)));
|
|
164
|
-
}
|
|
165
|
-
catch (error) {
|
|
166
|
-
if (!(error instanceof UnattributableBatch))
|
|
167
|
-
throw error;
|
|
168
|
-
return Promise.all(requests.map((request) => this.search(request)));
|
|
169
|
-
}
|
|
195
|
+
const { files, unreadable } = await this.filesWithMatches(first, patterns);
|
|
196
|
+
const results = await javascriptEngine.searchFiles(files, requests, unreadable);
|
|
197
|
+
// The result names ripgrep because ripgrep is what searched the tree; the
|
|
198
|
+
// scanner is a shared post-step, not a different engine.
|
|
199
|
+
return results.map((result) => ({ ...result, engine: this.name }));
|
|
170
200
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return { count: resolved.count, matches: sortLocations(resolved.locations), engine: this.name };
|
|
174
|
-
}
|
|
175
|
-
/** One ripgrep pass; returns a tally per pattern. */
|
|
176
|
-
run(request, patterns) {
|
|
201
|
+
/** Which files contain any of these patterns, and which could not be read. */
|
|
202
|
+
filesWithMatches(request, patterns) {
|
|
177
203
|
return new Promise((resolve, reject) => {
|
|
178
204
|
const child = spawn(this.binary, buildRipgrepArgs(request, patterns), {
|
|
179
205
|
cwd: request.root,
|
|
180
206
|
windowsHide: true,
|
|
181
207
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
182
208
|
});
|
|
183
|
-
|
|
184
|
-
let pending = '';
|
|
209
|
+
let stdout = '';
|
|
185
210
|
let stderr = '';
|
|
186
211
|
child.stdout.setEncoding('utf8');
|
|
187
|
-
child.stdout.on('data', (chunk) =>
|
|
188
|
-
pending += chunk;
|
|
189
|
-
let newline = pending.indexOf('\n');
|
|
190
|
-
while (newline !== -1) {
|
|
191
|
-
sink.line(pending.slice(0, newline));
|
|
192
|
-
pending = pending.slice(newline + 1);
|
|
193
|
-
newline = pending.indexOf('\n');
|
|
194
|
-
}
|
|
195
|
-
});
|
|
212
|
+
child.stdout.on('data', (chunk) => (stdout += chunk));
|
|
196
213
|
child.stderr.setEncoding('utf8');
|
|
197
|
-
child.stderr.on('data', (chunk) =>
|
|
198
|
-
stderr += chunk;
|
|
199
|
-
});
|
|
214
|
+
child.stderr.on('data', (chunk) => (stderr += chunk));
|
|
200
215
|
child.once('error', reject);
|
|
201
216
|
child.once('close', (code) => {
|
|
202
|
-
|
|
203
|
-
if (sink.failure) {
|
|
204
|
-
reject(sink.failure);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
// 0 = matches, 1 = no matches, 2 = an actual failure.
|
|
217
|
+
// 0 = matches, 1 = none, anything else is a real failure.
|
|
208
218
|
if (code !== 0 && code !== 1) {
|
|
209
219
|
reject(new Error(`ripgrep exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
210
220
|
return;
|
|
211
221
|
}
|
|
212
|
-
|
|
222
|
+
const files = [];
|
|
223
|
+
// --null separates paths with NUL, which is the only separator a file
|
|
224
|
+
// name cannot contain. Splitting on newlines loses files whose names
|
|
225
|
+
// contain one.
|
|
226
|
+
for (const entry of stdout.split('\0')) {
|
|
227
|
+
if (entry.length === 0)
|
|
228
|
+
continue;
|
|
229
|
+
const absolutePath = path.resolve(request.root, entry);
|
|
230
|
+
if (request.options.excludeFiles.has(absolutePath))
|
|
231
|
+
continue;
|
|
232
|
+
files.push({ absolutePath, relativePath: toPosix(path.relative(request.root, absolutePath)) });
|
|
233
|
+
}
|
|
234
|
+
files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
|
|
235
|
+
resolve({ files, unreadable: parseRipgrepErrors(stderr) });
|
|
213
236
|
});
|
|
214
237
|
});
|
|
215
238
|
}
|
|
216
239
|
}
|
|
217
240
|
/**
|
|
218
|
-
*
|
|
241
|
+
* Pulls the paths out of ripgrep's stderr.
|
|
219
242
|
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*/
|
|
225
|
-
export function shouldBatchPatterns(patterns, options) {
|
|
226
|
-
if (patterns.length <= 1)
|
|
227
|
-
return false;
|
|
228
|
-
if (options.regex || options.ignoreCase)
|
|
229
|
-
return false;
|
|
230
|
-
return canBatchLiterals(patterns);
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Parses ripgrep's newline-delimited JSON into per-pattern tallies.
|
|
243
|
+
* `--no-messages` used to be passed, which threw these away: a file ripgrep
|
|
244
|
+
* could not open produced no match, no error and no difference from a file that
|
|
245
|
+
* was searched and found clean. ripgrep writes one line per failure, as
|
|
246
|
+
* `path: reason`, and the path is what the report needs.
|
|
234
247
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* unattributable match - is trivial to exercise as data and nearly impossible
|
|
238
|
-
* to provoke from a real ripgrep on demand.
|
|
248
|
+
* Anything that does not parse is returned as-is rather than dropped, on the
|
|
249
|
+
* principle that an unexplained line from a subprocess is still information.
|
|
239
250
|
*/
|
|
240
|
-
export function
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
if (event.type !== 'match' || !event.data)
|
|
257
|
-
return;
|
|
258
|
-
const data = event.data;
|
|
259
|
-
const submatches = data.submatches ?? [];
|
|
260
|
-
if (submatches.length === 0)
|
|
261
|
-
return;
|
|
262
|
-
// ripgrep echoes the path as given ("./src/a.ts" when the target is
|
|
263
|
-
// "."); normalise so both engines report the same relative path.
|
|
264
|
-
const absolute = path.resolve(request.root, decodeText(data.path));
|
|
265
|
-
if (request.options.excludeFiles.has(absolute))
|
|
266
|
-
return;
|
|
267
|
-
const file = toPosix(path.relative(request.root, absolute));
|
|
268
|
-
const lineNumber = data.line_number ?? 0;
|
|
269
|
-
const text_ = truncate(decodeText(data.lines));
|
|
270
|
-
for (const submatch of submatches) {
|
|
271
|
-
// With --fixed-strings and no --ignore-case the matched text is the
|
|
272
|
-
// pattern verbatim, which is what makes attribution exact.
|
|
273
|
-
const pattern = single ?? decodeText(submatch.match);
|
|
274
|
-
const tally = tallies.get(pattern);
|
|
275
|
-
if (!tally) {
|
|
276
|
-
sink.failure = new UnattributableBatch(`ripgrep reported an unexpected match: ${pattern}`);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
tally.count += 1;
|
|
280
|
-
const last = tally.locations.at(-1);
|
|
281
|
-
if (last && last.file === file && last.line === lineNumber) {
|
|
282
|
-
last.count += 1;
|
|
283
|
-
}
|
|
284
|
-
else if (tally.locations.length < MAX_COLLECTED_MATCHES) {
|
|
285
|
-
tally.locations.push({ file, line: lineNumber, column: (submatch.start ?? 0) + 1, text: text_, count: 1 });
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
return sink;
|
|
251
|
+
export function parseRipgrepErrors(stderr) {
|
|
252
|
+
const paths = [];
|
|
253
|
+
for (const raw of stderr.split(/\r?\n/)) {
|
|
254
|
+
const line = raw.trim();
|
|
255
|
+
if (line.length === 0)
|
|
256
|
+
continue;
|
|
257
|
+
// "rg: " prefixes ripgrep's own diagnostics rather than a file's.
|
|
258
|
+
const body = line.startsWith('rg: ') ? line.slice(4) : line;
|
|
259
|
+
const separator = body.indexOf(': ');
|
|
260
|
+
paths.push(separator === -1 ? body : body.slice(0, separator));
|
|
261
|
+
}
|
|
262
|
+
return paths;
|
|
291
263
|
}
|
|
292
264
|
/* --------------------------------------------------------------- javascript */
|
|
293
265
|
/** Builds the matching RegExp shared by the JS engine. */
|
|
@@ -301,14 +273,6 @@ export function buildJsRegExp(symbol, options) {
|
|
|
301
273
|
const source = options.word ? `(?<![\\p{L}\\p{N}_])(?:${escaped})(?![\\p{L}\\p{N}_])` : escaped;
|
|
302
274
|
return new RegExp(source, `${flags}u`);
|
|
303
275
|
}
|
|
304
|
-
function isProbablyBinary(buffer) {
|
|
305
|
-
const limit = Math.min(buffer.length, 8192);
|
|
306
|
-
for (let index = 0; index < limit; index++) {
|
|
307
|
-
if (buffer[index] === 0)
|
|
308
|
-
return true;
|
|
309
|
-
}
|
|
310
|
-
return false;
|
|
311
|
-
}
|
|
312
276
|
class JavaScriptEngine {
|
|
313
277
|
name = 'javascript';
|
|
314
278
|
async search(request) {
|
|
@@ -324,20 +288,32 @@ class JavaScriptEngine {
|
|
|
324
288
|
const [first] = requests;
|
|
325
289
|
if (!first)
|
|
326
290
|
return [];
|
|
327
|
-
const
|
|
328
|
-
return this.searchFiles(files, requests);
|
|
291
|
+
const enumeration = await enumerateCandidates(first);
|
|
292
|
+
return this.searchFiles(enumeration.files, requests, [], enumeration.skipped);
|
|
329
293
|
}
|
|
330
|
-
/**
|
|
331
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Scans an already-enumerated file list.
|
|
296
|
+
*
|
|
297
|
+
* `unreadable` carries paths a caller already knows it could not open -
|
|
298
|
+
* ripgrep reports those on stderr, and they belong in the same ledger as the
|
|
299
|
+
* ones this scanner discovers for itself.
|
|
300
|
+
*/
|
|
301
|
+
async searchFiles(files, requests, unreadable = [], skipped = []) {
|
|
332
302
|
const [first] = requests;
|
|
333
303
|
if (!first)
|
|
334
304
|
return [];
|
|
305
|
+
const ledger = new LedgerBuilder();
|
|
306
|
+
for (const entry of skipped)
|
|
307
|
+
ledger.add(entry.path, entry.reason, entry.matches);
|
|
308
|
+
for (const entry of unreadable)
|
|
309
|
+
ledger.add(entry, 'unreadable');
|
|
335
310
|
const patterns = [...new Set(requests.map((request) => request.symbol))];
|
|
336
311
|
const regexps = new Map(patterns.map((pattern) => [pattern, buildJsRegExp(pattern, first.options)]));
|
|
337
312
|
const tallies = new Map(patterns.map((pattern) => [pattern, emptyTally()]));
|
|
338
313
|
const concurrency = Math.min(16, Math.max(1, files.length));
|
|
339
314
|
let cursor = 0;
|
|
340
315
|
const perFile = new Map();
|
|
316
|
+
let unclassifiedFiles = 0;
|
|
341
317
|
const worker = async () => {
|
|
342
318
|
while (cursor < files.length) {
|
|
343
319
|
const file = files[cursor++];
|
|
@@ -345,13 +321,41 @@ class JavaScriptEngine {
|
|
|
345
321
|
if (!file)
|
|
346
322
|
return;
|
|
347
323
|
const buffer = await fs.readFile(file.absolutePath).catch(() => null);
|
|
348
|
-
if (!buffer
|
|
324
|
+
if (!buffer) {
|
|
325
|
+
// A file we cannot open might hold anything, so it is recorded rather
|
|
326
|
+
// than passed over as though it had been read and found clean.
|
|
327
|
+
ledger.add(file.relativePath, 'unreadable');
|
|
349
328
|
continue;
|
|
329
|
+
}
|
|
350
330
|
const content = buffer.toString('utf8');
|
|
331
|
+
let mask;
|
|
332
|
+
const getMask = first.options.ignoreComments
|
|
333
|
+
? () => (mask ??= createCommentMask(content, file.relativePath))
|
|
334
|
+
: undefined;
|
|
351
335
|
const scanned = new Map();
|
|
352
336
|
for (const [pattern, regexp] of regexps) {
|
|
353
|
-
scanned.set(pattern, scanContent(content, file.relativePath, regexp));
|
|
337
|
+
scanned.set(pattern, scanContent(content, file.relativePath, regexp, getMask));
|
|
338
|
+
}
|
|
339
|
+
if (isBinary(buffer)) {
|
|
340
|
+
// Searched, but not counted. Skipping binary files silently was a way
|
|
341
|
+
// to pass an assertion by never looking; searching them and saying
|
|
342
|
+
// what was found leaves the decision with the reader.
|
|
343
|
+
//
|
|
344
|
+
// Only a binary file that *did* contain the symbol goes in the
|
|
345
|
+
// ledger. One that did not is not a gap - it was read, searched and
|
|
346
|
+
// found clean - and leaving it out is also what keeps the two engines
|
|
347
|
+
// reporting the same thing, since ripgrep only ever hands the scanner
|
|
348
|
+
// files that matched.
|
|
349
|
+
const found = [...scanned.values()].reduce((total, tally) => total + tally.count, 0);
|
|
350
|
+
if (found > 0)
|
|
351
|
+
ledger.add(file.relativePath, 'binary', found);
|
|
352
|
+
continue;
|
|
354
353
|
}
|
|
354
|
+
// A mask exists only if some pattern matched, since that is the only
|
|
355
|
+
// thing that calls getMask - so reaching here already means this file
|
|
356
|
+
// matched and its language was not understood.
|
|
357
|
+
if (mask && !mask.classified)
|
|
358
|
+
unclassifiedFiles += 1;
|
|
355
359
|
perFile.set(file.relativePath, scanned);
|
|
356
360
|
}
|
|
357
361
|
};
|
|
@@ -364,21 +368,26 @@ class JavaScriptEngine {
|
|
|
364
368
|
for (const [pattern, tally] of scanned) {
|
|
365
369
|
const total = tallies.get(pattern);
|
|
366
370
|
total.count += tally.count;
|
|
371
|
+
total.commentCount += tally.commentCount;
|
|
367
372
|
for (const location of tally.locations) {
|
|
368
373
|
if (total.locations.length < MAX_COLLECTED_MATCHES)
|
|
369
374
|
total.locations.push(location);
|
|
370
375
|
}
|
|
371
376
|
}
|
|
372
377
|
}
|
|
378
|
+
const scope = ledger.build();
|
|
373
379
|
return requests.map((request) => {
|
|
374
380
|
const tally = tallies.get(request.symbol) ?? emptyTally();
|
|
375
|
-
return {
|
|
381
|
+
return {
|
|
382
|
+
count: tally.count,
|
|
383
|
+
commentMatches: tally.commentCount,
|
|
384
|
+
unclassifiedFiles,
|
|
385
|
+
matches: tally.locations,
|
|
386
|
+
scope,
|
|
387
|
+
engine: this.name,
|
|
388
|
+
};
|
|
376
389
|
});
|
|
377
390
|
}
|
|
378
|
-
/** Every file a request should look at, sorted by relative path. */
|
|
379
|
-
async collectFiles(request) {
|
|
380
|
-
return (await enumerateCandidates(request)).files;
|
|
381
|
-
}
|
|
382
391
|
}
|
|
383
392
|
/**
|
|
384
393
|
* Lists the files a request would search, sorted by relative path.
|
|
@@ -390,7 +399,21 @@ class JavaScriptEngine {
|
|
|
390
399
|
*/
|
|
391
400
|
export async function enumerateCandidates(request, budget) {
|
|
392
401
|
const matcher = createGlobMatcher(request.options.globs);
|
|
402
|
+
const excluded = createExcludeMatcher(request.options.excludeGlobs);
|
|
403
|
+
const admits = (relativePath) => matcher(relativePath) && !excluded(relativePath);
|
|
393
404
|
const found = new Map();
|
|
405
|
+
const skipped = [];
|
|
406
|
+
const note = (relativePath, reason) => {
|
|
407
|
+
// Only gaps go in the ledger. `.git` and `node_modules` are configuration,
|
|
408
|
+
// not news: they are the same on every run, they are documented, and
|
|
409
|
+
// reporting them each time would bury the entries that do mean something.
|
|
410
|
+
// It also keeps the two engines' ledgers identical, since ripgrep is only
|
|
411
|
+
// ever asked about files it did not skip.
|
|
412
|
+
if (!UNCERTAIN_REASONS.has(reason))
|
|
413
|
+
return;
|
|
414
|
+
if (skipped.length < MAX_LEDGER_ENTRIES)
|
|
415
|
+
skipped.push({ path: relativePath, reason });
|
|
416
|
+
};
|
|
394
417
|
let bytes = 0;
|
|
395
418
|
let exceeded = false;
|
|
396
419
|
const admit = (absolutePath, relativePath, size) => {
|
|
@@ -409,17 +432,22 @@ export async function enumerateCandidates(request, budget) {
|
|
|
409
432
|
continue;
|
|
410
433
|
if (stats.isFile()) {
|
|
411
434
|
const relativePath = toPosix(path.relative(request.root, absoluteTarget));
|
|
412
|
-
if (stats.size <= MAX_FILE_SIZE &&
|
|
435
|
+
if (stats.size <= MAX_FILE_SIZE && admits(relativePath)) {
|
|
413
436
|
if (!admit(absoluteTarget, relativePath, stats.size))
|
|
414
437
|
break outer;
|
|
415
438
|
}
|
|
416
439
|
continue;
|
|
417
440
|
}
|
|
418
|
-
|
|
441
|
+
const prefix = toPosix(path.relative(request.root, absoluteTarget));
|
|
442
|
+
const walkOptions = {
|
|
443
|
+
scope: request.options.scope,
|
|
444
|
+
onSkip: (relativePath, reason) => note(prefix ? `${prefix}/${relativePath}` : relativePath, reason),
|
|
445
|
+
};
|
|
446
|
+
for await (const file of walkFiles(absoluteTarget, walkOptions)) {
|
|
419
447
|
if (file.size > MAX_FILE_SIZE)
|
|
420
448
|
continue;
|
|
421
449
|
const relativePath = toPosix(path.relative(request.root, file.absolutePath));
|
|
422
|
-
if (!
|
|
450
|
+
if (!admits(relativePath))
|
|
423
451
|
continue;
|
|
424
452
|
if (!admit(file.absolutePath, relativePath, file.size))
|
|
425
453
|
break outer;
|
|
@@ -428,12 +456,20 @@ export async function enumerateCandidates(request, budget) {
|
|
|
428
456
|
for (const excluded of request.options.excludeFiles)
|
|
429
457
|
found.delete(excluded);
|
|
430
458
|
const files = [...found.values()].sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
|
|
431
|
-
return { files, exceeded, bytes };
|
|
459
|
+
return { files, exceeded, bytes, skipped };
|
|
432
460
|
}
|
|
433
461
|
/** Counts matches in one file and records per-line snippets. */
|
|
434
|
-
export function scanContent(content, relativePath, regexp
|
|
462
|
+
export function scanContent(content, relativePath, regexp,
|
|
463
|
+
/**
|
|
464
|
+
* Resolved lazily, and only once the file has produced a match. Classifying
|
|
465
|
+
* every file in scope would mean a comment scan of the whole tree; this way
|
|
466
|
+
* the cost is proportional to matches, which for an architecture assertion is
|
|
467
|
+
* usually zero.
|
|
468
|
+
*/
|
|
469
|
+
getMask) {
|
|
435
470
|
regexp.lastIndex = 0;
|
|
436
471
|
let count = 0;
|
|
472
|
+
let commentCount = 0;
|
|
437
473
|
const byLine = new Map();
|
|
438
474
|
// Lazily built line index: only paid for when the file actually matches.
|
|
439
475
|
let lineStarts = null;
|
|
@@ -458,6 +494,12 @@ export function scanContent(content, relativePath, regexp) {
|
|
|
458
494
|
};
|
|
459
495
|
let match;
|
|
460
496
|
while ((match = regexp.exec(content)) !== null) {
|
|
497
|
+
if (getMask?.().isComment(match.index)) {
|
|
498
|
+
commentCount += 1;
|
|
499
|
+
if (match[0].length === 0)
|
|
500
|
+
regexp.lastIndex += 1;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
461
503
|
count += 1;
|
|
462
504
|
const { line, column } = lineOf(match.index);
|
|
463
505
|
const existing = byLine.get(line);
|
|
@@ -478,7 +520,11 @@ export function scanContent(content, relativePath, regexp) {
|
|
|
478
520
|
if (match[0].length === 0)
|
|
479
521
|
regexp.lastIndex += 1;
|
|
480
522
|
}
|
|
481
|
-
|
|
523
|
+
// No sort: exec scans forward, so lines are discovered in increasing order and
|
|
524
|
+
// a Map preserves insertion order. Sorting here was dead code - mutation
|
|
525
|
+
// testing found it by reporting that neither reversing nor removing the
|
|
526
|
+
// comparator changed any result.
|
|
527
|
+
return { count, locations: [...byLine.values()], commentCount };
|
|
482
528
|
}
|
|
483
529
|
export const javascriptEngine = new JavaScriptEngine();
|
|
484
530
|
/**
|
|
@@ -500,7 +546,9 @@ export const javascriptEngine = new JavaScriptEngine();
|
|
|
500
546
|
export const SMALL_TREE_BUDGET = process.platform === 'win32' ? { maxFiles: 512, maxBytes: 1024 * 1024 } : { maxFiles: 32, maxBytes: 64 * 1024 };
|
|
501
547
|
/** Spawn failures that mean "this binary is not installed", not "search failed". */
|
|
502
548
|
const MISSING_BINARY_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EINVAL', 'UNKNOWN']);
|
|
503
|
-
|
|
549
|
+
/** Exported so the code list can be asserted; EACCES and friends are not
|
|
550
|
+
* reproducible on demand from a real spawn. */
|
|
551
|
+
export function isMissingBinary(error) {
|
|
504
552
|
const code = error?.code;
|
|
505
553
|
return typeof code === 'string' && MISSING_BINARY_CODES.has(code);
|
|
506
554
|
}
|
|
@@ -528,6 +576,7 @@ function enumerationKey(request) {
|
|
|
528
576
|
request.root,
|
|
529
577
|
request.targets,
|
|
530
578
|
request.options.globs,
|
|
579
|
+
request.options.excludeGlobs,
|
|
531
580
|
excludeSetId(request.options.excludeFiles),
|
|
532
581
|
]);
|
|
533
582
|
}
|
|
@@ -614,6 +663,7 @@ export function createCachedEngine(engine) {
|
|
|
614
663
|
request.options.word,
|
|
615
664
|
request.options.ignoreCase,
|
|
616
665
|
request.options.globs,
|
|
666
|
+
request.options.excludeGlobs,
|
|
617
667
|
[...request.options.excludeFiles].sort(),
|
|
618
668
|
]);
|
|
619
669
|
const recordFallback = (error) => {
|