cssgrep 1.2.0 → 1.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 +64 -1
- package/README.md +177 -4
- package/{index.js → cli.js} +552 -153
- package/completions/_cssgrep +9 -0
- package/completions/cssgrep.bash +2 -2
- package/completions/cssgrep.fish +9 -0
- package/index.d.ts +93 -0
- package/lib.js +419 -0
- package/man/cssgrep.1 +156 -10
- package/package.json +14 -10
package/{index.js → cli.js}
RENAMED
|
@@ -3,31 +3,47 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
const {
|
|
7
|
+
parse, offsetToPosition, lineTextAt, textOf, collapseWs, ancestor,
|
|
8
|
+
} = require('./lib.js');
|
|
9
|
+
|
|
10
|
+
// dom-serializer + js-beautify are only needed by -p, and cost ~13 ms of a
|
|
11
|
+
// ~43 ms startup (measured, ROADMAP Phase 11) — loaded on first use so the
|
|
12
|
+
// grepprg-style hot path never pays for them.
|
|
13
|
+
let render = null;
|
|
14
|
+
let beautify = null;
|
|
15
|
+
function loadPrettyPrinter() {
|
|
16
|
+
if (!render) {
|
|
17
|
+
render = require('dom-serializer').default;
|
|
18
|
+
beautify = require('js-beautify').html;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
// Single source of truth for the version. A constant rather than a read of
|
|
12
23
|
// package.json, so it survives compilation into a standalone binary (Bun
|
|
13
24
|
// --compile / Node SEA), where package.json won't sit next to the executable.
|
|
14
25
|
// Keep in sync with package.json on release.
|
|
15
|
-
const VERSION = '1.
|
|
26
|
+
const VERSION = '1.4.0';
|
|
16
27
|
|
|
17
28
|
const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
|
|
18
29
|
|
|
19
30
|
Usage:
|
|
20
31
|
cssgrep <selector> [file ...]
|
|
21
32
|
cssgrep <selector> -r <dir ...>
|
|
33
|
+
cssgrep -e '[label=]<sel>' [-e ...] [file ...]
|
|
22
34
|
cat file.html | cssgrep <selector>
|
|
23
35
|
|
|
24
|
-
Output (
|
|
36
|
+
Output (each matching line once, like grep; with -n, one record per match):
|
|
25
37
|
{line contents} (default; stdin or single file)
|
|
26
38
|
{file}:{line contents} (default; multiple files)
|
|
27
39
|
{line}:{col} {line contents} (with -n; stdin or single file)
|
|
28
40
|
{file}:{line}:{col} {line contents} (with -n; multiple files)
|
|
29
41
|
|
|
30
42
|
Options:
|
|
43
|
+
-e, --selector <[label=]sel> Add a selector (repeatable). Matches from all
|
|
44
|
+
-e selectors merge in document order; each is tagged
|
|
45
|
+
[label] (default label: the selector text itself).
|
|
46
|
+
With -e, every positional argument is a file path.
|
|
31
47
|
-r, --recursive Recurse into directory arguments.
|
|
32
48
|
--max-depth <n> Limit -r recursion depth (1 = the given dir only).
|
|
33
49
|
--ext <list> Comma-separated extensions for -r (default: html,htm).
|
|
@@ -41,7 +57,8 @@ Options:
|
|
|
41
57
|
-p, --print Pretty-print the matched node's HTML above its location.
|
|
42
58
|
--attr <name> Print the value of attribute <name> (skips nodes without it).
|
|
43
59
|
--text Print the matched node's text content (whitespace collapsed).
|
|
44
|
-
--json Print one JSON record per match (NDJSON: file,line,col,
|
|
60
|
+
--json Print one JSON record per match (NDJSON: file,line,col,
|
|
61
|
+
html,text; plus label with -e).
|
|
45
62
|
--parent <n> Report the n-th ancestor of each match instead (dedup'd).
|
|
46
63
|
-w, --max-width <n> Truncate the shown line to <n> columns (ellipsis added).
|
|
47
64
|
-A, --after-context <n> Print <n> source lines after each match.
|
|
@@ -60,9 +77,31 @@ Options:
|
|
|
60
77
|
--no-filename Never print the file name prefix (even for many files).
|
|
61
78
|
--color[=<when>] Colorize output: auto (default, also what a bare
|
|
62
79
|
--color means, like grep), always or never.
|
|
80
|
+
--watch Re-run the search whenever a watched file changes
|
|
81
|
+
(requires paths; excludes -q and the rewrite ops).
|
|
82
|
+
On a TTY the screen is cleared and results reprinted;
|
|
83
|
+
piped output appends each run after a == HH:MM:SS ==
|
|
84
|
+
separator; with --json each run emits an NDJSON
|
|
85
|
+
{"event":"run",...} record followed by the matches.
|
|
86
|
+
Exit with Ctrl-C (status 0).
|
|
87
|
+
--no-clear With --watch on a TTY: append instead of clearing.
|
|
63
88
|
-h, --help Show this help.
|
|
64
89
|
-V, --version Show version and exit.
|
|
65
90
|
|
|
91
|
+
Rewrite (a separate mode: excludes -n/-p/--attr/--text/--json, -c/-l/-L/-q,
|
|
92
|
+
-A/-B/-C, -m/-M, -w, -0 and -e; composes with --parent):
|
|
93
|
+
--add-class <c> Add a class to each matched element.
|
|
94
|
+
--remove-class <c> Remove a class (attribute dropped when emptied).
|
|
95
|
+
--set-attr <k=v> Set attribute k to v (added if missing).
|
|
96
|
+
--remove-attr <k> Remove attribute k.
|
|
97
|
+
--rename-tag <t> Rename the element (and its closing tag, if present).
|
|
98
|
+
--diff Emit a unified diff instead of the document; required
|
|
99
|
+
for multiple files. Apply with git apply / patch.
|
|
100
|
+
|
|
101
|
+
A single input prints the rewritten document to stdout. Only the matched tags'
|
|
102
|
+
bytes change; ops compose as rename -> remove-attr -> set-attr -> remove-class
|
|
103
|
+
-> add-class regardless of argument order. Exit: 0 edits, 1 none, 2 error.
|
|
104
|
+
|
|
66
105
|
Short flags combine (-rn) and a value attaches to its flag (-w100) or follows it
|
|
67
106
|
(-w 100); a value-taking flag may close a cluster (-rnw100). Long options take a
|
|
68
107
|
value with = or as the next word (--max-width=100, --ext htm).
|
|
@@ -77,13 +116,22 @@ function fail(msg) {
|
|
|
77
116
|
process.exit(2);
|
|
78
117
|
}
|
|
79
118
|
|
|
119
|
+
// grep's most predictable stumble: there is no invert-match here, because CSS
|
|
120
|
+
// expresses inversion in the selector itself. Teach instead of just rejecting.
|
|
121
|
+
function failInvert(flag) {
|
|
122
|
+
fail(`${flag}: there is no invert-match — CSS expresses inversion in the ` +
|
|
123
|
+
`selector, e.g. 'img:not([alt])' or 'div:not(:has(a))'; see man cssgrep`);
|
|
124
|
+
}
|
|
125
|
+
|
|
80
126
|
// ANSI SGR codes matching grep's default scheme: bold-red match, magenta
|
|
81
|
-
// filename, green line/col numbers, cyan separators.
|
|
127
|
+
// filename, green line/col numbers, cyan separators. The [label] tag from -e
|
|
128
|
+
// has no grep counterpart; yellow keeps it distinct from all of the above.
|
|
82
129
|
const COLORS = {
|
|
83
130
|
match: '1;31',
|
|
84
131
|
file: '35',
|
|
85
132
|
line: '32',
|
|
86
133
|
sep: '36',
|
|
134
|
+
label: '33',
|
|
87
135
|
};
|
|
88
136
|
|
|
89
137
|
function paint(code, str) {
|
|
@@ -93,6 +141,7 @@ function paint(code, str) {
|
|
|
93
141
|
function parseArgs(argv) {
|
|
94
142
|
const opts = {
|
|
95
143
|
selector: null,
|
|
144
|
+
selectors: [],
|
|
96
145
|
positionals: [],
|
|
97
146
|
paths: [],
|
|
98
147
|
recursive: false,
|
|
@@ -122,6 +171,10 @@ function parseArgs(argv) {
|
|
|
122
171
|
noFilename: false,
|
|
123
172
|
noMessages: false,
|
|
124
173
|
color: 'auto',
|
|
174
|
+
rewrite: { renameTag: null, removeAttr: [], setAttr: {}, removeClass: [], addClass: [] },
|
|
175
|
+
diff: false,
|
|
176
|
+
watch: false,
|
|
177
|
+
noClear: false,
|
|
125
178
|
};
|
|
126
179
|
const setExts = v => {
|
|
127
180
|
opts.extGiven = true;
|
|
@@ -141,6 +194,16 @@ function parseArgs(argv) {
|
|
|
141
194
|
const setAfter = v => { opts.after = boundedInt(v, '--after-context', 0); };
|
|
142
195
|
const setBefore = v => { opts.before = boundedInt(v, '--before-context', 0); };
|
|
143
196
|
const setContext = v => { opts.after = opts.before = boundedInt(v, '--context', 0); };
|
|
197
|
+
// -e [label=]<selector>. A `=` outside brackets never begins a *working*
|
|
198
|
+
// selector (css-what tokenizes `a=b` as an unmatchable tag named `=b`), so a
|
|
199
|
+
// leading identifier + `=` is unambiguously a label. Unlabeled selectors are
|
|
200
|
+
// tagged with their own text, so [label] and the --json `label` field are
|
|
201
|
+
// always present when -e is used.
|
|
202
|
+
const addSelector = v => {
|
|
203
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*)=([\s\S]+)$/.exec(v);
|
|
204
|
+
if (m) opts.selectors.push({ label: m[1], selector: m[2] });
|
|
205
|
+
else opts.selectors.push({ label: v, selector: v });
|
|
206
|
+
};
|
|
144
207
|
const addIgnore = v => { const c = compileIgnore(v); if (c) opts.ignore.push(c); };
|
|
145
208
|
const addInclude = v => { const c = compileIgnore(v); if (c) opts.include.push(c); };
|
|
146
209
|
const addIgnoreFile = v => {
|
|
@@ -152,7 +215,7 @@ function parseArgs(argv) {
|
|
|
152
215
|
// Short flags that take a value (rest of the cluster, or the next argument).
|
|
153
216
|
const shortValueFlags = {
|
|
154
217
|
w: setMaxWidth, m: setMaxCount, M: setMaxTotal, A: setAfter, B: setBefore, C: setContext,
|
|
155
|
-
i: addIgnore,
|
|
218
|
+
i: addIgnore, e: addSelector,
|
|
156
219
|
};
|
|
157
220
|
for (let i = 0; i < argv.length; i++) {
|
|
158
221
|
const a = argv[i];
|
|
@@ -201,6 +264,23 @@ function parseArgs(argv) {
|
|
|
201
264
|
case '--before-context': setBefore(value()); break;
|
|
202
265
|
case '--context': setContext(value()); break;
|
|
203
266
|
case '--color': case '--colour': opts.color = inline != null ? inline : 'auto'; break;
|
|
267
|
+
case '--selector': addSelector(value()); break;
|
|
268
|
+
case '--add-class': opts.rewrite.addClass.push(value()); break;
|
|
269
|
+
case '--remove-class': opts.rewrite.removeClass.push(value()); break;
|
|
270
|
+
case '--remove-attr': opts.rewrite.removeAttr.push(value()); break;
|
|
271
|
+
case '--set-attr': {
|
|
272
|
+
const v = value();
|
|
273
|
+
const eq = v.indexOf('=');
|
|
274
|
+
const k = eq === -1 ? v : v.slice(0, eq);
|
|
275
|
+
if (!k) fail('--set-attr requires a name (name=value)');
|
|
276
|
+
opts.rewrite.setAttr[k] = eq === -1 ? '' : v.slice(eq + 1);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
case '--rename-tag': opts.rewrite.renameTag = value(); break;
|
|
280
|
+
case '--diff': opts.diff = true; break;
|
|
281
|
+
case '--watch': opts.watch = true; break;
|
|
282
|
+
case '--no-clear': opts.noClear = true; break;
|
|
283
|
+
case '--invert-match': failInvert(name); break;
|
|
204
284
|
default: fail(`unknown option: ${name}`);
|
|
205
285
|
}
|
|
206
286
|
continue;
|
|
@@ -232,6 +312,7 @@ function parseArgs(argv) {
|
|
|
232
312
|
case '0': case 'Z': opts.nul = true; break;
|
|
233
313
|
case 'H': opts.withFilename = true; break;
|
|
234
314
|
case 's': opts.noMessages = true; break;
|
|
315
|
+
case 'v': failInvert('-v'); break;
|
|
235
316
|
default: fail(`unknown option: -${ch}`);
|
|
236
317
|
}
|
|
237
318
|
}
|
|
@@ -240,7 +321,9 @@ function parseArgs(argv) {
|
|
|
240
321
|
|
|
241
322
|
opts.positionals.push(a);
|
|
242
323
|
}
|
|
243
|
-
if (opts.positionals.length === 0
|
|
324
|
+
if (opts.positionals.length === 0 && !opts.selectors.length) {
|
|
325
|
+
fail('no selector given (try --help)');
|
|
326
|
+
}
|
|
244
327
|
// Aggregate modes each suppress per-match output, so at most one may apply.
|
|
245
328
|
const aggregates = [opts.count, opts.filesWithMatches, opts.filesWithoutMatch, opts.quiet]
|
|
246
329
|
.filter(Boolean).length;
|
|
@@ -260,6 +343,34 @@ function parseArgs(argv) {
|
|
|
260
343
|
}
|
|
261
344
|
if (opts.lineNumber && opts.count) fail('-n cannot be combined with -c');
|
|
262
345
|
if (opts.lineNumber && opts.print) fail('-n cannot be combined with -p');
|
|
346
|
+
// Rewrite is its own program mode, not a fourth output axis: it emits a
|
|
347
|
+
// document (or a diff), so everything that shapes per-match output is
|
|
348
|
+
// meaningless with it.
|
|
349
|
+
const r = opts.rewrite;
|
|
350
|
+
opts.rewriteActive = Boolean(r.renameTag) || r.removeAttr.length > 0
|
|
351
|
+
|| r.removeClass.length > 0 || r.addClass.length > 0
|
|
352
|
+
|| Object.keys(r.setAttr).length > 0;
|
|
353
|
+
if (opts.diff && !opts.rewriteActive) fail('--diff requires a rewrite operation');
|
|
354
|
+
if (opts.rewriteActive) {
|
|
355
|
+
if (printModes > 0) fail('rewrite operations cannot be combined with -p/--attr/--text/--json');
|
|
356
|
+
if (aggregates > 0) fail('rewrite operations cannot be combined with -c/-l/-L/-q');
|
|
357
|
+
if (opts.before > 0 || opts.after > 0) fail('rewrite operations cannot be combined with -A/-B/-C');
|
|
358
|
+
if (opts.lineNumber) fail('rewrite operations cannot be combined with -n');
|
|
359
|
+
if (opts.maxWidth) fail('rewrite operations cannot be combined with -w');
|
|
360
|
+
if (opts.nul) fail('rewrite operations cannot be combined with -0');
|
|
361
|
+
if (opts.maxCount || opts.maxTotal) fail('rewrite operations cannot be combined with -m/-M');
|
|
362
|
+
if (opts.selectors.length) {
|
|
363
|
+
fail('rewrite takes a single positional selector (use a selector list like "a, b" instead of -e)');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Watch re-runs the search on change; modes that end the run early or edit
|
|
367
|
+
// files make no sense against it.
|
|
368
|
+
if (opts.noClear && !opts.watch) fail('--no-clear requires --watch');
|
|
369
|
+
if (opts.watch) {
|
|
370
|
+
if (opts.quiet) fail('--watch cannot be combined with -q');
|
|
371
|
+
if (opts.rewriteActive) fail('--watch cannot be combined with rewrite operations');
|
|
372
|
+
if (opts.noClear && opts.json) fail('--no-clear is meaningless with --json (it never clears)');
|
|
373
|
+
}
|
|
263
374
|
if (!['auto', 'always', 'never'].includes(opts.color)) {
|
|
264
375
|
fail(`invalid --color value: ${opts.color} (expected auto, always or never)`);
|
|
265
376
|
}
|
|
@@ -279,6 +390,13 @@ function parseArgs(argv) {
|
|
|
279
390
|
// `grepprg`, which appends args in its own order). Fall back to
|
|
280
391
|
// "selector is the first positional" when the split is unclear.
|
|
281
392
|
function resolveSelectorAndPaths(opts) {
|
|
393
|
+
// With -e the selectors are explicit, so — like grep -e — every positional
|
|
394
|
+
// is a file path; a mistyped one is reported as unreadable, not re-guessed
|
|
395
|
+
// as a selector.
|
|
396
|
+
if (opts.selectors.length) {
|
|
397
|
+
opts.paths = opts.positionals;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
282
400
|
const pos = opts.positionals;
|
|
283
401
|
const onDisk = [];
|
|
284
402
|
const notOnDisk = [];
|
|
@@ -297,53 +415,6 @@ function resolveSelectorAndPaths(opts) {
|
|
|
297
415
|
}
|
|
298
416
|
}
|
|
299
417
|
|
|
300
|
-
// Precompute the byte offset at which each line starts, so offset->line/col
|
|
301
|
-
// is a binary search rather than a re-scan per match.
|
|
302
|
-
function lineIndex(src) {
|
|
303
|
-
const starts = [0];
|
|
304
|
-
for (let i = 0; i < src.length; i++) {
|
|
305
|
-
if (src.charCodeAt(i) === 10 /* \n */) starts.push(i + 1);
|
|
306
|
-
}
|
|
307
|
-
return starts;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function offsetToPosition(starts, src, offset) {
|
|
311
|
-
// binary search for the greatest line start <= offset
|
|
312
|
-
let lo = 0, hi = starts.length - 1;
|
|
313
|
-
while (lo < hi) {
|
|
314
|
-
const mid = (lo + hi + 1) >> 1;
|
|
315
|
-
if (starts[mid] <= offset) lo = mid;
|
|
316
|
-
else hi = mid - 1;
|
|
317
|
-
}
|
|
318
|
-
const lineStart = starts[lo];
|
|
319
|
-
let lineEnd = src.indexOf('\n', lineStart);
|
|
320
|
-
if (lineEnd === -1) lineEnd = src.length;
|
|
321
|
-
// strip a trailing \r so CRLF files render cleanly
|
|
322
|
-
let text = src.slice(lineStart, lineEnd);
|
|
323
|
-
if (text.endsWith('\r')) text = text.slice(0, -1);
|
|
324
|
-
return {
|
|
325
|
-
line: lo + 1, // 1-based
|
|
326
|
-
// col in UTF-16 code units: a JS string index into `text`, used by the
|
|
327
|
-
// highlight math. bcol in bytes: what gets printed — vim's grepformat %c
|
|
328
|
-
// and terminals count bytes, so non-ASCII text before the match would
|
|
329
|
-
// otherwise land the cursor short.
|
|
330
|
-
col: offset - lineStart + 1, // 1-based
|
|
331
|
-
bcol: Buffer.byteLength(src.slice(lineStart, offset), 'utf8') + 1, // 1-based
|
|
332
|
-
text,
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Text of a 1-based line number, with the trailing \r stripped (CRLF), plus the
|
|
337
|
-
// line's byte start (so a node's offset maps to a column within it).
|
|
338
|
-
function lineTextAt(starts, src, lineNo) {
|
|
339
|
-
const lineStart = starts[lineNo - 1];
|
|
340
|
-
let lineEnd = src.indexOf('\n', lineStart);
|
|
341
|
-
if (lineEnd === -1) lineEnd = src.length;
|
|
342
|
-
let text = src.slice(lineStart, lineEnd);
|
|
343
|
-
if (text.endsWith('\r')) text = text.slice(0, -1);
|
|
344
|
-
return { lineStart, text };
|
|
345
|
-
}
|
|
346
|
-
|
|
347
418
|
// Never cut between the halves of a surrogate pair: a lone half is invalid
|
|
348
419
|
// Unicode and serializes as U+FFFD garbage. Backing off one unit loses at most
|
|
349
420
|
// one display column.
|
|
@@ -377,43 +448,6 @@ function renderText(pos, off, nodeEnd, opts) {
|
|
|
377
448
|
return vis.slice(0, s) + paint(COLORS.match, vis.slice(s, e)) + vis.slice(e);
|
|
378
449
|
}
|
|
379
450
|
|
|
380
|
-
// Concatenate the text of a node and all its descendants (dependency-free,
|
|
381
|
-
// rather than pulling in domutils). Used by --text.
|
|
382
|
-
function textOf(node) {
|
|
383
|
-
if (node.type === 'text') return node.data || '';
|
|
384
|
-
if (!node.children) return '';
|
|
385
|
-
let s = '';
|
|
386
|
-
for (const child of node.children) s += textOf(child);
|
|
387
|
-
return s;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const collapseWs = s => s.replace(/\s+/g, ' ').trim();
|
|
391
|
-
|
|
392
|
-
const isElement = n => n && (n.type === 'tag' || n.type === 'script' || n.type === 'style');
|
|
393
|
-
|
|
394
|
-
// Climb n element levels from el, clamping at the document root.
|
|
395
|
-
function ancestor(el, n) {
|
|
396
|
-
let node = el;
|
|
397
|
-
for (let k = 0; k < n; k++) {
|
|
398
|
-
if (!isElement(node.parent)) break;
|
|
399
|
-
node = node.parent;
|
|
400
|
-
}
|
|
401
|
-
return node;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// --parent re-points each match to its n-th ancestor; dedup by identity so a
|
|
405
|
-
// shared container is reported once, preserving first-seen order.
|
|
406
|
-
function retarget(nodes, opts) {
|
|
407
|
-
if (!opts.parent) return nodes;
|
|
408
|
-
const seen = new Set();
|
|
409
|
-
const result = [];
|
|
410
|
-
for (const el of nodes) {
|
|
411
|
-
const a = ancestor(el, opts.parent);
|
|
412
|
-
if (!seen.has(a)) { seen.add(a); result.push(a); }
|
|
413
|
-
}
|
|
414
|
-
return result;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
451
|
// Sentinels marking where a highlighted node begins/ends. They are injected as
|
|
418
452
|
// HTML *comment* nodes (not text) so js-beautify keeps the surrounding block
|
|
419
453
|
// layout — text markers would make adjacent block elements collapse inline.
|
|
@@ -428,6 +462,7 @@ const HL_END = '';
|
|
|
428
462
|
// highlight) is given and coloring is on, those nodes are wrapped in the match
|
|
429
463
|
// color within the printed block.
|
|
430
464
|
function prettyPrint(el, origins, opts) {
|
|
465
|
+
loadPrettyPrinter();
|
|
431
466
|
const highlight = origins && origins.length && opts && opts.colorOn;
|
|
432
467
|
const inserted = [];
|
|
433
468
|
if (highlight) {
|
|
@@ -503,14 +538,15 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
503
538
|
const lineCount = src.length === 0 ? 0 : (src.endsWith('\n') ? starts.length - 1 : starts.length);
|
|
504
539
|
|
|
505
540
|
// Map each match line to a representative node span (the first match on it),
|
|
506
|
-
// which drives the in-line highlight when
|
|
541
|
+
// which drives the in-line highlight — and the [label] tag — when emitting.
|
|
507
542
|
const info = new Map();
|
|
508
|
-
|
|
543
|
+
const posState = {};
|
|
544
|
+
for (const { el, label: selLabel } of targets) {
|
|
509
545
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
510
|
-
const pos = offsetToPosition(starts, src, off);
|
|
546
|
+
const pos = offsetToPosition(starts, src, off, posState);
|
|
511
547
|
if (info.has(pos.line)) continue;
|
|
512
548
|
const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
|
|
513
|
-
info.set(pos.line, { off, nodeEnd, pos });
|
|
549
|
+
info.set(pos.line, { off, nodeEnd, pos, selLabel });
|
|
514
550
|
}
|
|
515
551
|
|
|
516
552
|
// Expand match lines to [L-before, L+after] windows and merge adjacent ones.
|
|
@@ -537,8 +573,9 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
537
573
|
prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + ' '
|
|
538
574
|
: c(COLORS.sep, '-');
|
|
539
575
|
}
|
|
576
|
+
const tag = m && m.selLabel != null ? c(COLORS.label, `[${m.selLabel}]`) + ' ' : '';
|
|
540
577
|
const body = m
|
|
541
|
-
? renderText(m.pos, m.off, m.nodeEnd, opts)
|
|
578
|
+
? tag + renderText(m.pos, m.off, m.nodeEnd, opts) // highlight + truncate
|
|
542
579
|
: truncate(lineTextAt(starts, src, L).text, opts.maxWidth);
|
|
543
580
|
out.push(prefix + body);
|
|
544
581
|
}
|
|
@@ -549,12 +586,29 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
549
586
|
// used by the aggregate modes (-l/-L/-c). `showLabel` decides whether per-match
|
|
550
587
|
// lines carry a `file:` prefix (only when more than one file is searched).
|
|
551
588
|
function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const
|
|
589
|
+
// The lib owns parsing and selection: one parse per source, then one
|
|
590
|
+
// doc.search() per selector against the same tree. The CLI works on the
|
|
591
|
+
// raw nodes (the match objects' documented escape hatch) because its
|
|
592
|
+
// output modes need node-level access the match shape doesn't model
|
|
593
|
+
// (pretty-printing, ancestor re-targeting, in-line highlight spans).
|
|
594
|
+
const doc = parse(src);
|
|
595
|
+
// One record per (selector, matched node): with -e a node matched by two
|
|
596
|
+
// selectors is reported once per selector, tagged with each label, and the
|
|
597
|
+
// merged stream is in document order (same node = same offset, so the
|
|
598
|
+
// stable sort keeps command-line selector order for ties). A positional
|
|
599
|
+
// selector is the degenerate case with a null label, which suppresses the
|
|
600
|
+
// [label] tag and the --json `label` field everywhere.
|
|
601
|
+
const selList = opts.selectors.length
|
|
602
|
+
? opts.selectors
|
|
603
|
+
: [{ label: null, selector: opts.selector }];
|
|
604
|
+
const records = [];
|
|
605
|
+
for (const s of selList) {
|
|
606
|
+
for (const m of doc.search(s.selector)) records.push({ el: m.node, label: s.label });
|
|
607
|
+
}
|
|
608
|
+
if (selList.length > 1) {
|
|
609
|
+
records.sort((a, b) => (a.el.startIndex || 0) - (b.el.startIndex || 0));
|
|
610
|
+
}
|
|
611
|
+
const found = records.length;
|
|
558
612
|
// Aggregate modes suppress per-match output entirely.
|
|
559
613
|
if (opts.quiet) return found; // status only
|
|
560
614
|
if (opts.filesWithMatches) { if (found) out.push(name); return found; }
|
|
@@ -564,7 +618,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
564
618
|
// -m/--max-count caps matches per source; `limit` is the remaining global
|
|
565
619
|
// budget from -M/--max-total (Infinity when neither applies).
|
|
566
620
|
const cap = Math.min(opts.maxCount || Infinity, limit);
|
|
567
|
-
const limited = Number.isFinite(cap) ?
|
|
621
|
+
const limited = Number.isFinite(cap) ? records.slice(0, cap) : records;
|
|
568
622
|
if (opts.count) {
|
|
569
623
|
// grep parity: every searched file reports a count, zeros included, so
|
|
570
624
|
// scripts get one row per file (exit status still says whether anything
|
|
@@ -573,18 +627,36 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
573
627
|
out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
|
|
574
628
|
return limited.length;
|
|
575
629
|
}
|
|
576
|
-
|
|
577
|
-
//
|
|
578
|
-
|
|
579
|
-
const
|
|
630
|
+
// Nothing to emit: return before building the line index — a zero-match
|
|
631
|
+
// pass over a large file shouldn't pay a full line scan for nothing.
|
|
632
|
+
if (limited.length === 0) return 0;
|
|
633
|
+
const starts = opts.print ? null : doc.lineStarts();
|
|
634
|
+
// --parent re-targets matches to ancestors (no-op without it). Dedup is per
|
|
635
|
+
// (ancestor, label), so two -e selectors sharing a container still report it
|
|
636
|
+
// once each. Aggregate modes above operate on the raw matches; targeting
|
|
637
|
+
// only affects what prints.
|
|
638
|
+
let targets = limited;
|
|
639
|
+
if (opts.parent) {
|
|
640
|
+
targets = [];
|
|
641
|
+
const seen = new Map(); // ancestor -> Set of labels
|
|
642
|
+
for (const r of limited) {
|
|
643
|
+
const a = ancestor(r.el, opts.parent);
|
|
644
|
+
let labels = seen.get(a);
|
|
645
|
+
if (!labels) { labels = new Set(); seen.set(a, labels); }
|
|
646
|
+
if (!labels.has(r.label)) {
|
|
647
|
+
labels.add(r.label);
|
|
648
|
+
targets.push({ el: a, label: r.label });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
580
652
|
// For --parent + -p, remember which original matches sit under each ancestor
|
|
581
653
|
// so they can be highlighted inside the printed container.
|
|
582
654
|
const originsByTarget = new Map();
|
|
583
655
|
if (opts.parent && opts.print) {
|
|
584
|
-
for (const
|
|
585
|
-
const a = ancestor(el, opts.parent);
|
|
656
|
+
for (const r of limited) {
|
|
657
|
+
const a = ancestor(r.el, opts.parent);
|
|
586
658
|
if (!originsByTarget.has(a)) originsByTarget.set(a, []);
|
|
587
|
-
originsByTarget.get(a).push(el);
|
|
659
|
+
originsByTarget.get(a).push(r.el);
|
|
588
660
|
}
|
|
589
661
|
}
|
|
590
662
|
if (opts.before > 0 || opts.after > 0) {
|
|
@@ -598,15 +670,18 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
598
670
|
if (opts.json) {
|
|
599
671
|
// NDJSON: one self-contained record per match. `html` is the exact source
|
|
600
672
|
// slice; newlines are escaped by JSON.stringify, so each record stays on
|
|
601
|
-
// one line. Ignores --color and -n (line/col are always present).
|
|
602
|
-
|
|
673
|
+
// one line. Ignores --color and -n (line/col are always present). `label`
|
|
674
|
+
// appears only with -e.
|
|
675
|
+
const posState = {};
|
|
676
|
+
for (const { el, label: selLabel } of targets) {
|
|
603
677
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
604
|
-
const pos = offsetToPosition(starts, src, off);
|
|
678
|
+
const pos = offsetToPosition(starts, src, off, posState);
|
|
605
679
|
const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
|
|
606
680
|
out.push(JSON.stringify({
|
|
607
681
|
file: name,
|
|
608
682
|
line: pos.line,
|
|
609
683
|
col: pos.bcol,
|
|
684
|
+
...(selLabel !== null && { label: selLabel }),
|
|
610
685
|
html: src.slice(off, nodeEnd),
|
|
611
686
|
text: collapseWs(textOf(el)),
|
|
612
687
|
}));
|
|
@@ -614,17 +689,27 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
614
689
|
return targets.length;
|
|
615
690
|
}
|
|
616
691
|
let emitted = 0;
|
|
617
|
-
|
|
692
|
+
// grep parity: without -n, a physical line prints once no matter how many
|
|
693
|
+
// matches sit on it (grep never repeats a line). With -n each match keeps
|
|
694
|
+
// its own line:col record — that per-match locator is the tool's point.
|
|
695
|
+
// Extraction modes print per-match values, so they never dedup.
|
|
696
|
+
const seenLines = (opts.lineNumber || opts.attr != null || opts.text) ? null : new Set();
|
|
697
|
+
const posState = {};
|
|
698
|
+
for (const { el, label: selLabel } of targets) {
|
|
699
|
+
const c = opts.colorOn ? paint : (_, s) => s;
|
|
700
|
+
// The [label] tag from -e; a null label (positional selector) prints none.
|
|
701
|
+
const tag = selLabel === null ? '' : c(COLORS.label, `[${selLabel}]`) + ' ';
|
|
618
702
|
if (opts.print) {
|
|
619
|
-
// -p shows the re-indented node only; no line:col locator
|
|
620
|
-
//
|
|
703
|
+
// -p shows the re-indented node only; no line:col locator (the [label]
|
|
704
|
+
// tag gets its own line above the block). With --parent, the original
|
|
705
|
+
// matched descendants are highlighted inside the container.
|
|
706
|
+
if (tag) out.push(tag.trimEnd());
|
|
621
707
|
out.push(prettyPrint(el, originsByTarget.get(el), opts), ''); // blank separator
|
|
622
708
|
emitted++;
|
|
623
709
|
continue;
|
|
624
710
|
}
|
|
625
711
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
626
|
-
const pos = offsetToPosition(starts, src, off);
|
|
627
|
-
const c = opts.colorOn ? paint : (_, s) => s;
|
|
712
|
+
const pos = offsetToPosition(starts, src, off, posState);
|
|
628
713
|
|
|
629
714
|
// Choose the content printed for this match. --attr/--text replace the
|
|
630
715
|
// source line with the extracted value (whole value highlighted as the
|
|
@@ -636,6 +721,10 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
636
721
|
} else if (opts.text) {
|
|
637
722
|
text = c(COLORS.match, truncate(collapseWs(textOf(el)), opts.maxWidth));
|
|
638
723
|
} else {
|
|
724
|
+
if (seenLines) {
|
|
725
|
+
if (seenLines.has(pos.line)) continue; // this line already printed
|
|
726
|
+
seenLines.add(pos.line);
|
|
727
|
+
}
|
|
639
728
|
const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1; // exclusive
|
|
640
729
|
text = renderText(pos, off, nodeEnd, opts);
|
|
641
730
|
}
|
|
@@ -651,7 +740,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
651
740
|
if (opts.lineNumber) {
|
|
652
741
|
prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.bcol)) + ' ';
|
|
653
742
|
}
|
|
654
|
-
out.push(prefix + text);
|
|
743
|
+
out.push(prefix + tag + text);
|
|
655
744
|
emitted++;
|
|
656
745
|
}
|
|
657
746
|
return emitted;
|
|
@@ -795,11 +884,271 @@ function looksBinary(buf) {
|
|
|
795
884
|
return n > 0 && suspicious / n > 0.3;
|
|
796
885
|
}
|
|
797
886
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
887
|
+
// --- rewrite output -----------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
// Myers O(ND) shortest edit script over lines. Only ever runs on the middle
|
|
890
|
+
// slice left after common prefix/suffix trimming, which transform()'s local
|
|
891
|
+
// splices keep small.
|
|
892
|
+
function myersDiff(a, b) {
|
|
893
|
+
const N = a.length, M = b.length, max = N + M, off = max;
|
|
894
|
+
if (max === 0) return [];
|
|
895
|
+
const trace = [];
|
|
896
|
+
const v = new Array(2 * max + 2).fill(0);
|
|
897
|
+
let D = -1;
|
|
898
|
+
for (let d = 0; d <= max && D < 0; d++) {
|
|
899
|
+
trace.push(v.slice());
|
|
900
|
+
for (let k = -d; k <= d; k += 2) {
|
|
901
|
+
let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]))
|
|
902
|
+
? v[off + k + 1]
|
|
903
|
+
: v[off + k - 1] + 1;
|
|
904
|
+
let y = x - k;
|
|
905
|
+
while (x < N && y < M && a[x] === b[y]) { x++; y++; }
|
|
906
|
+
v[off + k] = x;
|
|
907
|
+
if (x >= N && y >= M) { D = d; break; }
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const script = [];
|
|
911
|
+
let x = N, y = M;
|
|
912
|
+
for (let d = D; d > 0; d--) {
|
|
913
|
+
const vp = trace[d];
|
|
914
|
+
const k = x - y;
|
|
915
|
+
const prevK = (k === -d || (k !== d && vp[off + k - 1] < vp[off + k + 1])) ? k + 1 : k - 1;
|
|
916
|
+
const prevX = vp[off + prevK], prevY = prevX - prevK;
|
|
917
|
+
while (x > prevX && y > prevY) { script.push({ t: ' ', l: a[--x] }); y--; }
|
|
918
|
+
if (x === prevX) script.push({ t: '+', l: b[--y] });
|
|
919
|
+
else script.push({ t: '-', l: a[--x] });
|
|
920
|
+
}
|
|
921
|
+
while (x > 0 && y > 0) { script.push({ t: ' ', l: a[--x] }); y--; }
|
|
922
|
+
while (x > 0) script.push({ t: '-', l: a[--x] });
|
|
923
|
+
while (y > 0) script.push({ t: '+', l: b[--y] });
|
|
924
|
+
return script.reverse();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Unified diff of two documents, git-apply compatible: ---/+++ headers with
|
|
928
|
+
// a/ b/ prefixes, 3 context lines, merged hunks, and "\ No newline at end of
|
|
929
|
+
// file" markers when a side's last line is unterminated.
|
|
930
|
+
function unifiedDiff(name, oldStr, newStr) {
|
|
931
|
+
const split = s => {
|
|
932
|
+
const lines = s.split('\n');
|
|
933
|
+
const noEol = lines[lines.length - 1] !== '';
|
|
934
|
+
if (!noEol) lines.pop();
|
|
935
|
+
return { lines, noEol };
|
|
936
|
+
};
|
|
937
|
+
const A = split(oldStr), B = split(newStr);
|
|
938
|
+
const a = A.lines, b = B.lines;
|
|
939
|
+
let pre = 0;
|
|
940
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
|
|
941
|
+
let suf = 0;
|
|
942
|
+
while (suf < a.length - pre && suf < b.length - pre
|
|
943
|
+
&& a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;
|
|
944
|
+
const script = [
|
|
945
|
+
...a.slice(0, pre).map(l => ({ t: ' ', l })),
|
|
946
|
+
...myersDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf)),
|
|
947
|
+
...a.slice(a.length - suf).map(l => ({ t: ' ', l })),
|
|
948
|
+
];
|
|
949
|
+
|
|
950
|
+
// Old/new line number sitting *before* each script entry (1-based).
|
|
951
|
+
const oldPos = [], newPos = [];
|
|
952
|
+
let ol = 1, nl = 1;
|
|
953
|
+
for (const s of script) {
|
|
954
|
+
oldPos.push(ol);
|
|
955
|
+
newPos.push(nl);
|
|
956
|
+
if (s.t !== '+') ol++;
|
|
957
|
+
if (s.t !== '-') nl++;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Group changes into hunks: 3 context lines, merge when gaps are ≤ 6.
|
|
961
|
+
const C = 3;
|
|
962
|
+
const hunks = [];
|
|
963
|
+
let i = 0;
|
|
964
|
+
while (i < script.length) {
|
|
965
|
+
if (script[i].t === ' ') { i++; continue; }
|
|
966
|
+
const hStart = Math.max(0, i - C);
|
|
967
|
+
let lastChange = i;
|
|
968
|
+
let j = i + 1;
|
|
969
|
+
while (j < script.length) {
|
|
970
|
+
if (script[j].t !== ' ') { lastChange = j; j++; continue; }
|
|
971
|
+
let k = j;
|
|
972
|
+
while (k < script.length && script[k].t === ' ') k++;
|
|
973
|
+
if (k === script.length || k - j > 2 * C) break;
|
|
974
|
+
j = k;
|
|
975
|
+
}
|
|
976
|
+
const hEnd = Math.min(script.length, lastChange + C + 1);
|
|
977
|
+
hunks.push([hStart, hEnd]);
|
|
978
|
+
i = hEnd;
|
|
979
|
+
}
|
|
801
980
|
|
|
802
|
-
|
|
981
|
+
const out = [`--- a/${name}`, `+++ b/${name}`];
|
|
982
|
+
for (const [s, e] of hunks) {
|
|
983
|
+
let oc = 0, nc = 0;
|
|
984
|
+
for (let k = s; k < e; k++) {
|
|
985
|
+
if (script[k].t !== '+') oc++;
|
|
986
|
+
if (script[k].t !== '-') nc++;
|
|
987
|
+
}
|
|
988
|
+
const os = oc ? oldPos[s] : oldPos[s] - 1;
|
|
989
|
+
const ns = nc ? newPos[s] : newPos[s] - 1;
|
|
990
|
+
out.push(`@@ -${os},${oc} +${ns},${nc} @@`);
|
|
991
|
+
for (let k = s; k < e; k++) {
|
|
992
|
+
const { t, l } = script[k];
|
|
993
|
+
out.push(t + l);
|
|
994
|
+
const atOldEnd = t !== '+' && oldPos[k] === a.length && A.noEol;
|
|
995
|
+
const atNewEnd = t !== '-' && newPos[k] === b.length && B.noEol;
|
|
996
|
+
if (atOldEnd || atNewEnd) out.push('\');
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return out.join('\n') + '\n';
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// The rewrite program mode: transform each source and emit the document
|
|
1003
|
+
// (single input) or a unified diff (any number of files). Never writes a
|
|
1004
|
+
// file — apply diffs with git apply / patch (see ROADMAP Phase 9).
|
|
1005
|
+
function rewriteMain(opts, files, useStdin) {
|
|
1006
|
+
if (!useStdin && files.length > 1 && !opts.diff) {
|
|
1007
|
+
fail('rewriting multiple files requires --diff');
|
|
1008
|
+
}
|
|
1009
|
+
const sources = [];
|
|
1010
|
+
if (useStdin) {
|
|
1011
|
+
sources.push({ name: '(standard input)', buf: readStdin() });
|
|
1012
|
+
} else {
|
|
1013
|
+
for (const f of files) {
|
|
1014
|
+
try {
|
|
1015
|
+
sources.push({ name: f, buf: fs.readFileSync(f) });
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
let totalEdits = 0;
|
|
1022
|
+
const diffs = [];
|
|
1023
|
+
for (const { name, buf } of sources) {
|
|
1024
|
+
// A rewriter must never corrupt bytes it didn't edit: binary input is
|
|
1025
|
+
// never HTML, and a lossy UTF-8 decode written back would mangle every
|
|
1026
|
+
// non-UTF-8 byte in the file — refuse both outright (exit 2).
|
|
1027
|
+
if (looksBinary(buf)) fail(`${name}: binary input; refusing to rewrite`);
|
|
1028
|
+
const src = buf.toString('utf8');
|
|
1029
|
+
if (!Buffer.from(src, 'utf8').equals(buf)) {
|
|
1030
|
+
fail(`${name}: not valid UTF-8; refusing to rewrite`);
|
|
1031
|
+
}
|
|
1032
|
+
let result;
|
|
1033
|
+
try {
|
|
1034
|
+
result = parse(src).transform(opts.selector, { ...opts.rewrite, parent: opts.parent });
|
|
1035
|
+
} catch (e) {
|
|
1036
|
+
if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
|
|
1037
|
+
fail(`invalid selector: ${opts.selector}`);
|
|
1038
|
+
}
|
|
1039
|
+
fail(e.message);
|
|
1040
|
+
}
|
|
1041
|
+
totalEdits += result.edits.length;
|
|
1042
|
+
if (opts.diff) {
|
|
1043
|
+
if (result.edits.length) diffs.push(unifiedDiff(name, src, result.html));
|
|
1044
|
+
} else {
|
|
1045
|
+
// Filter-friendly: the document is always emitted, changed or not
|
|
1046
|
+
// (like sed); the exit status says whether anything was edited.
|
|
1047
|
+
process.stdout.write(result.html);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (diffs.length) process.stdout.write(diffs.join(''));
|
|
1051
|
+
process.exitCode = totalEdits > 0 ? 0 : 1;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// The watch program mode: rerun the search whenever a watched path changes.
|
|
1055
|
+
// Native recursive fs.watch, no polling (see ROADMAP Phase 10; caveat:
|
|
1056
|
+
// network/virtual filesystems may not deliver events). Output adapts like
|
|
1057
|
+
// --color=auto does: a TTY gets clear+reprint, a pipe gets append mode with
|
|
1058
|
+
// `== HH:MM:SS … ==` separators (--no-clear forces append on a TTY), and
|
|
1059
|
+
// --json gets an NDJSON stream — {"event":"run",...} then the match records.
|
|
1060
|
+
// Runs until SIGINT (exit 0, like watch(1)).
|
|
1061
|
+
function watchMain(opts) {
|
|
1062
|
+
const clearMode = !opts.json && !opts.noClear && Boolean(process.stdout.isTTY);
|
|
1063
|
+
const renderOut = out => (!out.length ? ''
|
|
1064
|
+
: opts.nul && (opts.filesWithMatches || opts.filesWithoutMatch)
|
|
1065
|
+
? out.map(s => s + '\0').join('')
|
|
1066
|
+
: out.join('\n') + '\n');
|
|
1067
|
+
|
|
1068
|
+
const run = changed => {
|
|
1069
|
+
// Re-walk every run: a rerun sees exactly what a fresh invocation would.
|
|
1070
|
+
const { files } = resolveFiles(opts);
|
|
1071
|
+
const out = [];
|
|
1072
|
+
let total = 0;
|
|
1073
|
+
try {
|
|
1074
|
+
total = searchFiles(opts, files, out);
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
|
|
1077
|
+
fail(`invalid selector: ${opts.selector != null
|
|
1078
|
+
? opts.selector
|
|
1079
|
+
: opts.selectors.map(s => s.selector).join(', ')}`);
|
|
1080
|
+
}
|
|
1081
|
+
fail(e.message);
|
|
1082
|
+
}
|
|
1083
|
+
if (opts.json) {
|
|
1084
|
+
process.stdout.write(JSON.stringify({
|
|
1085
|
+
event: 'run',
|
|
1086
|
+
changed: changed || null,
|
|
1087
|
+
matches: total,
|
|
1088
|
+
}) + '\n' + renderOut(out));
|
|
1089
|
+
} else if (clearMode) {
|
|
1090
|
+
process.stdout.write('\x1b[2J\x1b[H' + (out.length ? renderOut(out) : 'cssgrep: no matches\n'));
|
|
1091
|
+
} else {
|
|
1092
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
1093
|
+
process.stdout.write(`== ${ts} ${changed || 'watching'} ==\n` + renderOut(out));
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
// Debounce change bursts (editors often fire several events per save).
|
|
1098
|
+
let timer = null;
|
|
1099
|
+
let pending = null;
|
|
1100
|
+
const schedule = changed => {
|
|
1101
|
+
pending = changed;
|
|
1102
|
+
clearTimeout(timer);
|
|
1103
|
+
timer = setTimeout(() => { const c = pending; pending = null; run(c); }, 80);
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
// Directory targets get one recursive watcher each. Explicit file targets
|
|
1107
|
+
// are watched via their parent directory, filtered by name — editors often
|
|
1108
|
+
// save by rename-replace, which would orphan a watcher on the file itself.
|
|
1109
|
+
const watchers = [];
|
|
1110
|
+
const fileTargets = new Map(); // parent dir -> Set of basenames
|
|
1111
|
+
const targets = opts.paths.length ? opts.paths : ['.'];
|
|
1112
|
+
for (const p of targets) {
|
|
1113
|
+
const st = fs.statSync(p, { throwIfNoEntry: false });
|
|
1114
|
+
if (!st) {
|
|
1115
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: ${p}: no such file or directory\n`);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
if (st.isDirectory()) {
|
|
1119
|
+
watchers.push(fs.watch(p, { recursive: true },
|
|
1120
|
+
(ev, f) => schedule(f ? path.join(p, f) : p)));
|
|
1121
|
+
} else {
|
|
1122
|
+
const dir = path.dirname(p);
|
|
1123
|
+
if (!fileTargets.has(dir)) fileTargets.set(dir, new Set());
|
|
1124
|
+
fileTargets.get(dir).add(path.basename(p));
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
for (const [dir, bases] of fileTargets) {
|
|
1128
|
+
watchers.push(fs.watch(dir,
|
|
1129
|
+
(ev, f) => { if (!f || bases.has(f)) schedule(f ? path.join(dir, f) : dir); }));
|
|
1130
|
+
}
|
|
1131
|
+
if (!watchers.length) fail('--watch: nothing to watch');
|
|
1132
|
+
for (const w of watchers) {
|
|
1133
|
+
w.on('error', e => {
|
|
1134
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: watch: ${e.message}\n`);
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
process.on('SIGINT', () => {
|
|
1139
|
+
for (const w of watchers) w.close();
|
|
1140
|
+
clearTimeout(timer);
|
|
1141
|
+
process.exitCode = 0; // watch(1) convention
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
run(null); // initial pass, then wait
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Resolve the paths to search into a concrete file list (or stdin). Watch
|
|
1148
|
+
// mode calls this again on every rerun, so a rerun sees exactly what a fresh
|
|
1149
|
+
// invocation would: new files picked up per the include/ignore/--ext rules,
|
|
1150
|
+
// deleted ones dropped.
|
|
1151
|
+
function resolveFiles(opts) {
|
|
803
1152
|
let files = [];
|
|
804
1153
|
let useStdin = false;
|
|
805
1154
|
if (opts.recursive) {
|
|
@@ -816,9 +1165,57 @@ function main() {
|
|
|
816
1165
|
} else {
|
|
817
1166
|
useStdin = true;
|
|
818
1167
|
}
|
|
1168
|
+
return { files, useStdin };
|
|
1169
|
+
}
|
|
819
1170
|
|
|
1171
|
+
// One search pass over a file list; appends output lines to `out` and returns
|
|
1172
|
+
// the match total. Shared by the single-shot main path and each watch rerun.
|
|
1173
|
+
function searchFiles(opts, files, out) {
|
|
820
1174
|
// A label (file prefix) is shown when searching more than one file; -H forces
|
|
821
1175
|
// it on (even for one file or stdin) and --no-filename forces it off.
|
|
1176
|
+
const showLabel = opts.withFilename ? true
|
|
1177
|
+
: opts.noFilename ? false
|
|
1178
|
+
: files.length > 1;
|
|
1179
|
+
let total = 0;
|
|
1180
|
+
// -M/--max-total: remaining matches allowed across all files (Infinity = off).
|
|
1181
|
+
const room = () => (opts.maxTotal ? Math.max(0, opts.maxTotal - total) : Infinity);
|
|
1182
|
+
for (const f of files) {
|
|
1183
|
+
if (room() <= 0) break; // -M: global budget exhausted
|
|
1184
|
+
let buf;
|
|
1185
|
+
try {
|
|
1186
|
+
buf = fs.readFileSync(f); // Buffer: sniff before decoding
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
if (looksBinary(buf)) {
|
|
1192
|
+
if (!opts.noMessages && !opts.quiet) {
|
|
1193
|
+
process.stderr.write(`cssgrep: ${f}: binary file (skipped)\n`);
|
|
1194
|
+
}
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
|
|
1198
|
+
if (opts.quiet && total > 0) break; // -q: first match decides the status
|
|
1199
|
+
}
|
|
1200
|
+
return total;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function main() {
|
|
1204
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
1205
|
+
resolveSelectorAndPaths(opts);
|
|
1206
|
+
|
|
1207
|
+
const { files, useStdin } = resolveFiles(opts);
|
|
1208
|
+
|
|
1209
|
+
if (opts.rewriteActive) {
|
|
1210
|
+
rewriteMain(opts, files, useStdin);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (opts.watch) {
|
|
1214
|
+
if (useStdin) fail('--watch requires file or directory paths');
|
|
1215
|
+
watchMain(opts);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
822
1219
|
const showLabel = opts.withFilename ? true
|
|
823
1220
|
: opts.noFilename ? false
|
|
824
1221
|
: (!useStdin && files.length > 1);
|
|
@@ -826,9 +1223,6 @@ function main() {
|
|
|
826
1223
|
const out = [];
|
|
827
1224
|
let total = 0;
|
|
828
1225
|
|
|
829
|
-
// -M/--max-total: remaining matches allowed across all files (Infinity = off).
|
|
830
|
-
const room = () => (opts.maxTotal ? Math.max(0, opts.maxTotal - total) : Infinity);
|
|
831
|
-
|
|
832
1226
|
try {
|
|
833
1227
|
if (useStdin) {
|
|
834
1228
|
const buf = readStdin();
|
|
@@ -837,31 +1231,18 @@ function main() {
|
|
|
837
1231
|
process.stderr.write('cssgrep: (standard input): binary input (skipped)\n');
|
|
838
1232
|
}
|
|
839
1233
|
} else {
|
|
840
|
-
total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out,
|
|
1234
|
+
total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out,
|
|
1235
|
+
opts.maxTotal || Infinity);
|
|
841
1236
|
}
|
|
842
1237
|
} else {
|
|
843
|
-
|
|
844
|
-
if (room() <= 0) break; // -M: global budget exhausted
|
|
845
|
-
let buf;
|
|
846
|
-
try {
|
|
847
|
-
buf = fs.readFileSync(f); // Buffer: sniff before decoding
|
|
848
|
-
} catch (e) {
|
|
849
|
-
if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
if (looksBinary(buf)) {
|
|
853
|
-
if (!opts.noMessages && !opts.quiet) {
|
|
854
|
-
process.stderr.write(`cssgrep: ${f}: binary file (skipped)\n`);
|
|
855
|
-
}
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
|
|
859
|
-
if (opts.quiet && total > 0) break; // -q: first match decides the status
|
|
860
|
-
}
|
|
1238
|
+
total = searchFiles(opts, files, out);
|
|
861
1239
|
}
|
|
862
1240
|
} catch (e) {
|
|
863
1241
|
if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
|
|
864
|
-
|
|
1242
|
+
const shown = opts.selector != null
|
|
1243
|
+
? opts.selector
|
|
1244
|
+
: opts.selectors.map(s => s.selector).join(', ');
|
|
1245
|
+
fail(`invalid selector: ${shown}`);
|
|
865
1246
|
}
|
|
866
1247
|
fail(e.message);
|
|
867
1248
|
}
|
|
@@ -870,11 +1251,29 @@ function main() {
|
|
|
870
1251
|
// For -l/-L, -0 NUL-terminates each file name (no newline) so the list is
|
|
871
1252
|
// safe for `xargs -0`. Other modes keep newline-separated records (with -0
|
|
872
1253
|
// the NUL appears only as the in-record file-name separator).
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1254
|
+
//
|
|
1255
|
+
// Written in byte-bounded chunks, never as one join of everything: with
|
|
1256
|
+
// enough (or long enough) result lines a single joined string exceeds
|
|
1257
|
+
// V8's maximum string length and crashes — observed with 40k matches on
|
|
1258
|
+
// an 8 MB minified line. Writes queue; process.exitCode (not exit())
|
|
1259
|
+
// keeps them flush-safe.
|
|
1260
|
+
const nulList = opts.nul && (opts.filesWithMatches || opts.filesWithoutMatch);
|
|
1261
|
+
const CHUNK_BYTES = 32 << 20; // ~32 MB per write
|
|
1262
|
+
let batch = [];
|
|
1263
|
+
let bytes = 0;
|
|
1264
|
+
const flush = () => {
|
|
1265
|
+
if (!batch.length) return;
|
|
1266
|
+
process.stdout.write(nulList ? batch.map(s => s + '\0').join('')
|
|
1267
|
+
: batch.join('\n') + '\n');
|
|
1268
|
+
batch = [];
|
|
1269
|
+
bytes = 0;
|
|
1270
|
+
};
|
|
1271
|
+
for (const line of out) {
|
|
1272
|
+
batch.push(line);
|
|
1273
|
+
bytes += line.length + 1;
|
|
1274
|
+
if (bytes >= CHUNK_BYTES) flush();
|
|
877
1275
|
}
|
|
1276
|
+
flush();
|
|
878
1277
|
}
|
|
879
1278
|
// Normally success means "a match was found". With -L it means "a file
|
|
880
1279
|
// without a match was printed", which is decoupled from the match total.
|