cssgrep 1.2.0 → 1.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 +46 -1
- package/README.md +173 -1
- package/{index.js → cli.js} +498 -143
- 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 +403 -0
- package/man/cssgrep.1 +149 -4
- package/package.json +12 -9
package/{index.js → cli.js}
RENAMED
|
@@ -3,22 +3,24 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { parseDocument } = require('htmlparser2');
|
|
7
|
-
const { selectAll } = require('css-select');
|
|
8
6
|
const render = require('dom-serializer').default;
|
|
9
7
|
const { html: beautify } = require('js-beautify');
|
|
8
|
+
const {
|
|
9
|
+
parse, offsetToPosition, lineTextAt, textOf, collapseWs, ancestor,
|
|
10
|
+
} = require('./lib.js');
|
|
10
11
|
|
|
11
12
|
// Single source of truth for the version. A constant rather than a read of
|
|
12
13
|
// package.json, so it survives compilation into a standalone binary (Bun
|
|
13
14
|
// --compile / Node SEA), where package.json won't sit next to the executable.
|
|
14
15
|
// Keep in sync with package.json on release.
|
|
15
|
-
const VERSION = '1.
|
|
16
|
+
const VERSION = '1.3.0';
|
|
16
17
|
|
|
17
18
|
const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
|
|
18
19
|
|
|
19
20
|
Usage:
|
|
20
21
|
cssgrep <selector> [file ...]
|
|
21
22
|
cssgrep <selector> -r <dir ...>
|
|
23
|
+
cssgrep -e '[label=]<sel>' [-e ...] [file ...]
|
|
22
24
|
cat file.html | cssgrep <selector>
|
|
23
25
|
|
|
24
26
|
Output (one line per match):
|
|
@@ -28,6 +30,10 @@ Output (one line per match):
|
|
|
28
30
|
{file}:{line}:{col} {line contents} (with -n; multiple files)
|
|
29
31
|
|
|
30
32
|
Options:
|
|
33
|
+
-e, --selector <[label=]sel> Add a selector (repeatable). Matches from all
|
|
34
|
+
-e selectors merge in document order; each is tagged
|
|
35
|
+
[label] (default label: the selector text itself).
|
|
36
|
+
With -e, every positional argument is a file path.
|
|
31
37
|
-r, --recursive Recurse into directory arguments.
|
|
32
38
|
--max-depth <n> Limit -r recursion depth (1 = the given dir only).
|
|
33
39
|
--ext <list> Comma-separated extensions for -r (default: html,htm).
|
|
@@ -41,7 +47,8 @@ Options:
|
|
|
41
47
|
-p, --print Pretty-print the matched node's HTML above its location.
|
|
42
48
|
--attr <name> Print the value of attribute <name> (skips nodes without it).
|
|
43
49
|
--text Print the matched node's text content (whitespace collapsed).
|
|
44
|
-
--json Print one JSON record per match (NDJSON: file,line,col,
|
|
50
|
+
--json Print one JSON record per match (NDJSON: file,line,col,
|
|
51
|
+
html,text; plus label with -e).
|
|
45
52
|
--parent <n> Report the n-th ancestor of each match instead (dedup'd).
|
|
46
53
|
-w, --max-width <n> Truncate the shown line to <n> columns (ellipsis added).
|
|
47
54
|
-A, --after-context <n> Print <n> source lines after each match.
|
|
@@ -60,9 +67,31 @@ Options:
|
|
|
60
67
|
--no-filename Never print the file name prefix (even for many files).
|
|
61
68
|
--color[=<when>] Colorize output: auto (default, also what a bare
|
|
62
69
|
--color means, like grep), always or never.
|
|
70
|
+
--watch Re-run the search whenever a watched file changes
|
|
71
|
+
(requires paths; excludes -q and the rewrite ops).
|
|
72
|
+
On a TTY the screen is cleared and results reprinted;
|
|
73
|
+
piped output appends each run after a == HH:MM:SS ==
|
|
74
|
+
separator; with --json each run emits an NDJSON
|
|
75
|
+
{"event":"run",...} record followed by the matches.
|
|
76
|
+
Exit with Ctrl-C (status 0).
|
|
77
|
+
--no-clear With --watch on a TTY: append instead of clearing.
|
|
63
78
|
-h, --help Show this help.
|
|
64
79
|
-V, --version Show version and exit.
|
|
65
80
|
|
|
81
|
+
Rewrite (a separate mode: excludes -n/-p/--attr/--text/--json, -c/-l/-L/-q,
|
|
82
|
+
-A/-B/-C, -m/-M, -w, -0 and -e; composes with --parent):
|
|
83
|
+
--add-class <c> Add a class to each matched element.
|
|
84
|
+
--remove-class <c> Remove a class (attribute dropped when emptied).
|
|
85
|
+
--set-attr <k=v> Set attribute k to v (added if missing).
|
|
86
|
+
--remove-attr <k> Remove attribute k.
|
|
87
|
+
--rename-tag <t> Rename the element (and its closing tag, if present).
|
|
88
|
+
--diff Emit a unified diff instead of the document; required
|
|
89
|
+
for multiple files. Apply with git apply / patch.
|
|
90
|
+
|
|
91
|
+
A single input prints the rewritten document to stdout. Only the matched tags'
|
|
92
|
+
bytes change; ops compose as rename -> remove-attr -> set-attr -> remove-class
|
|
93
|
+
-> add-class regardless of argument order. Exit: 0 edits, 1 none, 2 error.
|
|
94
|
+
|
|
66
95
|
Short flags combine (-rn) and a value attaches to its flag (-w100) or follows it
|
|
67
96
|
(-w 100); a value-taking flag may close a cluster (-rnw100). Long options take a
|
|
68
97
|
value with = or as the next word (--max-width=100, --ext htm).
|
|
@@ -77,13 +106,22 @@ function fail(msg) {
|
|
|
77
106
|
process.exit(2);
|
|
78
107
|
}
|
|
79
108
|
|
|
109
|
+
// grep's most predictable stumble: there is no invert-match here, because CSS
|
|
110
|
+
// expresses inversion in the selector itself. Teach instead of just rejecting.
|
|
111
|
+
function failInvert(flag) {
|
|
112
|
+
fail(`${flag}: there is no invert-match — CSS expresses inversion in the ` +
|
|
113
|
+
`selector, e.g. 'img:not([alt])' or 'div:not(:has(a))'; see man cssgrep`);
|
|
114
|
+
}
|
|
115
|
+
|
|
80
116
|
// ANSI SGR codes matching grep's default scheme: bold-red match, magenta
|
|
81
|
-
// filename, green line/col numbers, cyan separators.
|
|
117
|
+
// filename, green line/col numbers, cyan separators. The [label] tag from -e
|
|
118
|
+
// has no grep counterpart; yellow keeps it distinct from all of the above.
|
|
82
119
|
const COLORS = {
|
|
83
120
|
match: '1;31',
|
|
84
121
|
file: '35',
|
|
85
122
|
line: '32',
|
|
86
123
|
sep: '36',
|
|
124
|
+
label: '33',
|
|
87
125
|
};
|
|
88
126
|
|
|
89
127
|
function paint(code, str) {
|
|
@@ -93,6 +131,7 @@ function paint(code, str) {
|
|
|
93
131
|
function parseArgs(argv) {
|
|
94
132
|
const opts = {
|
|
95
133
|
selector: null,
|
|
134
|
+
selectors: [],
|
|
96
135
|
positionals: [],
|
|
97
136
|
paths: [],
|
|
98
137
|
recursive: false,
|
|
@@ -122,6 +161,10 @@ function parseArgs(argv) {
|
|
|
122
161
|
noFilename: false,
|
|
123
162
|
noMessages: false,
|
|
124
163
|
color: 'auto',
|
|
164
|
+
rewrite: { renameTag: null, removeAttr: [], setAttr: {}, removeClass: [], addClass: [] },
|
|
165
|
+
diff: false,
|
|
166
|
+
watch: false,
|
|
167
|
+
noClear: false,
|
|
125
168
|
};
|
|
126
169
|
const setExts = v => {
|
|
127
170
|
opts.extGiven = true;
|
|
@@ -141,6 +184,16 @@ function parseArgs(argv) {
|
|
|
141
184
|
const setAfter = v => { opts.after = boundedInt(v, '--after-context', 0); };
|
|
142
185
|
const setBefore = v => { opts.before = boundedInt(v, '--before-context', 0); };
|
|
143
186
|
const setContext = v => { opts.after = opts.before = boundedInt(v, '--context', 0); };
|
|
187
|
+
// -e [label=]<selector>. A `=` outside brackets never begins a *working*
|
|
188
|
+
// selector (css-what tokenizes `a=b` as an unmatchable tag named `=b`), so a
|
|
189
|
+
// leading identifier + `=` is unambiguously a label. Unlabeled selectors are
|
|
190
|
+
// tagged with their own text, so [label] and the --json `label` field are
|
|
191
|
+
// always present when -e is used.
|
|
192
|
+
const addSelector = v => {
|
|
193
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*)=([\s\S]+)$/.exec(v);
|
|
194
|
+
if (m) opts.selectors.push({ label: m[1], selector: m[2] });
|
|
195
|
+
else opts.selectors.push({ label: v, selector: v });
|
|
196
|
+
};
|
|
144
197
|
const addIgnore = v => { const c = compileIgnore(v); if (c) opts.ignore.push(c); };
|
|
145
198
|
const addInclude = v => { const c = compileIgnore(v); if (c) opts.include.push(c); };
|
|
146
199
|
const addIgnoreFile = v => {
|
|
@@ -152,7 +205,7 @@ function parseArgs(argv) {
|
|
|
152
205
|
// Short flags that take a value (rest of the cluster, or the next argument).
|
|
153
206
|
const shortValueFlags = {
|
|
154
207
|
w: setMaxWidth, m: setMaxCount, M: setMaxTotal, A: setAfter, B: setBefore, C: setContext,
|
|
155
|
-
i: addIgnore,
|
|
208
|
+
i: addIgnore, e: addSelector,
|
|
156
209
|
};
|
|
157
210
|
for (let i = 0; i < argv.length; i++) {
|
|
158
211
|
const a = argv[i];
|
|
@@ -201,6 +254,23 @@ function parseArgs(argv) {
|
|
|
201
254
|
case '--before-context': setBefore(value()); break;
|
|
202
255
|
case '--context': setContext(value()); break;
|
|
203
256
|
case '--color': case '--colour': opts.color = inline != null ? inline : 'auto'; break;
|
|
257
|
+
case '--selector': addSelector(value()); break;
|
|
258
|
+
case '--add-class': opts.rewrite.addClass.push(value()); break;
|
|
259
|
+
case '--remove-class': opts.rewrite.removeClass.push(value()); break;
|
|
260
|
+
case '--remove-attr': opts.rewrite.removeAttr.push(value()); break;
|
|
261
|
+
case '--set-attr': {
|
|
262
|
+
const v = value();
|
|
263
|
+
const eq = v.indexOf('=');
|
|
264
|
+
const k = eq === -1 ? v : v.slice(0, eq);
|
|
265
|
+
if (!k) fail('--set-attr requires a name (name=value)');
|
|
266
|
+
opts.rewrite.setAttr[k] = eq === -1 ? '' : v.slice(eq + 1);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case '--rename-tag': opts.rewrite.renameTag = value(); break;
|
|
270
|
+
case '--diff': opts.diff = true; break;
|
|
271
|
+
case '--watch': opts.watch = true; break;
|
|
272
|
+
case '--no-clear': opts.noClear = true; break;
|
|
273
|
+
case '--invert-match': failInvert(name); break;
|
|
204
274
|
default: fail(`unknown option: ${name}`);
|
|
205
275
|
}
|
|
206
276
|
continue;
|
|
@@ -232,6 +302,7 @@ function parseArgs(argv) {
|
|
|
232
302
|
case '0': case 'Z': opts.nul = true; break;
|
|
233
303
|
case 'H': opts.withFilename = true; break;
|
|
234
304
|
case 's': opts.noMessages = true; break;
|
|
305
|
+
case 'v': failInvert('-v'); break;
|
|
235
306
|
default: fail(`unknown option: -${ch}`);
|
|
236
307
|
}
|
|
237
308
|
}
|
|
@@ -240,7 +311,9 @@ function parseArgs(argv) {
|
|
|
240
311
|
|
|
241
312
|
opts.positionals.push(a);
|
|
242
313
|
}
|
|
243
|
-
if (opts.positionals.length === 0
|
|
314
|
+
if (opts.positionals.length === 0 && !opts.selectors.length) {
|
|
315
|
+
fail('no selector given (try --help)');
|
|
316
|
+
}
|
|
244
317
|
// Aggregate modes each suppress per-match output, so at most one may apply.
|
|
245
318
|
const aggregates = [opts.count, opts.filesWithMatches, opts.filesWithoutMatch, opts.quiet]
|
|
246
319
|
.filter(Boolean).length;
|
|
@@ -260,6 +333,34 @@ function parseArgs(argv) {
|
|
|
260
333
|
}
|
|
261
334
|
if (opts.lineNumber && opts.count) fail('-n cannot be combined with -c');
|
|
262
335
|
if (opts.lineNumber && opts.print) fail('-n cannot be combined with -p');
|
|
336
|
+
// Rewrite is its own program mode, not a fourth output axis: it emits a
|
|
337
|
+
// document (or a diff), so everything that shapes per-match output is
|
|
338
|
+
// meaningless with it.
|
|
339
|
+
const r = opts.rewrite;
|
|
340
|
+
opts.rewriteActive = Boolean(r.renameTag) || r.removeAttr.length > 0
|
|
341
|
+
|| r.removeClass.length > 0 || r.addClass.length > 0
|
|
342
|
+
|| Object.keys(r.setAttr).length > 0;
|
|
343
|
+
if (opts.diff && !opts.rewriteActive) fail('--diff requires a rewrite operation');
|
|
344
|
+
if (opts.rewriteActive) {
|
|
345
|
+
if (printModes > 0) fail('rewrite operations cannot be combined with -p/--attr/--text/--json');
|
|
346
|
+
if (aggregates > 0) fail('rewrite operations cannot be combined with -c/-l/-L/-q');
|
|
347
|
+
if (opts.before > 0 || opts.after > 0) fail('rewrite operations cannot be combined with -A/-B/-C');
|
|
348
|
+
if (opts.lineNumber) fail('rewrite operations cannot be combined with -n');
|
|
349
|
+
if (opts.maxWidth) fail('rewrite operations cannot be combined with -w');
|
|
350
|
+
if (opts.nul) fail('rewrite operations cannot be combined with -0');
|
|
351
|
+
if (opts.maxCount || opts.maxTotal) fail('rewrite operations cannot be combined with -m/-M');
|
|
352
|
+
if (opts.selectors.length) {
|
|
353
|
+
fail('rewrite takes a single positional selector (use a selector list like "a, b" instead of -e)');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Watch re-runs the search on change; modes that end the run early or edit
|
|
357
|
+
// files make no sense against it.
|
|
358
|
+
if (opts.noClear && !opts.watch) fail('--no-clear requires --watch');
|
|
359
|
+
if (opts.watch) {
|
|
360
|
+
if (opts.quiet) fail('--watch cannot be combined with -q');
|
|
361
|
+
if (opts.rewriteActive) fail('--watch cannot be combined with rewrite operations');
|
|
362
|
+
if (opts.noClear && opts.json) fail('--no-clear is meaningless with --json (it never clears)');
|
|
363
|
+
}
|
|
263
364
|
if (!['auto', 'always', 'never'].includes(opts.color)) {
|
|
264
365
|
fail(`invalid --color value: ${opts.color} (expected auto, always or never)`);
|
|
265
366
|
}
|
|
@@ -279,6 +380,13 @@ function parseArgs(argv) {
|
|
|
279
380
|
// `grepprg`, which appends args in its own order). Fall back to
|
|
280
381
|
// "selector is the first positional" when the split is unclear.
|
|
281
382
|
function resolveSelectorAndPaths(opts) {
|
|
383
|
+
// With -e the selectors are explicit, so — like grep -e — every positional
|
|
384
|
+
// is a file path; a mistyped one is reported as unreadable, not re-guessed
|
|
385
|
+
// as a selector.
|
|
386
|
+
if (opts.selectors.length) {
|
|
387
|
+
opts.paths = opts.positionals;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
282
390
|
const pos = opts.positionals;
|
|
283
391
|
const onDisk = [];
|
|
284
392
|
const notOnDisk = [];
|
|
@@ -297,53 +405,6 @@ function resolveSelectorAndPaths(opts) {
|
|
|
297
405
|
}
|
|
298
406
|
}
|
|
299
407
|
|
|
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
408
|
// Never cut between the halves of a surrogate pair: a lone half is invalid
|
|
348
409
|
// Unicode and serializes as U+FFFD garbage. Backing off one unit loses at most
|
|
349
410
|
// one display column.
|
|
@@ -377,43 +438,6 @@ function renderText(pos, off, nodeEnd, opts) {
|
|
|
377
438
|
return vis.slice(0, s) + paint(COLORS.match, vis.slice(s, e)) + vis.slice(e);
|
|
378
439
|
}
|
|
379
440
|
|
|
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
441
|
// Sentinels marking where a highlighted node begins/ends. They are injected as
|
|
418
442
|
// HTML *comment* nodes (not text) so js-beautify keeps the surrounding block
|
|
419
443
|
// layout — text markers would make adjacent block elements collapse inline.
|
|
@@ -503,14 +527,14 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
503
527
|
const lineCount = src.length === 0 ? 0 : (src.endsWith('\n') ? starts.length - 1 : starts.length);
|
|
504
528
|
|
|
505
529
|
// Map each match line to a representative node span (the first match on it),
|
|
506
|
-
// which drives the in-line highlight when
|
|
530
|
+
// which drives the in-line highlight — and the [label] tag — when emitting.
|
|
507
531
|
const info = new Map();
|
|
508
|
-
for (const el of targets) {
|
|
532
|
+
for (const { el, label: selLabel } of targets) {
|
|
509
533
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
510
534
|
const pos = offsetToPosition(starts, src, off);
|
|
511
535
|
if (info.has(pos.line)) continue;
|
|
512
536
|
const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
|
|
513
|
-
info.set(pos.line, { off, nodeEnd, pos });
|
|
537
|
+
info.set(pos.line, { off, nodeEnd, pos, selLabel });
|
|
514
538
|
}
|
|
515
539
|
|
|
516
540
|
// Expand match lines to [L-before, L+after] windows and merge adjacent ones.
|
|
@@ -537,8 +561,9 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
537
561
|
prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + ' '
|
|
538
562
|
: c(COLORS.sep, '-');
|
|
539
563
|
}
|
|
564
|
+
const tag = m && m.selLabel != null ? c(COLORS.label, `[${m.selLabel}]`) + ' ' : '';
|
|
540
565
|
const body = m
|
|
541
|
-
? renderText(m.pos, m.off, m.nodeEnd, opts)
|
|
566
|
+
? tag + renderText(m.pos, m.off, m.nodeEnd, opts) // highlight + truncate
|
|
542
567
|
: truncate(lineTextAt(starts, src, L).text, opts.maxWidth);
|
|
543
568
|
out.push(prefix + body);
|
|
544
569
|
}
|
|
@@ -549,12 +574,29 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
549
574
|
// used by the aggregate modes (-l/-L/-c). `showLabel` decides whether per-match
|
|
550
575
|
// lines carry a `file:` prefix (only when more than one file is searched).
|
|
551
576
|
function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const
|
|
577
|
+
// The lib owns parsing and selection: one parse per source, then one
|
|
578
|
+
// doc.search() per selector against the same tree. The CLI works on the
|
|
579
|
+
// raw nodes (the match objects' documented escape hatch) because its
|
|
580
|
+
// output modes need node-level access the match shape doesn't model
|
|
581
|
+
// (pretty-printing, ancestor re-targeting, in-line highlight spans).
|
|
582
|
+
const doc = parse(src);
|
|
583
|
+
// One record per (selector, matched node): with -e a node matched by two
|
|
584
|
+
// selectors is reported once per selector, tagged with each label, and the
|
|
585
|
+
// merged stream is in document order (same node = same offset, so the
|
|
586
|
+
// stable sort keeps command-line selector order for ties). A positional
|
|
587
|
+
// selector is the degenerate case with a null label, which suppresses the
|
|
588
|
+
// [label] tag and the --json `label` field everywhere.
|
|
589
|
+
const selList = opts.selectors.length
|
|
590
|
+
? opts.selectors
|
|
591
|
+
: [{ label: null, selector: opts.selector }];
|
|
592
|
+
const records = [];
|
|
593
|
+
for (const s of selList) {
|
|
594
|
+
for (const m of doc.search(s.selector)) records.push({ el: m.node, label: s.label });
|
|
595
|
+
}
|
|
596
|
+
if (selList.length > 1) {
|
|
597
|
+
records.sort((a, b) => (a.el.startIndex || 0) - (b.el.startIndex || 0));
|
|
598
|
+
}
|
|
599
|
+
const found = records.length;
|
|
558
600
|
// Aggregate modes suppress per-match output entirely.
|
|
559
601
|
if (opts.quiet) return found; // status only
|
|
560
602
|
if (opts.filesWithMatches) { if (found) out.push(name); return found; }
|
|
@@ -564,7 +606,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
564
606
|
// -m/--max-count caps matches per source; `limit` is the remaining global
|
|
565
607
|
// budget from -M/--max-total (Infinity when neither applies).
|
|
566
608
|
const cap = Math.min(opts.maxCount || Infinity, limit);
|
|
567
|
-
const limited = Number.isFinite(cap) ?
|
|
609
|
+
const limited = Number.isFinite(cap) ? records.slice(0, cap) : records;
|
|
568
610
|
if (opts.count) {
|
|
569
611
|
// grep parity: every searched file reports a count, zeros included, so
|
|
570
612
|
// scripts get one row per file (exit status still says whether anything
|
|
@@ -573,18 +615,33 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
573
615
|
out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
|
|
574
616
|
return limited.length;
|
|
575
617
|
}
|
|
576
|
-
const starts = opts.print ? null :
|
|
577
|
-
// --parent re-targets matches to ancestors (no-op without it).
|
|
578
|
-
//
|
|
579
|
-
|
|
618
|
+
const starts = opts.print ? null : doc.lineStarts();
|
|
619
|
+
// --parent re-targets matches to ancestors (no-op without it). Dedup is per
|
|
620
|
+
// (ancestor, label), so two -e selectors sharing a container still report it
|
|
621
|
+
// once each. Aggregate modes above operate on the raw matches; targeting
|
|
622
|
+
// only affects what prints.
|
|
623
|
+
let targets = limited;
|
|
624
|
+
if (opts.parent) {
|
|
625
|
+
targets = [];
|
|
626
|
+
const seen = new Map(); // ancestor -> Set of labels
|
|
627
|
+
for (const r of limited) {
|
|
628
|
+
const a = ancestor(r.el, opts.parent);
|
|
629
|
+
let labels = seen.get(a);
|
|
630
|
+
if (!labels) { labels = new Set(); seen.set(a, labels); }
|
|
631
|
+
if (!labels.has(r.label)) {
|
|
632
|
+
labels.add(r.label);
|
|
633
|
+
targets.push({ el: a, label: r.label });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
580
637
|
// For --parent + -p, remember which original matches sit under each ancestor
|
|
581
638
|
// so they can be highlighted inside the printed container.
|
|
582
639
|
const originsByTarget = new Map();
|
|
583
640
|
if (opts.parent && opts.print) {
|
|
584
|
-
for (const
|
|
585
|
-
const a = ancestor(el, opts.parent);
|
|
641
|
+
for (const r of limited) {
|
|
642
|
+
const a = ancestor(r.el, opts.parent);
|
|
586
643
|
if (!originsByTarget.has(a)) originsByTarget.set(a, []);
|
|
587
|
-
originsByTarget.get(a).push(el);
|
|
644
|
+
originsByTarget.get(a).push(r.el);
|
|
588
645
|
}
|
|
589
646
|
}
|
|
590
647
|
if (opts.before > 0 || opts.after > 0) {
|
|
@@ -598,8 +655,9 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
598
655
|
if (opts.json) {
|
|
599
656
|
// NDJSON: one self-contained record per match. `html` is the exact source
|
|
600
657
|
// 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
|
-
|
|
658
|
+
// one line. Ignores --color and -n (line/col are always present). `label`
|
|
659
|
+
// appears only with -e.
|
|
660
|
+
for (const { el, label: selLabel } of targets) {
|
|
603
661
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
604
662
|
const pos = offsetToPosition(starts, src, off);
|
|
605
663
|
const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
|
|
@@ -607,6 +665,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
607
665
|
file: name,
|
|
608
666
|
line: pos.line,
|
|
609
667
|
col: pos.bcol,
|
|
668
|
+
...(selLabel !== null && { label: selLabel }),
|
|
610
669
|
html: src.slice(off, nodeEnd),
|
|
611
670
|
text: collapseWs(textOf(el)),
|
|
612
671
|
}));
|
|
@@ -614,17 +673,21 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
614
673
|
return targets.length;
|
|
615
674
|
}
|
|
616
675
|
let emitted = 0;
|
|
617
|
-
for (const el of targets) {
|
|
676
|
+
for (const { el, label: selLabel } of targets) {
|
|
677
|
+
const c = opts.colorOn ? paint : (_, s) => s;
|
|
678
|
+
// The [label] tag from -e; a null label (positional selector) prints none.
|
|
679
|
+
const tag = selLabel === null ? '' : c(COLORS.label, `[${selLabel}]`) + ' ';
|
|
618
680
|
if (opts.print) {
|
|
619
|
-
// -p shows the re-indented node only; no line:col locator
|
|
620
|
-
//
|
|
681
|
+
// -p shows the re-indented node only; no line:col locator (the [label]
|
|
682
|
+
// tag gets its own line above the block). With --parent, the original
|
|
683
|
+
// matched descendants are highlighted inside the container.
|
|
684
|
+
if (tag) out.push(tag.trimEnd());
|
|
621
685
|
out.push(prettyPrint(el, originsByTarget.get(el), opts), ''); // blank separator
|
|
622
686
|
emitted++;
|
|
623
687
|
continue;
|
|
624
688
|
}
|
|
625
689
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
626
690
|
const pos = offsetToPosition(starts, src, off);
|
|
627
|
-
const c = opts.colorOn ? paint : (_, s) => s;
|
|
628
691
|
|
|
629
692
|
// Choose the content printed for this match. --attr/--text replace the
|
|
630
693
|
// source line with the extracted value (whole value highlighted as the
|
|
@@ -651,7 +714,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
651
714
|
if (opts.lineNumber) {
|
|
652
715
|
prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.bcol)) + ' ';
|
|
653
716
|
}
|
|
654
|
-
out.push(prefix + text);
|
|
717
|
+
out.push(prefix + tag + text);
|
|
655
718
|
emitted++;
|
|
656
719
|
}
|
|
657
720
|
return emitted;
|
|
@@ -795,11 +858,271 @@ function looksBinary(buf) {
|
|
|
795
858
|
return n > 0 && suspicious / n > 0.3;
|
|
796
859
|
}
|
|
797
860
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
861
|
+
// --- rewrite output -----------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
// Myers O(ND) shortest edit script over lines. Only ever runs on the middle
|
|
864
|
+
// slice left after common prefix/suffix trimming, which transform()'s local
|
|
865
|
+
// splices keep small.
|
|
866
|
+
function myersDiff(a, b) {
|
|
867
|
+
const N = a.length, M = b.length, max = N + M, off = max;
|
|
868
|
+
if (max === 0) return [];
|
|
869
|
+
const trace = [];
|
|
870
|
+
const v = new Array(2 * max + 2).fill(0);
|
|
871
|
+
let D = -1;
|
|
872
|
+
for (let d = 0; d <= max && D < 0; d++) {
|
|
873
|
+
trace.push(v.slice());
|
|
874
|
+
for (let k = -d; k <= d; k += 2) {
|
|
875
|
+
let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]))
|
|
876
|
+
? v[off + k + 1]
|
|
877
|
+
: v[off + k - 1] + 1;
|
|
878
|
+
let y = x - k;
|
|
879
|
+
while (x < N && y < M && a[x] === b[y]) { x++; y++; }
|
|
880
|
+
v[off + k] = x;
|
|
881
|
+
if (x >= N && y >= M) { D = d; break; }
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const script = [];
|
|
885
|
+
let x = N, y = M;
|
|
886
|
+
for (let d = D; d > 0; d--) {
|
|
887
|
+
const vp = trace[d];
|
|
888
|
+
const k = x - y;
|
|
889
|
+
const prevK = (k === -d || (k !== d && vp[off + k - 1] < vp[off + k + 1])) ? k + 1 : k - 1;
|
|
890
|
+
const prevX = vp[off + prevK], prevY = prevX - prevK;
|
|
891
|
+
while (x > prevX && y > prevY) { script.push({ t: ' ', l: a[--x] }); y--; }
|
|
892
|
+
if (x === prevX) script.push({ t: '+', l: b[--y] });
|
|
893
|
+
else script.push({ t: '-', l: a[--x] });
|
|
894
|
+
}
|
|
895
|
+
while (x > 0 && y > 0) { script.push({ t: ' ', l: a[--x] }); y--; }
|
|
896
|
+
while (x > 0) script.push({ t: '-', l: a[--x] });
|
|
897
|
+
while (y > 0) script.push({ t: '+', l: b[--y] });
|
|
898
|
+
return script.reverse();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Unified diff of two documents, git-apply compatible: ---/+++ headers with
|
|
902
|
+
// a/ b/ prefixes, 3 context lines, merged hunks, and "\ No newline at end of
|
|
903
|
+
// file" markers when a side's last line is unterminated.
|
|
904
|
+
function unifiedDiff(name, oldStr, newStr) {
|
|
905
|
+
const split = s => {
|
|
906
|
+
const lines = s.split('\n');
|
|
907
|
+
const noEol = lines[lines.length - 1] !== '';
|
|
908
|
+
if (!noEol) lines.pop();
|
|
909
|
+
return { lines, noEol };
|
|
910
|
+
};
|
|
911
|
+
const A = split(oldStr), B = split(newStr);
|
|
912
|
+
const a = A.lines, b = B.lines;
|
|
913
|
+
let pre = 0;
|
|
914
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
|
|
915
|
+
let suf = 0;
|
|
916
|
+
while (suf < a.length - pre && suf < b.length - pre
|
|
917
|
+
&& a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;
|
|
918
|
+
const script = [
|
|
919
|
+
...a.slice(0, pre).map(l => ({ t: ' ', l })),
|
|
920
|
+
...myersDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf)),
|
|
921
|
+
...a.slice(a.length - suf).map(l => ({ t: ' ', l })),
|
|
922
|
+
];
|
|
923
|
+
|
|
924
|
+
// Old/new line number sitting *before* each script entry (1-based).
|
|
925
|
+
const oldPos = [], newPos = [];
|
|
926
|
+
let ol = 1, nl = 1;
|
|
927
|
+
for (const s of script) {
|
|
928
|
+
oldPos.push(ol);
|
|
929
|
+
newPos.push(nl);
|
|
930
|
+
if (s.t !== '+') ol++;
|
|
931
|
+
if (s.t !== '-') nl++;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Group changes into hunks: 3 context lines, merge when gaps are ≤ 6.
|
|
935
|
+
const C = 3;
|
|
936
|
+
const hunks = [];
|
|
937
|
+
let i = 0;
|
|
938
|
+
while (i < script.length) {
|
|
939
|
+
if (script[i].t === ' ') { i++; continue; }
|
|
940
|
+
const hStart = Math.max(0, i - C);
|
|
941
|
+
let lastChange = i;
|
|
942
|
+
let j = i + 1;
|
|
943
|
+
while (j < script.length) {
|
|
944
|
+
if (script[j].t !== ' ') { lastChange = j; j++; continue; }
|
|
945
|
+
let k = j;
|
|
946
|
+
while (k < script.length && script[k].t === ' ') k++;
|
|
947
|
+
if (k === script.length || k - j > 2 * C) break;
|
|
948
|
+
j = k;
|
|
949
|
+
}
|
|
950
|
+
const hEnd = Math.min(script.length, lastChange + C + 1);
|
|
951
|
+
hunks.push([hStart, hEnd]);
|
|
952
|
+
i = hEnd;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const out = [`--- a/${name}`, `+++ b/${name}`];
|
|
956
|
+
for (const [s, e] of hunks) {
|
|
957
|
+
let oc = 0, nc = 0;
|
|
958
|
+
for (let k = s; k < e; k++) {
|
|
959
|
+
if (script[k].t !== '+') oc++;
|
|
960
|
+
if (script[k].t !== '-') nc++;
|
|
961
|
+
}
|
|
962
|
+
const os = oc ? oldPos[s] : oldPos[s] - 1;
|
|
963
|
+
const ns = nc ? newPos[s] : newPos[s] - 1;
|
|
964
|
+
out.push(`@@ -${os},${oc} +${ns},${nc} @@`);
|
|
965
|
+
for (let k = s; k < e; k++) {
|
|
966
|
+
const { t, l } = script[k];
|
|
967
|
+
out.push(t + l);
|
|
968
|
+
const atOldEnd = t !== '+' && oldPos[k] === a.length && A.noEol;
|
|
969
|
+
const atNewEnd = t !== '-' && newPos[k] === b.length && B.noEol;
|
|
970
|
+
if (atOldEnd || atNewEnd) out.push('\');
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return out.join('\n') + '\n';
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// The rewrite program mode: transform each source and emit the document
|
|
977
|
+
// (single input) or a unified diff (any number of files). Never writes a
|
|
978
|
+
// file — apply diffs with git apply / patch (see ROADMAP Phase 9).
|
|
979
|
+
function rewriteMain(opts, files, useStdin) {
|
|
980
|
+
if (!useStdin && files.length > 1 && !opts.diff) {
|
|
981
|
+
fail('rewriting multiple files requires --diff');
|
|
982
|
+
}
|
|
983
|
+
const sources = [];
|
|
984
|
+
if (useStdin) {
|
|
985
|
+
sources.push({ name: '(standard input)', buf: readStdin() });
|
|
986
|
+
} else {
|
|
987
|
+
for (const f of files) {
|
|
988
|
+
try {
|
|
989
|
+
sources.push({ name: f, buf: fs.readFileSync(f) });
|
|
990
|
+
} catch (e) {
|
|
991
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
let totalEdits = 0;
|
|
996
|
+
const diffs = [];
|
|
997
|
+
for (const { name, buf } of sources) {
|
|
998
|
+
// A rewriter must never corrupt bytes it didn't edit: binary input is
|
|
999
|
+
// never HTML, and a lossy UTF-8 decode written back would mangle every
|
|
1000
|
+
// non-UTF-8 byte in the file — refuse both outright (exit 2).
|
|
1001
|
+
if (looksBinary(buf)) fail(`${name}: binary input; refusing to rewrite`);
|
|
1002
|
+
const src = buf.toString('utf8');
|
|
1003
|
+
if (!Buffer.from(src, 'utf8').equals(buf)) {
|
|
1004
|
+
fail(`${name}: not valid UTF-8; refusing to rewrite`);
|
|
1005
|
+
}
|
|
1006
|
+
let result;
|
|
1007
|
+
try {
|
|
1008
|
+
result = parse(src).transform(opts.selector, { ...opts.rewrite, parent: opts.parent });
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
|
|
1011
|
+
fail(`invalid selector: ${opts.selector}`);
|
|
1012
|
+
}
|
|
1013
|
+
fail(e.message);
|
|
1014
|
+
}
|
|
1015
|
+
totalEdits += result.edits.length;
|
|
1016
|
+
if (opts.diff) {
|
|
1017
|
+
if (result.edits.length) diffs.push(unifiedDiff(name, src, result.html));
|
|
1018
|
+
} else {
|
|
1019
|
+
// Filter-friendly: the document is always emitted, changed or not
|
|
1020
|
+
// (like sed); the exit status says whether anything was edited.
|
|
1021
|
+
process.stdout.write(result.html);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (diffs.length) process.stdout.write(diffs.join(''));
|
|
1025
|
+
process.exitCode = totalEdits > 0 ? 0 : 1;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// The watch program mode: rerun the search whenever a watched path changes.
|
|
1029
|
+
// Native recursive fs.watch, no polling (see ROADMAP Phase 10; caveat:
|
|
1030
|
+
// network/virtual filesystems may not deliver events). Output adapts like
|
|
1031
|
+
// --color=auto does: a TTY gets clear+reprint, a pipe gets append mode with
|
|
1032
|
+
// `== HH:MM:SS … ==` separators (--no-clear forces append on a TTY), and
|
|
1033
|
+
// --json gets an NDJSON stream — {"event":"run",...} then the match records.
|
|
1034
|
+
// Runs until SIGINT (exit 0, like watch(1)).
|
|
1035
|
+
function watchMain(opts) {
|
|
1036
|
+
const clearMode = !opts.json && !opts.noClear && Boolean(process.stdout.isTTY);
|
|
1037
|
+
const renderOut = out => (!out.length ? ''
|
|
1038
|
+
: opts.nul && (opts.filesWithMatches || opts.filesWithoutMatch)
|
|
1039
|
+
? out.map(s => s + '\0').join('')
|
|
1040
|
+
: out.join('\n') + '\n');
|
|
1041
|
+
|
|
1042
|
+
const run = changed => {
|
|
1043
|
+
// Re-walk every run: a rerun sees exactly what a fresh invocation would.
|
|
1044
|
+
const { files } = resolveFiles(opts);
|
|
1045
|
+
const out = [];
|
|
1046
|
+
let total = 0;
|
|
1047
|
+
try {
|
|
1048
|
+
total = searchFiles(opts, files, out);
|
|
1049
|
+
} catch (e) {
|
|
1050
|
+
if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
|
|
1051
|
+
fail(`invalid selector: ${opts.selector != null
|
|
1052
|
+
? opts.selector
|
|
1053
|
+
: opts.selectors.map(s => s.selector).join(', ')}`);
|
|
1054
|
+
}
|
|
1055
|
+
fail(e.message);
|
|
1056
|
+
}
|
|
1057
|
+
if (opts.json) {
|
|
1058
|
+
process.stdout.write(JSON.stringify({
|
|
1059
|
+
event: 'run',
|
|
1060
|
+
changed: changed || null,
|
|
1061
|
+
matches: total,
|
|
1062
|
+
}) + '\n' + renderOut(out));
|
|
1063
|
+
} else if (clearMode) {
|
|
1064
|
+
process.stdout.write('\x1b[2J\x1b[H' + (out.length ? renderOut(out) : 'cssgrep: no matches\n'));
|
|
1065
|
+
} else {
|
|
1066
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
1067
|
+
process.stdout.write(`== ${ts} ${changed || 'watching'} ==\n` + renderOut(out));
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// Debounce change bursts (editors often fire several events per save).
|
|
1072
|
+
let timer = null;
|
|
1073
|
+
let pending = null;
|
|
1074
|
+
const schedule = changed => {
|
|
1075
|
+
pending = changed;
|
|
1076
|
+
clearTimeout(timer);
|
|
1077
|
+
timer = setTimeout(() => { const c = pending; pending = null; run(c); }, 80);
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
// Directory targets get one recursive watcher each. Explicit file targets
|
|
1081
|
+
// are watched via their parent directory, filtered by name — editors often
|
|
1082
|
+
// save by rename-replace, which would orphan a watcher on the file itself.
|
|
1083
|
+
const watchers = [];
|
|
1084
|
+
const fileTargets = new Map(); // parent dir -> Set of basenames
|
|
1085
|
+
const targets = opts.paths.length ? opts.paths : ['.'];
|
|
1086
|
+
for (const p of targets) {
|
|
1087
|
+
const st = fs.statSync(p, { throwIfNoEntry: false });
|
|
1088
|
+
if (!st) {
|
|
1089
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: ${p}: no such file or directory\n`);
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
if (st.isDirectory()) {
|
|
1093
|
+
watchers.push(fs.watch(p, { recursive: true },
|
|
1094
|
+
(ev, f) => schedule(f ? path.join(p, f) : p)));
|
|
1095
|
+
} else {
|
|
1096
|
+
const dir = path.dirname(p);
|
|
1097
|
+
if (!fileTargets.has(dir)) fileTargets.set(dir, new Set());
|
|
1098
|
+
fileTargets.get(dir).add(path.basename(p));
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
for (const [dir, bases] of fileTargets) {
|
|
1102
|
+
watchers.push(fs.watch(dir,
|
|
1103
|
+
(ev, f) => { if (!f || bases.has(f)) schedule(f ? path.join(dir, f) : dir); }));
|
|
1104
|
+
}
|
|
1105
|
+
if (!watchers.length) fail('--watch: nothing to watch');
|
|
1106
|
+
for (const w of watchers) {
|
|
1107
|
+
w.on('error', e => {
|
|
1108
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: watch: ${e.message}\n`);
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
process.on('SIGINT', () => {
|
|
1113
|
+
for (const w of watchers) w.close();
|
|
1114
|
+
clearTimeout(timer);
|
|
1115
|
+
process.exitCode = 0; // watch(1) convention
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
run(null); // initial pass, then wait
|
|
1119
|
+
}
|
|
801
1120
|
|
|
802
|
-
|
|
1121
|
+
// Resolve the paths to search into a concrete file list (or stdin). Watch
|
|
1122
|
+
// mode calls this again on every rerun, so a rerun sees exactly what a fresh
|
|
1123
|
+
// invocation would: new files picked up per the include/ignore/--ext rules,
|
|
1124
|
+
// deleted ones dropped.
|
|
1125
|
+
function resolveFiles(opts) {
|
|
803
1126
|
let files = [];
|
|
804
1127
|
let useStdin = false;
|
|
805
1128
|
if (opts.recursive) {
|
|
@@ -816,9 +1139,57 @@ function main() {
|
|
|
816
1139
|
} else {
|
|
817
1140
|
useStdin = true;
|
|
818
1141
|
}
|
|
1142
|
+
return { files, useStdin };
|
|
1143
|
+
}
|
|
819
1144
|
|
|
1145
|
+
// One search pass over a file list; appends output lines to `out` and returns
|
|
1146
|
+
// the match total. Shared by the single-shot main path and each watch rerun.
|
|
1147
|
+
function searchFiles(opts, files, out) {
|
|
820
1148
|
// A label (file prefix) is shown when searching more than one file; -H forces
|
|
821
1149
|
// it on (even for one file or stdin) and --no-filename forces it off.
|
|
1150
|
+
const showLabel = opts.withFilename ? true
|
|
1151
|
+
: opts.noFilename ? false
|
|
1152
|
+
: files.length > 1;
|
|
1153
|
+
let total = 0;
|
|
1154
|
+
// -M/--max-total: remaining matches allowed across all files (Infinity = off).
|
|
1155
|
+
const room = () => (opts.maxTotal ? Math.max(0, opts.maxTotal - total) : Infinity);
|
|
1156
|
+
for (const f of files) {
|
|
1157
|
+
if (room() <= 0) break; // -M: global budget exhausted
|
|
1158
|
+
let buf;
|
|
1159
|
+
try {
|
|
1160
|
+
buf = fs.readFileSync(f); // Buffer: sniff before decoding
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
if (looksBinary(buf)) {
|
|
1166
|
+
if (!opts.noMessages && !opts.quiet) {
|
|
1167
|
+
process.stderr.write(`cssgrep: ${f}: binary file (skipped)\n`);
|
|
1168
|
+
}
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
|
|
1172
|
+
if (opts.quiet && total > 0) break; // -q: first match decides the status
|
|
1173
|
+
}
|
|
1174
|
+
return total;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function main() {
|
|
1178
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
1179
|
+
resolveSelectorAndPaths(opts);
|
|
1180
|
+
|
|
1181
|
+
const { files, useStdin } = resolveFiles(opts);
|
|
1182
|
+
|
|
1183
|
+
if (opts.rewriteActive) {
|
|
1184
|
+
rewriteMain(opts, files, useStdin);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (opts.watch) {
|
|
1188
|
+
if (useStdin) fail('--watch requires file or directory paths');
|
|
1189
|
+
watchMain(opts);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
822
1193
|
const showLabel = opts.withFilename ? true
|
|
823
1194
|
: opts.noFilename ? false
|
|
824
1195
|
: (!useStdin && files.length > 1);
|
|
@@ -826,9 +1197,6 @@ function main() {
|
|
|
826
1197
|
const out = [];
|
|
827
1198
|
let total = 0;
|
|
828
1199
|
|
|
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
1200
|
try {
|
|
833
1201
|
if (useStdin) {
|
|
834
1202
|
const buf = readStdin();
|
|
@@ -837,31 +1205,18 @@ function main() {
|
|
|
837
1205
|
process.stderr.write('cssgrep: (standard input): binary input (skipped)\n');
|
|
838
1206
|
}
|
|
839
1207
|
} else {
|
|
840
|
-
total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out,
|
|
1208
|
+
total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out,
|
|
1209
|
+
opts.maxTotal || Infinity);
|
|
841
1210
|
}
|
|
842
1211
|
} 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
|
-
}
|
|
1212
|
+
total = searchFiles(opts, files, out);
|
|
861
1213
|
}
|
|
862
1214
|
} catch (e) {
|
|
863
1215
|
if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
|
|
864
|
-
|
|
1216
|
+
const shown = opts.selector != null
|
|
1217
|
+
? opts.selector
|
|
1218
|
+
: opts.selectors.map(s => s.selector).join(', ');
|
|
1219
|
+
fail(`invalid selector: ${shown}`);
|
|
865
1220
|
}
|
|
866
1221
|
fail(e.message);
|
|
867
1222
|
}
|