cssgrep 1.1.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 +77 -1
- package/README.md +182 -7
- package/{index.js → cli.js} +593 -162
- package/completions/_cssgrep +10 -0
- package/completions/cssgrep.bash +2 -2
- package/completions/cssgrep.fish +10 -0
- package/index.d.ts +93 -0
- package/lib.js +403 -0
- package/man/cssgrep.1 +181 -12
- 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).
|
|
@@ -35,11 +41,14 @@ Options:
|
|
|
35
41
|
-i, --ignore <glob> Skip files/dirs matching <glob> when recursing (repeatable).
|
|
36
42
|
--exclude <glob> Alias for --ignore.
|
|
37
43
|
--ignore-file <path> Read ignore globs from <path> (one per line, # comments).
|
|
44
|
+
-S, --follow Follow symbolic links when recursing with -r
|
|
45
|
+
(default: skip them; loops are detected).
|
|
38
46
|
-n, --line-number Prefix each match with its line:col (excludes -c, -p).
|
|
39
47
|
-p, --print Pretty-print the matched node's HTML above its location.
|
|
40
48
|
--attr <name> Print the value of attribute <name> (skips nodes without it).
|
|
41
49
|
--text Print the matched node's text content (whitespace collapsed).
|
|
42
|
-
--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).
|
|
43
52
|
--parent <n> Report the n-th ancestor of each match instead (dedup'd).
|
|
44
53
|
-w, --max-width <n> Truncate the shown line to <n> columns (ellipsis added).
|
|
45
54
|
-A, --after-context <n> Print <n> source lines after each match.
|
|
@@ -47,7 +56,8 @@ Options:
|
|
|
47
56
|
-C, --context <n> Print <n> source lines before and after each match.
|
|
48
57
|
-m, --max-count <n> Stop after <n> matches per file.
|
|
49
58
|
-M, --max-total <n> Stop after <n> matches in total (across all files).
|
|
50
|
-
-c, --count Print only a count of matches (per file when
|
|
59
|
+
-c, --count Print only a count of matches (per file when
|
|
60
|
+
relevant, zeros included, like grep).
|
|
51
61
|
-l, --files-with-matches Print only the names of files that have a match.
|
|
52
62
|
-L, --files-without-match Print only the names of files with no match.
|
|
53
63
|
-q, --quiet Print nothing; exit 0 on first match, 1 if none.
|
|
@@ -55,10 +65,33 @@ Options:
|
|
|
55
65
|
-0, --null Separate the file name with a NUL byte (for xargs -0).
|
|
56
66
|
-H, --with-filename Always print the file name prefix (even for one file).
|
|
57
67
|
--no-filename Never print the file name prefix (even for many files).
|
|
58
|
-
--color[=<when>] Colorize output: auto (default
|
|
68
|
+
--color[=<when>] Colorize output: auto (default, also what a bare
|
|
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.
|
|
59
78
|
-h, --help Show this help.
|
|
60
79
|
-V, --version Show version and exit.
|
|
61
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
|
+
|
|
62
95
|
Short flags combine (-rn) and a value attaches to its flag (-w100) or follows it
|
|
63
96
|
(-w 100); a value-taking flag may close a cluster (-rnw100). Long options take a
|
|
64
97
|
value with = or as the next word (--max-width=100, --ext htm).
|
|
@@ -73,13 +106,22 @@ function fail(msg) {
|
|
|
73
106
|
process.exit(2);
|
|
74
107
|
}
|
|
75
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
|
+
|
|
76
116
|
// ANSI SGR codes matching grep's default scheme: bold-red match, magenta
|
|
77
|
-
// 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.
|
|
78
119
|
const COLORS = {
|
|
79
120
|
match: '1;31',
|
|
80
121
|
file: '35',
|
|
81
122
|
line: '32',
|
|
82
123
|
sep: '36',
|
|
124
|
+
label: '33',
|
|
83
125
|
};
|
|
84
126
|
|
|
85
127
|
function paint(code, str) {
|
|
@@ -89,9 +131,11 @@ function paint(code, str) {
|
|
|
89
131
|
function parseArgs(argv) {
|
|
90
132
|
const opts = {
|
|
91
133
|
selector: null,
|
|
134
|
+
selectors: [],
|
|
92
135
|
positionals: [],
|
|
93
136
|
paths: [],
|
|
94
137
|
recursive: false,
|
|
138
|
+
follow: false,
|
|
95
139
|
exts: ['html', 'htm'],
|
|
96
140
|
extGiven: false,
|
|
97
141
|
maxDepth: 0,
|
|
@@ -117,6 +161,10 @@ function parseArgs(argv) {
|
|
|
117
161
|
noFilename: false,
|
|
118
162
|
noMessages: false,
|
|
119
163
|
color: 'auto',
|
|
164
|
+
rewrite: { renameTag: null, removeAttr: [], setAttr: {}, removeClass: [], addClass: [] },
|
|
165
|
+
diff: false,
|
|
166
|
+
watch: false,
|
|
167
|
+
noClear: false,
|
|
120
168
|
};
|
|
121
169
|
const setExts = v => {
|
|
122
170
|
opts.extGiven = true;
|
|
@@ -136,6 +184,16 @@ function parseArgs(argv) {
|
|
|
136
184
|
const setAfter = v => { opts.after = boundedInt(v, '--after-context', 0); };
|
|
137
185
|
const setBefore = v => { opts.before = boundedInt(v, '--before-context', 0); };
|
|
138
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
|
+
};
|
|
139
197
|
const addIgnore = v => { const c = compileIgnore(v); if (c) opts.ignore.push(c); };
|
|
140
198
|
const addInclude = v => { const c = compileIgnore(v); if (c) opts.include.push(c); };
|
|
141
199
|
const addIgnoreFile = v => {
|
|
@@ -147,22 +205,28 @@ function parseArgs(argv) {
|
|
|
147
205
|
// Short flags that take a value (rest of the cluster, or the next argument).
|
|
148
206
|
const shortValueFlags = {
|
|
149
207
|
w: setMaxWidth, m: setMaxCount, M: setMaxTotal, A: setAfter, B: setBefore, C: setContext,
|
|
150
|
-
i: addIgnore,
|
|
208
|
+
i: addIgnore, e: addSelector,
|
|
151
209
|
};
|
|
152
210
|
for (let i = 0; i < argv.length; i++) {
|
|
153
211
|
const a = argv[i];
|
|
154
212
|
|
|
155
213
|
// Long options: --name or --name=value. A missing inline value is taken
|
|
156
|
-
// from the next argument (except --color, where a bare flag means "
|
|
214
|
+
// from the next argument (except --color, where a bare flag means "auto",
|
|
215
|
+
// as in GNU grep).
|
|
157
216
|
if (a.startsWith('--') && a.length > 2) {
|
|
158
217
|
const eq = a.indexOf('=');
|
|
159
218
|
const name = eq === -1 ? a : a.slice(0, eq);
|
|
160
219
|
const inline = eq === -1 ? null : a.slice(eq + 1);
|
|
161
|
-
const value = () =>
|
|
220
|
+
const value = () => {
|
|
221
|
+
if (inline != null) return inline;
|
|
222
|
+
if (i + 1 >= argv.length) fail(`option ${name} requires a value`);
|
|
223
|
+
return argv[++i];
|
|
224
|
+
};
|
|
162
225
|
switch (name) {
|
|
163
226
|
case '--help': process.stdout.write(USAGE + '\n'); process.exit(0); break;
|
|
164
227
|
case '--version': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
|
|
165
228
|
case '--recursive': opts.recursive = true; break;
|
|
229
|
+
case '--follow': opts.follow = true; break;
|
|
166
230
|
case '--line-number': opts.lineNumber = true; break;
|
|
167
231
|
case '--print': opts.print = true; break;
|
|
168
232
|
case '--count': opts.count = true; break;
|
|
@@ -181,14 +245,32 @@ function parseArgs(argv) {
|
|
|
181
245
|
case '--max-count': setMaxCount(value()); break;
|
|
182
246
|
case '--max-total': setMaxTotal(value()); break;
|
|
183
247
|
case '--max-depth': setMaxDepth(value()); break;
|
|
184
|
-
|
|
248
|
+
// htmlparser2 lowercases HTML attribute names, so match case-insensitively.
|
|
249
|
+
case '--attr': opts.attr = value().toLowerCase(); break;
|
|
185
250
|
case '--text': opts.text = true; break;
|
|
186
251
|
case '--json': opts.json = true; break;
|
|
187
252
|
case '--parent': setParent(value()); break;
|
|
188
253
|
case '--after-context': setAfter(value()); break;
|
|
189
254
|
case '--before-context': setBefore(value()); break;
|
|
190
255
|
case '--context': setContext(value()); break;
|
|
191
|
-
case '--color': case '--colour': opts.color = inline != null ? inline : '
|
|
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;
|
|
192
274
|
default: fail(`unknown option: ${name}`);
|
|
193
275
|
}
|
|
194
276
|
continue;
|
|
@@ -202,6 +284,7 @@ function parseArgs(argv) {
|
|
|
202
284
|
const ch = a[j];
|
|
203
285
|
if (shortValueFlags[ch]) {
|
|
204
286
|
const rest = a.slice(j + 1); // attached value, if any
|
|
287
|
+
if (rest === '' && i + 1 >= argv.length) fail(`option -${ch} requires a value`);
|
|
205
288
|
shortValueFlags[ch](rest !== '' ? rest : argv[++i]);
|
|
206
289
|
break; // value swallowed the cluster tail
|
|
207
290
|
}
|
|
@@ -209,6 +292,7 @@ function parseArgs(argv) {
|
|
|
209
292
|
case 'h': process.stdout.write(USAGE + '\n'); process.exit(0); break;
|
|
210
293
|
case 'V': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
|
|
211
294
|
case 'r': opts.recursive = true; break;
|
|
295
|
+
case 'S': opts.follow = true; break;
|
|
212
296
|
case 'n': opts.lineNumber = true; break;
|
|
213
297
|
case 'p': opts.print = true; break;
|
|
214
298
|
case 'c': opts.count = true; break;
|
|
@@ -218,6 +302,7 @@ function parseArgs(argv) {
|
|
|
218
302
|
case '0': case 'Z': opts.nul = true; break;
|
|
219
303
|
case 'H': opts.withFilename = true; break;
|
|
220
304
|
case 's': opts.noMessages = true; break;
|
|
305
|
+
case 'v': failInvert('-v'); break;
|
|
221
306
|
default: fail(`unknown option: -${ch}`);
|
|
222
307
|
}
|
|
223
308
|
}
|
|
@@ -226,7 +311,9 @@ function parseArgs(argv) {
|
|
|
226
311
|
|
|
227
312
|
opts.positionals.push(a);
|
|
228
313
|
}
|
|
229
|
-
if (opts.positionals.length === 0
|
|
314
|
+
if (opts.positionals.length === 0 && !opts.selectors.length) {
|
|
315
|
+
fail('no selector given (try --help)');
|
|
316
|
+
}
|
|
230
317
|
// Aggregate modes each suppress per-match output, so at most one may apply.
|
|
231
318
|
const aggregates = [opts.count, opts.filesWithMatches, opts.filesWithoutMatch, opts.quiet]
|
|
232
319
|
.filter(Boolean).length;
|
|
@@ -246,6 +333,34 @@ function parseArgs(argv) {
|
|
|
246
333
|
}
|
|
247
334
|
if (opts.lineNumber && opts.count) fail('-n cannot be combined with -c');
|
|
248
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
|
+
}
|
|
249
364
|
if (!['auto', 'always', 'never'].includes(opts.color)) {
|
|
250
365
|
fail(`invalid --color value: ${opts.color} (expected auto, always or never)`);
|
|
251
366
|
}
|
|
@@ -265,6 +380,13 @@ function parseArgs(argv) {
|
|
|
265
380
|
// `grepprg`, which appends args in its own order). Fall back to
|
|
266
381
|
// "selector is the first positional" when the split is unclear.
|
|
267
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
|
+
}
|
|
268
390
|
const pos = opts.positionals;
|
|
269
391
|
const onDisk = [];
|
|
270
392
|
const notOnDisk = [];
|
|
@@ -283,52 +405,18 @@ function resolveSelectorAndPaths(opts) {
|
|
|
283
405
|
}
|
|
284
406
|
}
|
|
285
407
|
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
return starts;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function offsetToPosition(starts, src, offset) {
|
|
297
|
-
// binary search for the greatest line start <= offset
|
|
298
|
-
let lo = 0, hi = starts.length - 1;
|
|
299
|
-
while (lo < hi) {
|
|
300
|
-
const mid = (lo + hi + 1) >> 1;
|
|
301
|
-
if (starts[mid] <= offset) lo = mid;
|
|
302
|
-
else hi = mid - 1;
|
|
303
|
-
}
|
|
304
|
-
const lineStart = starts[lo];
|
|
305
|
-
let lineEnd = src.indexOf('\n', lineStart);
|
|
306
|
-
if (lineEnd === -1) lineEnd = src.length;
|
|
307
|
-
// strip a trailing \r so CRLF files render cleanly
|
|
308
|
-
let text = src.slice(lineStart, lineEnd);
|
|
309
|
-
if (text.endsWith('\r')) text = text.slice(0, -1);
|
|
310
|
-
return {
|
|
311
|
-
line: lo + 1, // 1-based
|
|
312
|
-
col: offset - lineStart + 1, // 1-based
|
|
313
|
-
text,
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Text of a 1-based line number, with the trailing \r stripped (CRLF), plus the
|
|
318
|
-
// line's byte start (so a node's offset maps to a column within it).
|
|
319
|
-
function lineTextAt(starts, src, lineNo) {
|
|
320
|
-
const lineStart = starts[lineNo - 1];
|
|
321
|
-
let lineEnd = src.indexOf('\n', lineStart);
|
|
322
|
-
if (lineEnd === -1) lineEnd = src.length;
|
|
323
|
-
let text = src.slice(lineStart, lineEnd);
|
|
324
|
-
if (text.endsWith('\r')) text = text.slice(0, -1);
|
|
325
|
-
return { lineStart, text };
|
|
408
|
+
// Never cut between the halves of a surrogate pair: a lone half is invalid
|
|
409
|
+
// Unicode and serializes as U+FFFD garbage. Backing off one unit loses at most
|
|
410
|
+
// one display column.
|
|
411
|
+
function safeCut(text, at) {
|
|
412
|
+
const c = text.charCodeAt(at - 1);
|
|
413
|
+
return c >= 0xd800 && c <= 0xdbff ? at - 1 : at;
|
|
326
414
|
}
|
|
327
415
|
|
|
328
416
|
function truncate(text, maxWidth) {
|
|
329
417
|
if (!maxWidth || text.length <= maxWidth) return text;
|
|
330
|
-
if (maxWidth <= 1) return text.slice(0, maxWidth);
|
|
331
|
-
return text.slice(0, maxWidth - 1) + '…'; // …
|
|
418
|
+
if (maxWidth <= 1) return text.slice(0, safeCut(text, maxWidth));
|
|
419
|
+
return text.slice(0, safeCut(text, maxWidth - 1)) + '…'; // …
|
|
332
420
|
}
|
|
333
421
|
|
|
334
422
|
// Produce the visible match text: truncate first (so --max-width still measures
|
|
@@ -350,43 +438,6 @@ function renderText(pos, off, nodeEnd, opts) {
|
|
|
350
438
|
return vis.slice(0, s) + paint(COLORS.match, vis.slice(s, e)) + vis.slice(e);
|
|
351
439
|
}
|
|
352
440
|
|
|
353
|
-
// Concatenate the text of a node and all its descendants (dependency-free,
|
|
354
|
-
// rather than pulling in domutils). Used by --text.
|
|
355
|
-
function textOf(node) {
|
|
356
|
-
if (node.type === 'text') return node.data || '';
|
|
357
|
-
if (!node.children) return '';
|
|
358
|
-
let s = '';
|
|
359
|
-
for (const child of node.children) s += textOf(child);
|
|
360
|
-
return s;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const collapseWs = s => s.replace(/\s+/g, ' ').trim();
|
|
364
|
-
|
|
365
|
-
const isElement = n => n && (n.type === 'tag' || n.type === 'script' || n.type === 'style');
|
|
366
|
-
|
|
367
|
-
// Climb n element levels from el, clamping at the document root.
|
|
368
|
-
function ancestor(el, n) {
|
|
369
|
-
let node = el;
|
|
370
|
-
for (let k = 0; k < n; k++) {
|
|
371
|
-
if (!isElement(node.parent)) break;
|
|
372
|
-
node = node.parent;
|
|
373
|
-
}
|
|
374
|
-
return node;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// --parent re-points each match to its n-th ancestor; dedup by identity so a
|
|
378
|
-
// shared container is reported once, preserving first-seen order.
|
|
379
|
-
function retarget(nodes, opts) {
|
|
380
|
-
if (!opts.parent) return nodes;
|
|
381
|
-
const seen = new Set();
|
|
382
|
-
const result = [];
|
|
383
|
-
for (const el of nodes) {
|
|
384
|
-
const a = ancestor(el, opts.parent);
|
|
385
|
-
if (!seen.has(a)) { seen.add(a); result.push(a); }
|
|
386
|
-
}
|
|
387
|
-
return result;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
441
|
// Sentinels marking where a highlighted node begins/ends. They are injected as
|
|
391
442
|
// HTML *comment* nodes (not text) so js-beautify keeps the surrounding block
|
|
392
443
|
// layout — text markers would make adjacent block elements collapse inline.
|
|
@@ -476,14 +527,14 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
476
527
|
const lineCount = src.length === 0 ? 0 : (src.endsWith('\n') ? starts.length - 1 : starts.length);
|
|
477
528
|
|
|
478
529
|
// Map each match line to a representative node span (the first match on it),
|
|
479
|
-
// which drives the in-line highlight when
|
|
530
|
+
// which drives the in-line highlight — and the [label] tag — when emitting.
|
|
480
531
|
const info = new Map();
|
|
481
|
-
for (const el of targets) {
|
|
532
|
+
for (const { el, label: selLabel } of targets) {
|
|
482
533
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
483
534
|
const pos = offsetToPosition(starts, src, off);
|
|
484
535
|
if (info.has(pos.line)) continue;
|
|
485
536
|
const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
|
|
486
|
-
info.set(pos.line, { off, nodeEnd, pos });
|
|
537
|
+
info.set(pos.line, { off, nodeEnd, pos, selLabel });
|
|
487
538
|
}
|
|
488
539
|
|
|
489
540
|
// Expand match lines to [L-before, L+after] windows and merge adjacent ones.
|
|
@@ -507,11 +558,12 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
507
558
|
if (label) prefix += c(COLORS.file, label) + (opts.nul ? '\0' : sepColored);
|
|
508
559
|
if (opts.lineNumber) {
|
|
509
560
|
prefix += c(COLORS.line, String(L));
|
|
510
|
-
prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.
|
|
561
|
+
prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + ' '
|
|
511
562
|
: c(COLORS.sep, '-');
|
|
512
563
|
}
|
|
564
|
+
const tag = m && m.selLabel != null ? c(COLORS.label, `[${m.selLabel}]`) + ' ' : '';
|
|
513
565
|
const body = m
|
|
514
|
-
? renderText(m.pos, m.off, m.nodeEnd, opts)
|
|
566
|
+
? tag + renderText(m.pos, m.off, m.nodeEnd, opts) // highlight + truncate
|
|
515
567
|
: truncate(lineTextAt(starts, src, L).text, opts.maxWidth);
|
|
516
568
|
out.push(prefix + body);
|
|
517
569
|
}
|
|
@@ -522,12 +574,29 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
522
574
|
// used by the aggregate modes (-l/-L/-c). `showLabel` decides whether per-match
|
|
523
575
|
// lines carry a `file:` prefix (only when more than one file is searched).
|
|
524
576
|
function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
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;
|
|
531
600
|
// Aggregate modes suppress per-match output entirely.
|
|
532
601
|
if (opts.quiet) return found; // status only
|
|
533
602
|
if (opts.filesWithMatches) { if (found) out.push(name); return found; }
|
|
@@ -537,60 +606,88 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
537
606
|
// -m/--max-count caps matches per source; `limit` is the remaining global
|
|
538
607
|
// budget from -M/--max-total (Infinity when neither applies).
|
|
539
608
|
const cap = Math.min(opts.maxCount || Infinity, limit);
|
|
540
|
-
const limited = Number.isFinite(cap) ?
|
|
609
|
+
const limited = Number.isFinite(cap) ? records.slice(0, cap) : records;
|
|
541
610
|
if (opts.count) {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
611
|
+
// grep parity: every searched file reports a count, zeros included, so
|
|
612
|
+
// scripts get one row per file (exit status still says whether anything
|
|
613
|
+
// matched at all).
|
|
614
|
+
const fileSep = opts.nul ? '\0' : ':';
|
|
615
|
+
out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
|
|
546
616
|
return limited.length;
|
|
547
617
|
}
|
|
548
|
-
const starts = opts.print ? null :
|
|
549
|
-
// --parent re-targets matches to ancestors (no-op without it).
|
|
550
|
-
//
|
|
551
|
-
|
|
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
|
+
}
|
|
552
637
|
// For --parent + -p, remember which original matches sit under each ancestor
|
|
553
638
|
// so they can be highlighted inside the printed container.
|
|
554
639
|
const originsByTarget = new Map();
|
|
555
640
|
if (opts.parent && opts.print) {
|
|
556
|
-
for (const
|
|
557
|
-
const a = ancestor(el, opts.parent);
|
|
641
|
+
for (const r of limited) {
|
|
642
|
+
const a = ancestor(r.el, opts.parent);
|
|
558
643
|
if (!originsByTarget.has(a)) originsByTarget.set(a, []);
|
|
559
|
-
originsByTarget.get(a).push(el);
|
|
644
|
+
originsByTarget.get(a).push(r.el);
|
|
560
645
|
}
|
|
561
646
|
}
|
|
562
647
|
if (opts.before > 0 || opts.after > 0) {
|
|
563
648
|
emitContext(src, starts, name, showLabel, targets, opts, out);
|
|
564
649
|
return limited.length;
|
|
565
650
|
}
|
|
651
|
+
// From here on the return value is the number of *emitted* records (which is
|
|
652
|
+
// what the -M budget and the exit status should count): --attr can skip
|
|
653
|
+
// matches lacking the attribute, and --parent dedup can merge several
|
|
654
|
+
// matches into one printed ancestor.
|
|
566
655
|
if (opts.json) {
|
|
567
656
|
// NDJSON: one self-contained record per match. `html` is the exact source
|
|
568
657
|
// slice; newlines are escaped by JSON.stringify, so each record stays on
|
|
569
|
-
// one line. Ignores --color and -n (line/col are always present).
|
|
570
|
-
|
|
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) {
|
|
571
661
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
572
662
|
const pos = offsetToPosition(starts, src, off);
|
|
573
663
|
const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
|
|
574
664
|
out.push(JSON.stringify({
|
|
575
665
|
file: name,
|
|
576
666
|
line: pos.line,
|
|
577
|
-
col: pos.
|
|
667
|
+
col: pos.bcol,
|
|
668
|
+
...(selLabel !== null && { label: selLabel }),
|
|
578
669
|
html: src.slice(off, nodeEnd),
|
|
579
670
|
text: collapseWs(textOf(el)),
|
|
580
671
|
}));
|
|
581
672
|
}
|
|
582
|
-
return
|
|
673
|
+
return targets.length;
|
|
583
674
|
}
|
|
584
|
-
|
|
675
|
+
let emitted = 0;
|
|
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}]`) + ' ';
|
|
585
680
|
if (opts.print) {
|
|
586
|
-
// -p shows the re-indented node only; no line:col locator
|
|
587
|
-
//
|
|
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());
|
|
588
685
|
out.push(prettyPrint(el, originsByTarget.get(el), opts), ''); // blank separator
|
|
686
|
+
emitted++;
|
|
589
687
|
continue;
|
|
590
688
|
}
|
|
591
689
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
592
690
|
const pos = offsetToPosition(starts, src, off);
|
|
593
|
-
const c = opts.colorOn ? paint : (_, s) => s;
|
|
594
691
|
|
|
595
692
|
// Choose the content printed for this match. --attr/--text replace the
|
|
596
693
|
// source line with the extracted value (whole value highlighted as the
|
|
@@ -615,11 +712,12 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
615
712
|
let prefix = '';
|
|
616
713
|
if (label) prefix += c(COLORS.file, label) + fileSep;
|
|
617
714
|
if (opts.lineNumber) {
|
|
618
|
-
prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.
|
|
715
|
+
prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.bcol)) + ' ';
|
|
619
716
|
}
|
|
620
|
-
out.push(prefix + text);
|
|
717
|
+
out.push(prefix + tag + text);
|
|
718
|
+
emitted++;
|
|
621
719
|
}
|
|
622
|
-
return
|
|
720
|
+
return emitted;
|
|
623
721
|
}
|
|
624
722
|
|
|
625
723
|
// Translate a glob to a regex body. `*` matches within a path segment, `**`
|
|
@@ -632,9 +730,15 @@ function globToRegex(glob) {
|
|
|
632
730
|
const c = glob[i];
|
|
633
731
|
if (c === '*') {
|
|
634
732
|
if (glob[i + 1] === '*') { // ** (crosses /)
|
|
635
|
-
re += '.*';
|
|
636
733
|
i++;
|
|
637
|
-
if (glob[i + 1] === '/')
|
|
734
|
+
if (glob[i + 1] === '/') {
|
|
735
|
+
// `**/` spans whole segments (or none): `**/foo` matches `foo` and
|
|
736
|
+
// `a/b/foo`, but not `barfoo` — the `.*` must end at a `/`.
|
|
737
|
+
re += '(?:.*/)?';
|
|
738
|
+
i++;
|
|
739
|
+
} else {
|
|
740
|
+
re += '.*';
|
|
741
|
+
}
|
|
638
742
|
} else {
|
|
639
743
|
re += '[^/]*';
|
|
640
744
|
}
|
|
@@ -672,16 +776,25 @@ function compileIgnore(pattern) {
|
|
|
672
776
|
}
|
|
673
777
|
|
|
674
778
|
// True if name/path matches any compiled glob matcher (shared by --ignore/
|
|
675
|
-
// --exclude and --include).
|
|
779
|
+
// --exclude and --include). Globs always use `/` as the separator (gitignore
|
|
780
|
+
// semantics), so normalize Windows backslash paths before matching.
|
|
676
781
|
function matchesAny(name, full, isDir, matchers) {
|
|
782
|
+
const fullPosix = path.sep === '/' ? full : full.split(path.sep).join('/');
|
|
677
783
|
for (const m of matchers) {
|
|
678
784
|
if (m.dirOnly && !isDir) continue;
|
|
679
|
-
if (m.re.test(m.hasSlash ?
|
|
785
|
+
if (m.re.test(m.hasSlash ? fullPosix : name)) return true;
|
|
680
786
|
}
|
|
681
787
|
return false;
|
|
682
788
|
}
|
|
683
789
|
|
|
684
|
-
|
|
790
|
+
// `visited` (only with -S/--follow) holds the realpath of every directory
|
|
791
|
+
// already entered, so symlink cycles — and two links to the same physical
|
|
792
|
+
// directory — are traversed once. Without --follow, symlinks are skipped.
|
|
793
|
+
function* walk(dir, opts, depth = 1, visited = null) {
|
|
794
|
+
if (opts.follow && visited === null) {
|
|
795
|
+
visited = new Set();
|
|
796
|
+
try { visited.add(fs.realpathSync(dir)); } catch (e) { /* walked anyway */ }
|
|
797
|
+
}
|
|
685
798
|
let entries;
|
|
686
799
|
try {
|
|
687
800
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -689,14 +802,30 @@ function* walk(dir, opts, depth = 1) {
|
|
|
689
802
|
if (!opts.noMessages) process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
|
|
690
803
|
return;
|
|
691
804
|
}
|
|
805
|
+
// readdir order is filesystem-dependent; sort so output order (and therefore
|
|
806
|
+
// -M/-m truncation points) is stable across platforms.
|
|
807
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
692
808
|
for (const e of entries) {
|
|
693
809
|
const full = path.join(dir, e.name);
|
|
694
|
-
|
|
810
|
+
let isDir = e.isDirectory();
|
|
811
|
+
let isFile = e.isFile();
|
|
812
|
+
if (opts.follow && e.isSymbolicLink()) {
|
|
813
|
+
const st = fs.statSync(full, { throwIfNoEntry: false }); // resolves the link
|
|
814
|
+
if (!st) continue; // broken link
|
|
815
|
+
isDir = st.isDirectory();
|
|
816
|
+
isFile = st.isFile();
|
|
817
|
+
}
|
|
695
818
|
if (opts.ignore.length && matchesAny(e.name, full, isDir, opts.ignore)) continue;
|
|
696
819
|
if (isDir) {
|
|
820
|
+
if (opts.follow) {
|
|
821
|
+
let real;
|
|
822
|
+
try { real = fs.realpathSync(full); } catch (err) { continue; }
|
|
823
|
+
if (visited.has(real)) continue; // cycle or already-walked dir
|
|
824
|
+
visited.add(real);
|
|
825
|
+
}
|
|
697
826
|
// --max-depth caps how far we descend; depth 1 = the target's children.
|
|
698
|
-
if (!opts.maxDepth || depth < opts.maxDepth) yield* walk(full, opts, depth + 1);
|
|
699
|
-
} else if (
|
|
827
|
+
if (!opts.maxDepth || depth < opts.maxDepth) yield* walk(full, opts, depth + 1, visited);
|
|
828
|
+
} else if (isFile) {
|
|
700
829
|
// --include replaces the extension filter; otherwise filter by --ext.
|
|
701
830
|
if (opts.include.length) {
|
|
702
831
|
if (matchesAny(e.name, full, false, opts.include)) yield full;
|
|
@@ -709,7 +838,7 @@ function* walk(dir, opts, depth = 1) {
|
|
|
709
838
|
}
|
|
710
839
|
|
|
711
840
|
function readStdin() {
|
|
712
|
-
return fs.readFileSync(0
|
|
841
|
+
return fs.readFileSync(0); // Buffer: sniffed for binary before decoding
|
|
713
842
|
}
|
|
714
843
|
|
|
715
844
|
// Heuristic binary-file detector (grep/git style). A NUL byte in the first 8 KB
|
|
@@ -729,11 +858,271 @@ function looksBinary(buf) {
|
|
|
729
858
|
return n > 0 && suspicious / n > 0.3;
|
|
730
859
|
}
|
|
731
860
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
+
}
|
|
735
954
|
|
|
736
|
-
|
|
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
|
+
}
|
|
1120
|
+
|
|
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) {
|
|
737
1126
|
let files = [];
|
|
738
1127
|
let useStdin = false;
|
|
739
1128
|
if (opts.recursive) {
|
|
@@ -750,9 +1139,57 @@ function main() {
|
|
|
750
1139
|
} else {
|
|
751
1140
|
useStdin = true;
|
|
752
1141
|
}
|
|
1142
|
+
return { files, useStdin };
|
|
1143
|
+
}
|
|
753
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) {
|
|
754
1148
|
// A label (file prefix) is shown when searching more than one file; -H forces
|
|
755
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
|
+
|
|
756
1193
|
const showLabel = opts.withFilename ? true
|
|
757
1194
|
: opts.noFilename ? false
|
|
758
1195
|
: (!useStdin && files.length > 1);
|
|
@@ -760,35 +1197,26 @@ function main() {
|
|
|
760
1197
|
const out = [];
|
|
761
1198
|
let total = 0;
|
|
762
1199
|
|
|
763
|
-
// -M/--max-total: remaining matches allowed across all files (Infinity = off).
|
|
764
|
-
const room = () => (opts.maxTotal ? Math.max(0, opts.maxTotal - total) : Infinity);
|
|
765
|
-
|
|
766
1200
|
try {
|
|
767
1201
|
if (useStdin) {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
let buf;
|
|
773
|
-
try {
|
|
774
|
-
buf = fs.readFileSync(f); // Buffer: sniff before decoding
|
|
775
|
-
} catch (e) {
|
|
776
|
-
if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
|
|
777
|
-
continue;
|
|
1202
|
+
const buf = readStdin();
|
|
1203
|
+
if (looksBinary(buf)) {
|
|
1204
|
+
if (!opts.noMessages && !opts.quiet) {
|
|
1205
|
+
process.stderr.write('cssgrep: (standard input): binary input (skipped)\n');
|
|
778
1206
|
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
}
|
|
783
|
-
continue;
|
|
784
|
-
}
|
|
785
|
-
total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
|
|
786
|
-
if (opts.quiet && total > 0) break; // -q: first match decides the status
|
|
1207
|
+
} else {
|
|
1208
|
+
total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out,
|
|
1209
|
+
opts.maxTotal || Infinity);
|
|
787
1210
|
}
|
|
1211
|
+
} else {
|
|
1212
|
+
total = searchFiles(opts, files, out);
|
|
788
1213
|
}
|
|
789
1214
|
} catch (e) {
|
|
790
1215
|
if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
|
|
791
|
-
|
|
1216
|
+
const shown = opts.selector != null
|
|
1217
|
+
? opts.selector
|
|
1218
|
+
: opts.selectors.map(s => s.selector).join(', ');
|
|
1219
|
+
fail(`invalid selector: ${shown}`);
|
|
792
1220
|
}
|
|
793
1221
|
fail(e.message);
|
|
794
1222
|
}
|
|
@@ -805,8 +1233,11 @@ function main() {
|
|
|
805
1233
|
}
|
|
806
1234
|
// Normally success means "a match was found". With -L it means "a file
|
|
807
1235
|
// without a match was printed", which is decoupled from the match total.
|
|
1236
|
+
// Set exitCode rather than calling process.exit(): stdout writes to a pipe
|
|
1237
|
+
// are async, and exit() would drop whatever hasn't flushed yet, truncating
|
|
1238
|
+
// large result sets mid-stream.
|
|
808
1239
|
const success = opts.filesWithoutMatch ? out.length > 0 : total > 0;
|
|
809
|
-
process.
|
|
1240
|
+
process.exitCode = success ? 0 : 1;
|
|
810
1241
|
}
|
|
811
1242
|
|
|
812
1243
|
main();
|