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.
@@ -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.1.0';
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,html,text).
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 relevant).
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), always or never.
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 "always").
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 = () => (inline != null ? inline : argv[++i]);
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
- case '--attr': opts.attr = value(); break;
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 : 'always'; break;
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) fail('no selector given (try --help)');
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
- // Precompute the byte offset at which each line starts, so offset->line/col
287
- // is a binary search rather than a re-scan per match.
288
- function lineIndex(src) {
289
- const starts = [0];
290
- for (let i = 0; i < src.length; i++) {
291
- if (src.charCodeAt(i) === 10 /* \n */) starts.push(i + 1);
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 coloring.
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.col)) + ' '
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) // highlight + truncate
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
- const dom = parseDocument(src, {
526
- withStartIndices: true,
527
- withEndIndices: true,
528
- });
529
- const matches = selectAll(opts.selector, dom);
530
- const found = matches.length;
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) ? matches.slice(0, cap) : matches;
609
+ const limited = Number.isFinite(cap) ? records.slice(0, cap) : records;
541
610
  if (opts.count) {
542
- if (limited.length) {
543
- const fileSep = opts.nul ? '\0' : ':';
544
- out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
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 : lineIndex(src);
549
- // --parent re-targets matches to ancestors (no-op without it). Aggregate
550
- // modes above operate on the raw matches; targeting only affects what prints.
551
- const targets = retarget(limited, opts);
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 el of limited) {
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
- for (const el of targets) {
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.col,
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 limited.length;
673
+ return targets.length;
583
674
  }
584
- for (const el of targets) {
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. With --parent,
587
- // the original matched descendants are highlighted inside the container.
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.col)) + ' ';
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 limited.length;
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] === '/') i++; // consume the slash in **/
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 ? full : name)) return true;
785
+ if (m.re.test(m.hasSlash ? fullPosix : name)) return true;
680
786
  }
681
787
  return false;
682
788
  }
683
789
 
684
- function* walk(dir, opts, depth = 1) {
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
- const isDir = e.isDirectory();
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 (e.isFile()) {
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, 'utf8');
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
- function main() {
733
- const opts = parseArgs(process.argv.slice(2));
734
- resolveSelectorAndPaths(opts);
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
- // Resolve the list of files to search.
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
- total += searchSource(readStdin(), '(standard input)', showLabel, opts, out, room());
769
- } else {
770
- for (const f of files) {
771
- if (room() <= 0) break; // -M: global budget exhausted
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
- if (looksBinary(buf)) {
780
- if (!opts.noMessages && !opts.quiet) {
781
- process.stderr.write(`cssgrep: ${f}: binary file (skipped)\n`);
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
- fail(`invalid selector: ${opts.selector}`);
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.exit(success ? 0 : 1);
1240
+ process.exitCode = success ? 0 : 1;
810
1241
  }
811
1242
 
812
1243
  main();