cssgrep 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2.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).
@@ -41,7 +47,8 @@ Options:
41
47
  -p, --print Pretty-print the matched node's HTML above its location.
42
48
  --attr <name> Print the value of attribute <name> (skips nodes without it).
43
49
  --text Print the matched node's text content (whitespace collapsed).
44
- --json Print one JSON record per match (NDJSON: file,line,col,html,text).
50
+ --json Print one JSON record per match (NDJSON: file,line,col,
51
+ html,text; plus label with -e).
45
52
  --parent <n> Report the n-th ancestor of each match instead (dedup'd).
46
53
  -w, --max-width <n> Truncate the shown line to <n> columns (ellipsis added).
47
54
  -A, --after-context <n> Print <n> source lines after each match.
@@ -60,9 +67,31 @@ Options:
60
67
  --no-filename Never print the file name prefix (even for many files).
61
68
  --color[=<when>] Colorize output: auto (default, also what a bare
62
69
  --color means, like grep), always or never.
70
+ --watch Re-run the search whenever a watched file changes
71
+ (requires paths; excludes -q and the rewrite ops).
72
+ On a TTY the screen is cleared and results reprinted;
73
+ piped output appends each run after a == HH:MM:SS ==
74
+ separator; with --json each run emits an NDJSON
75
+ {"event":"run",...} record followed by the matches.
76
+ Exit with Ctrl-C (status 0).
77
+ --no-clear With --watch on a TTY: append instead of clearing.
63
78
  -h, --help Show this help.
64
79
  -V, --version Show version and exit.
65
80
 
81
+ Rewrite (a separate mode: excludes -n/-p/--attr/--text/--json, -c/-l/-L/-q,
82
+ -A/-B/-C, -m/-M, -w, -0 and -e; composes with --parent):
83
+ --add-class <c> Add a class to each matched element.
84
+ --remove-class <c> Remove a class (attribute dropped when emptied).
85
+ --set-attr <k=v> Set attribute k to v (added if missing).
86
+ --remove-attr <k> Remove attribute k.
87
+ --rename-tag <t> Rename the element (and its closing tag, if present).
88
+ --diff Emit a unified diff instead of the document; required
89
+ for multiple files. Apply with git apply / patch.
90
+
91
+ A single input prints the rewritten document to stdout. Only the matched tags'
92
+ bytes change; ops compose as rename -> remove-attr -> set-attr -> remove-class
93
+ -> add-class regardless of argument order. Exit: 0 edits, 1 none, 2 error.
94
+
66
95
  Short flags combine (-rn) and a value attaches to its flag (-w100) or follows it
67
96
  (-w 100); a value-taking flag may close a cluster (-rnw100). Long options take a
68
97
  value with = or as the next word (--max-width=100, --ext htm).
@@ -77,13 +106,22 @@ function fail(msg) {
77
106
  process.exit(2);
78
107
  }
79
108
 
109
+ // grep's most predictable stumble: there is no invert-match here, because CSS
110
+ // expresses inversion in the selector itself. Teach instead of just rejecting.
111
+ function failInvert(flag) {
112
+ fail(`${flag}: there is no invert-match — CSS expresses inversion in the ` +
113
+ `selector, e.g. 'img:not([alt])' or 'div:not(:has(a))'; see man cssgrep`);
114
+ }
115
+
80
116
  // ANSI SGR codes matching grep's default scheme: bold-red match, magenta
81
- // filename, green line/col numbers, cyan separators.
117
+ // filename, green line/col numbers, cyan separators. The [label] tag from -e
118
+ // has no grep counterpart; yellow keeps it distinct from all of the above.
82
119
  const COLORS = {
83
120
  match: '1;31',
84
121
  file: '35',
85
122
  line: '32',
86
123
  sep: '36',
124
+ label: '33',
87
125
  };
88
126
 
89
127
  function paint(code, str) {
@@ -93,6 +131,7 @@ function paint(code, str) {
93
131
  function parseArgs(argv) {
94
132
  const opts = {
95
133
  selector: null,
134
+ selectors: [],
96
135
  positionals: [],
97
136
  paths: [],
98
137
  recursive: false,
@@ -122,6 +161,10 @@ function parseArgs(argv) {
122
161
  noFilename: false,
123
162
  noMessages: false,
124
163
  color: 'auto',
164
+ rewrite: { renameTag: null, removeAttr: [], setAttr: {}, removeClass: [], addClass: [] },
165
+ diff: false,
166
+ watch: false,
167
+ noClear: false,
125
168
  };
126
169
  const setExts = v => {
127
170
  opts.extGiven = true;
@@ -141,6 +184,16 @@ function parseArgs(argv) {
141
184
  const setAfter = v => { opts.after = boundedInt(v, '--after-context', 0); };
142
185
  const setBefore = v => { opts.before = boundedInt(v, '--before-context', 0); };
143
186
  const setContext = v => { opts.after = opts.before = boundedInt(v, '--context', 0); };
187
+ // -e [label=]<selector>. A `=` outside brackets never begins a *working*
188
+ // selector (css-what tokenizes `a=b` as an unmatchable tag named `=b`), so a
189
+ // leading identifier + `=` is unambiguously a label. Unlabeled selectors are
190
+ // tagged with their own text, so [label] and the --json `label` field are
191
+ // always present when -e is used.
192
+ const addSelector = v => {
193
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)=([\s\S]+)$/.exec(v);
194
+ if (m) opts.selectors.push({ label: m[1], selector: m[2] });
195
+ else opts.selectors.push({ label: v, selector: v });
196
+ };
144
197
  const addIgnore = v => { const c = compileIgnore(v); if (c) opts.ignore.push(c); };
145
198
  const addInclude = v => { const c = compileIgnore(v); if (c) opts.include.push(c); };
146
199
  const addIgnoreFile = v => {
@@ -152,7 +205,7 @@ function parseArgs(argv) {
152
205
  // Short flags that take a value (rest of the cluster, or the next argument).
153
206
  const shortValueFlags = {
154
207
  w: setMaxWidth, m: setMaxCount, M: setMaxTotal, A: setAfter, B: setBefore, C: setContext,
155
- i: addIgnore,
208
+ i: addIgnore, e: addSelector,
156
209
  };
157
210
  for (let i = 0; i < argv.length; i++) {
158
211
  const a = argv[i];
@@ -201,6 +254,23 @@ function parseArgs(argv) {
201
254
  case '--before-context': setBefore(value()); break;
202
255
  case '--context': setContext(value()); break;
203
256
  case '--color': case '--colour': opts.color = inline != null ? inline : 'auto'; break;
257
+ case '--selector': addSelector(value()); break;
258
+ case '--add-class': opts.rewrite.addClass.push(value()); break;
259
+ case '--remove-class': opts.rewrite.removeClass.push(value()); break;
260
+ case '--remove-attr': opts.rewrite.removeAttr.push(value()); break;
261
+ case '--set-attr': {
262
+ const v = value();
263
+ const eq = v.indexOf('=');
264
+ const k = eq === -1 ? v : v.slice(0, eq);
265
+ if (!k) fail('--set-attr requires a name (name=value)');
266
+ opts.rewrite.setAttr[k] = eq === -1 ? '' : v.slice(eq + 1);
267
+ break;
268
+ }
269
+ case '--rename-tag': opts.rewrite.renameTag = value(); break;
270
+ case '--diff': opts.diff = true; break;
271
+ case '--watch': opts.watch = true; break;
272
+ case '--no-clear': opts.noClear = true; break;
273
+ case '--invert-match': failInvert(name); break;
204
274
  default: fail(`unknown option: ${name}`);
205
275
  }
206
276
  continue;
@@ -232,6 +302,7 @@ function parseArgs(argv) {
232
302
  case '0': case 'Z': opts.nul = true; break;
233
303
  case 'H': opts.withFilename = true; break;
234
304
  case 's': opts.noMessages = true; break;
305
+ case 'v': failInvert('-v'); break;
235
306
  default: fail(`unknown option: -${ch}`);
236
307
  }
237
308
  }
@@ -240,7 +311,9 @@ function parseArgs(argv) {
240
311
 
241
312
  opts.positionals.push(a);
242
313
  }
243
- if (opts.positionals.length === 0) fail('no selector given (try --help)');
314
+ if (opts.positionals.length === 0 && !opts.selectors.length) {
315
+ fail('no selector given (try --help)');
316
+ }
244
317
  // Aggregate modes each suppress per-match output, so at most one may apply.
245
318
  const aggregates = [opts.count, opts.filesWithMatches, opts.filesWithoutMatch, opts.quiet]
246
319
  .filter(Boolean).length;
@@ -260,6 +333,34 @@ function parseArgs(argv) {
260
333
  }
261
334
  if (opts.lineNumber && opts.count) fail('-n cannot be combined with -c');
262
335
  if (opts.lineNumber && opts.print) fail('-n cannot be combined with -p');
336
+ // Rewrite is its own program mode, not a fourth output axis: it emits a
337
+ // document (or a diff), so everything that shapes per-match output is
338
+ // meaningless with it.
339
+ const r = opts.rewrite;
340
+ opts.rewriteActive = Boolean(r.renameTag) || r.removeAttr.length > 0
341
+ || r.removeClass.length > 0 || r.addClass.length > 0
342
+ || Object.keys(r.setAttr).length > 0;
343
+ if (opts.diff && !opts.rewriteActive) fail('--diff requires a rewrite operation');
344
+ if (opts.rewriteActive) {
345
+ if (printModes > 0) fail('rewrite operations cannot be combined with -p/--attr/--text/--json');
346
+ if (aggregates > 0) fail('rewrite operations cannot be combined with -c/-l/-L/-q');
347
+ if (opts.before > 0 || opts.after > 0) fail('rewrite operations cannot be combined with -A/-B/-C');
348
+ if (opts.lineNumber) fail('rewrite operations cannot be combined with -n');
349
+ if (opts.maxWidth) fail('rewrite operations cannot be combined with -w');
350
+ if (opts.nul) fail('rewrite operations cannot be combined with -0');
351
+ if (opts.maxCount || opts.maxTotal) fail('rewrite operations cannot be combined with -m/-M');
352
+ if (opts.selectors.length) {
353
+ fail('rewrite takes a single positional selector (use a selector list like "a, b" instead of -e)');
354
+ }
355
+ }
356
+ // Watch re-runs the search on change; modes that end the run early or edit
357
+ // files make no sense against it.
358
+ if (opts.noClear && !opts.watch) fail('--no-clear requires --watch');
359
+ if (opts.watch) {
360
+ if (opts.quiet) fail('--watch cannot be combined with -q');
361
+ if (opts.rewriteActive) fail('--watch cannot be combined with rewrite operations');
362
+ if (opts.noClear && opts.json) fail('--no-clear is meaningless with --json (it never clears)');
363
+ }
263
364
  if (!['auto', 'always', 'never'].includes(opts.color)) {
264
365
  fail(`invalid --color value: ${opts.color} (expected auto, always or never)`);
265
366
  }
@@ -279,6 +380,13 @@ function parseArgs(argv) {
279
380
  // `grepprg`, which appends args in its own order). Fall back to
280
381
  // "selector is the first positional" when the split is unclear.
281
382
  function resolveSelectorAndPaths(opts) {
383
+ // With -e the selectors are explicit, so — like grep -e — every positional
384
+ // is a file path; a mistyped one is reported as unreadable, not re-guessed
385
+ // as a selector.
386
+ if (opts.selectors.length) {
387
+ opts.paths = opts.positionals;
388
+ return;
389
+ }
282
390
  const pos = opts.positionals;
283
391
  const onDisk = [];
284
392
  const notOnDisk = [];
@@ -297,53 +405,6 @@ function resolveSelectorAndPaths(opts) {
297
405
  }
298
406
  }
299
407
 
300
- // Precompute the byte offset at which each line starts, so offset->line/col
301
- // is a binary search rather than a re-scan per match.
302
- function lineIndex(src) {
303
- const starts = [0];
304
- for (let i = 0; i < src.length; i++) {
305
- if (src.charCodeAt(i) === 10 /* \n */) starts.push(i + 1);
306
- }
307
- return starts;
308
- }
309
-
310
- function offsetToPosition(starts, src, offset) {
311
- // binary search for the greatest line start <= offset
312
- let lo = 0, hi = starts.length - 1;
313
- while (lo < hi) {
314
- const mid = (lo + hi + 1) >> 1;
315
- if (starts[mid] <= offset) lo = mid;
316
- else hi = mid - 1;
317
- }
318
- const lineStart = starts[lo];
319
- let lineEnd = src.indexOf('\n', lineStart);
320
- if (lineEnd === -1) lineEnd = src.length;
321
- // strip a trailing \r so CRLF files render cleanly
322
- let text = src.slice(lineStart, lineEnd);
323
- if (text.endsWith('\r')) text = text.slice(0, -1);
324
- return {
325
- line: lo + 1, // 1-based
326
- // col in UTF-16 code units: a JS string index into `text`, used by the
327
- // highlight math. bcol in bytes: what gets printed — vim's grepformat %c
328
- // and terminals count bytes, so non-ASCII text before the match would
329
- // otherwise land the cursor short.
330
- col: offset - lineStart + 1, // 1-based
331
- bcol: Buffer.byteLength(src.slice(lineStart, offset), 'utf8') + 1, // 1-based
332
- text,
333
- };
334
- }
335
-
336
- // Text of a 1-based line number, with the trailing \r stripped (CRLF), plus the
337
- // line's byte start (so a node's offset maps to a column within it).
338
- function lineTextAt(starts, src, lineNo) {
339
- const lineStart = starts[lineNo - 1];
340
- let lineEnd = src.indexOf('\n', lineStart);
341
- if (lineEnd === -1) lineEnd = src.length;
342
- let text = src.slice(lineStart, lineEnd);
343
- if (text.endsWith('\r')) text = text.slice(0, -1);
344
- return { lineStart, text };
345
- }
346
-
347
408
  // Never cut between the halves of a surrogate pair: a lone half is invalid
348
409
  // Unicode and serializes as U+FFFD garbage. Backing off one unit loses at most
349
410
  // one display column.
@@ -377,43 +438,6 @@ function renderText(pos, off, nodeEnd, opts) {
377
438
  return vis.slice(0, s) + paint(COLORS.match, vis.slice(s, e)) + vis.slice(e);
378
439
  }
379
440
 
380
- // Concatenate the text of a node and all its descendants (dependency-free,
381
- // rather than pulling in domutils). Used by --text.
382
- function textOf(node) {
383
- if (node.type === 'text') return node.data || '';
384
- if (!node.children) return '';
385
- let s = '';
386
- for (const child of node.children) s += textOf(child);
387
- return s;
388
- }
389
-
390
- const collapseWs = s => s.replace(/\s+/g, ' ').trim();
391
-
392
- const isElement = n => n && (n.type === 'tag' || n.type === 'script' || n.type === 'style');
393
-
394
- // Climb n element levels from el, clamping at the document root.
395
- function ancestor(el, n) {
396
- let node = el;
397
- for (let k = 0; k < n; k++) {
398
- if (!isElement(node.parent)) break;
399
- node = node.parent;
400
- }
401
- return node;
402
- }
403
-
404
- // --parent re-points each match to its n-th ancestor; dedup by identity so a
405
- // shared container is reported once, preserving first-seen order.
406
- function retarget(nodes, opts) {
407
- if (!opts.parent) return nodes;
408
- const seen = new Set();
409
- const result = [];
410
- for (const el of nodes) {
411
- const a = ancestor(el, opts.parent);
412
- if (!seen.has(a)) { seen.add(a); result.push(a); }
413
- }
414
- return result;
415
- }
416
-
417
441
  // Sentinels marking where a highlighted node begins/ends. They are injected as
418
442
  // HTML *comment* nodes (not text) so js-beautify keeps the surrounding block
419
443
  // layout — text markers would make adjacent block elements collapse inline.
@@ -503,14 +527,14 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
503
527
  const lineCount = src.length === 0 ? 0 : (src.endsWith('\n') ? starts.length - 1 : starts.length);
504
528
 
505
529
  // Map each match line to a representative node span (the first match on it),
506
- // which drives the in-line highlight when coloring.
530
+ // which drives the in-line highlight — and the [label] tag — when emitting.
507
531
  const info = new Map();
508
- for (const el of targets) {
532
+ for (const { el, label: selLabel } of targets) {
509
533
  const off = el.startIndex == null ? 0 : el.startIndex;
510
534
  const pos = offsetToPosition(starts, src, off);
511
535
  if (info.has(pos.line)) continue;
512
536
  const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
513
- info.set(pos.line, { off, nodeEnd, pos });
537
+ info.set(pos.line, { off, nodeEnd, pos, selLabel });
514
538
  }
515
539
 
516
540
  // Expand match lines to [L-before, L+after] windows and merge adjacent ones.
@@ -537,8 +561,9 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
537
561
  prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + ' '
538
562
  : c(COLORS.sep, '-');
539
563
  }
564
+ const tag = m && m.selLabel != null ? c(COLORS.label, `[${m.selLabel}]`) + ' ' : '';
540
565
  const body = m
541
- ? renderText(m.pos, m.off, m.nodeEnd, opts) // highlight + truncate
566
+ ? tag + renderText(m.pos, m.off, m.nodeEnd, opts) // highlight + truncate
542
567
  : truncate(lineTextAt(starts, src, L).text, opts.maxWidth);
543
568
  out.push(prefix + body);
544
569
  }
@@ -549,12 +574,29 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
549
574
  // used by the aggregate modes (-l/-L/-c). `showLabel` decides whether per-match
550
575
  // lines carry a `file:` prefix (only when more than one file is searched).
551
576
  function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
552
- const dom = parseDocument(src, {
553
- withStartIndices: true,
554
- withEndIndices: true,
555
- });
556
- const matches = selectAll(opts.selector, dom);
557
- 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;
558
600
  // Aggregate modes suppress per-match output entirely.
559
601
  if (opts.quiet) return found; // status only
560
602
  if (opts.filesWithMatches) { if (found) out.push(name); return found; }
@@ -564,7 +606,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
564
606
  // -m/--max-count caps matches per source; `limit` is the remaining global
565
607
  // budget from -M/--max-total (Infinity when neither applies).
566
608
  const cap = Math.min(opts.maxCount || Infinity, limit);
567
- const limited = Number.isFinite(cap) ? matches.slice(0, cap) : matches;
609
+ const limited = Number.isFinite(cap) ? records.slice(0, cap) : records;
568
610
  if (opts.count) {
569
611
  // grep parity: every searched file reports a count, zeros included, so
570
612
  // scripts get one row per file (exit status still says whether anything
@@ -573,18 +615,33 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
573
615
  out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
574
616
  return limited.length;
575
617
  }
576
- const starts = opts.print ? null : lineIndex(src);
577
- // --parent re-targets matches to ancestors (no-op without it). Aggregate
578
- // modes above operate on the raw matches; targeting only affects what prints.
579
- 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
+ }
580
637
  // For --parent + -p, remember which original matches sit under each ancestor
581
638
  // so they can be highlighted inside the printed container.
582
639
  const originsByTarget = new Map();
583
640
  if (opts.parent && opts.print) {
584
- for (const el of limited) {
585
- const a = ancestor(el, opts.parent);
641
+ for (const r of limited) {
642
+ const a = ancestor(r.el, opts.parent);
586
643
  if (!originsByTarget.has(a)) originsByTarget.set(a, []);
587
- originsByTarget.get(a).push(el);
644
+ originsByTarget.get(a).push(r.el);
588
645
  }
589
646
  }
590
647
  if (opts.before > 0 || opts.after > 0) {
@@ -598,8 +655,9 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
598
655
  if (opts.json) {
599
656
  // NDJSON: one self-contained record per match. `html` is the exact source
600
657
  // slice; newlines are escaped by JSON.stringify, so each record stays on
601
- // one line. Ignores --color and -n (line/col are always present).
602
- 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) {
603
661
  const off = el.startIndex == null ? 0 : el.startIndex;
604
662
  const pos = offsetToPosition(starts, src, off);
605
663
  const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
@@ -607,6 +665,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
607
665
  file: name,
608
666
  line: pos.line,
609
667
  col: pos.bcol,
668
+ ...(selLabel !== null && { label: selLabel }),
610
669
  html: src.slice(off, nodeEnd),
611
670
  text: collapseWs(textOf(el)),
612
671
  }));
@@ -614,17 +673,21 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
614
673
  return targets.length;
615
674
  }
616
675
  let emitted = 0;
617
- for (const el of targets) {
676
+ for (const { el, label: selLabel } of targets) {
677
+ const c = opts.colorOn ? paint : (_, s) => s;
678
+ // The [label] tag from -e; a null label (positional selector) prints none.
679
+ const tag = selLabel === null ? '' : c(COLORS.label, `[${selLabel}]`) + ' ';
618
680
  if (opts.print) {
619
- // -p shows the re-indented node only; no line:col locator. With --parent,
620
- // 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());
621
685
  out.push(prettyPrint(el, originsByTarget.get(el), opts), ''); // blank separator
622
686
  emitted++;
623
687
  continue;
624
688
  }
625
689
  const off = el.startIndex == null ? 0 : el.startIndex;
626
690
  const pos = offsetToPosition(starts, src, off);
627
- const c = opts.colorOn ? paint : (_, s) => s;
628
691
 
629
692
  // Choose the content printed for this match. --attr/--text replace the
630
693
  // source line with the extracted value (whole value highlighted as the
@@ -651,7 +714,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
651
714
  if (opts.lineNumber) {
652
715
  prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.bcol)) + ' ';
653
716
  }
654
- out.push(prefix + text);
717
+ out.push(prefix + tag + text);
655
718
  emitted++;
656
719
  }
657
720
  return emitted;
@@ -795,11 +858,271 @@ function looksBinary(buf) {
795
858
  return n > 0 && suspicious / n > 0.3;
796
859
  }
797
860
 
798
- function main() {
799
- const opts = parseArgs(process.argv.slice(2));
800
- 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
+ }
954
+
955
+ const out = [`--- a/${name}`, `+++ b/${name}`];
956
+ for (const [s, e] of hunks) {
957
+ let oc = 0, nc = 0;
958
+ for (let k = s; k < e; k++) {
959
+ if (script[k].t !== '+') oc++;
960
+ if (script[k].t !== '-') nc++;
961
+ }
962
+ const os = oc ? oldPos[s] : oldPos[s] - 1;
963
+ const ns = nc ? newPos[s] : newPos[s] - 1;
964
+ out.push(`@@ -${os},${oc} +${ns},${nc} @@`);
965
+ for (let k = s; k < e; k++) {
966
+ const { t, l } = script[k];
967
+ out.push(t + l);
968
+ const atOldEnd = t !== '+' && oldPos[k] === a.length && A.noEol;
969
+ const atNewEnd = t !== '-' && newPos[k] === b.length && B.noEol;
970
+ if (atOldEnd || atNewEnd) out.push('\');
971
+ }
972
+ }
973
+ return out.join('\n') + '\n';
974
+ }
975
+
976
+ // The rewrite program mode: transform each source and emit the document
977
+ // (single input) or a unified diff (any number of files). Never writes a
978
+ // file — apply diffs with git apply / patch (see ROADMAP Phase 9).
979
+ function rewriteMain(opts, files, useStdin) {
980
+ if (!useStdin && files.length > 1 && !opts.diff) {
981
+ fail('rewriting multiple files requires --diff');
982
+ }
983
+ const sources = [];
984
+ if (useStdin) {
985
+ sources.push({ name: '(standard input)', buf: readStdin() });
986
+ } else {
987
+ for (const f of files) {
988
+ try {
989
+ sources.push({ name: f, buf: fs.readFileSync(f) });
990
+ } catch (e) {
991
+ if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
992
+ }
993
+ }
994
+ }
995
+ let totalEdits = 0;
996
+ const diffs = [];
997
+ for (const { name, buf } of sources) {
998
+ // A rewriter must never corrupt bytes it didn't edit: binary input is
999
+ // never HTML, and a lossy UTF-8 decode written back would mangle every
1000
+ // non-UTF-8 byte in the file — refuse both outright (exit 2).
1001
+ if (looksBinary(buf)) fail(`${name}: binary input; refusing to rewrite`);
1002
+ const src = buf.toString('utf8');
1003
+ if (!Buffer.from(src, 'utf8').equals(buf)) {
1004
+ fail(`${name}: not valid UTF-8; refusing to rewrite`);
1005
+ }
1006
+ let result;
1007
+ try {
1008
+ result = parse(src).transform(opts.selector, { ...opts.rewrite, parent: opts.parent });
1009
+ } catch (e) {
1010
+ if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
1011
+ fail(`invalid selector: ${opts.selector}`);
1012
+ }
1013
+ fail(e.message);
1014
+ }
1015
+ totalEdits += result.edits.length;
1016
+ if (opts.diff) {
1017
+ if (result.edits.length) diffs.push(unifiedDiff(name, src, result.html));
1018
+ } else {
1019
+ // Filter-friendly: the document is always emitted, changed or not
1020
+ // (like sed); the exit status says whether anything was edited.
1021
+ process.stdout.write(result.html);
1022
+ }
1023
+ }
1024
+ if (diffs.length) process.stdout.write(diffs.join(''));
1025
+ process.exitCode = totalEdits > 0 ? 0 : 1;
1026
+ }
1027
+
1028
+ // The watch program mode: rerun the search whenever a watched path changes.
1029
+ // Native recursive fs.watch, no polling (see ROADMAP Phase 10; caveat:
1030
+ // network/virtual filesystems may not deliver events). Output adapts like
1031
+ // --color=auto does: a TTY gets clear+reprint, a pipe gets append mode with
1032
+ // `== HH:MM:SS … ==` separators (--no-clear forces append on a TTY), and
1033
+ // --json gets an NDJSON stream — {"event":"run",...} then the match records.
1034
+ // Runs until SIGINT (exit 0, like watch(1)).
1035
+ function watchMain(opts) {
1036
+ const clearMode = !opts.json && !opts.noClear && Boolean(process.stdout.isTTY);
1037
+ const renderOut = out => (!out.length ? ''
1038
+ : opts.nul && (opts.filesWithMatches || opts.filesWithoutMatch)
1039
+ ? out.map(s => s + '\0').join('')
1040
+ : out.join('\n') + '\n');
1041
+
1042
+ const run = changed => {
1043
+ // Re-walk every run: a rerun sees exactly what a fresh invocation would.
1044
+ const { files } = resolveFiles(opts);
1045
+ const out = [];
1046
+ let total = 0;
1047
+ try {
1048
+ total = searchFiles(opts, files, out);
1049
+ } catch (e) {
1050
+ if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
1051
+ fail(`invalid selector: ${opts.selector != null
1052
+ ? opts.selector
1053
+ : opts.selectors.map(s => s.selector).join(', ')}`);
1054
+ }
1055
+ fail(e.message);
1056
+ }
1057
+ if (opts.json) {
1058
+ process.stdout.write(JSON.stringify({
1059
+ event: 'run',
1060
+ changed: changed || null,
1061
+ matches: total,
1062
+ }) + '\n' + renderOut(out));
1063
+ } else if (clearMode) {
1064
+ process.stdout.write('\x1b[2J\x1b[H' + (out.length ? renderOut(out) : 'cssgrep: no matches\n'));
1065
+ } else {
1066
+ const ts = new Date().toTimeString().slice(0, 8);
1067
+ process.stdout.write(`== ${ts} ${changed || 'watching'} ==\n` + renderOut(out));
1068
+ }
1069
+ };
1070
+
1071
+ // Debounce change bursts (editors often fire several events per save).
1072
+ let timer = null;
1073
+ let pending = null;
1074
+ const schedule = changed => {
1075
+ pending = changed;
1076
+ clearTimeout(timer);
1077
+ timer = setTimeout(() => { const c = pending; pending = null; run(c); }, 80);
1078
+ };
1079
+
1080
+ // Directory targets get one recursive watcher each. Explicit file targets
1081
+ // are watched via their parent directory, filtered by name — editors often
1082
+ // save by rename-replace, which would orphan a watcher on the file itself.
1083
+ const watchers = [];
1084
+ const fileTargets = new Map(); // parent dir -> Set of basenames
1085
+ const targets = opts.paths.length ? opts.paths : ['.'];
1086
+ for (const p of targets) {
1087
+ const st = fs.statSync(p, { throwIfNoEntry: false });
1088
+ if (!st) {
1089
+ if (!opts.noMessages) process.stderr.write(`cssgrep: ${p}: no such file or directory\n`);
1090
+ continue;
1091
+ }
1092
+ if (st.isDirectory()) {
1093
+ watchers.push(fs.watch(p, { recursive: true },
1094
+ (ev, f) => schedule(f ? path.join(p, f) : p)));
1095
+ } else {
1096
+ const dir = path.dirname(p);
1097
+ if (!fileTargets.has(dir)) fileTargets.set(dir, new Set());
1098
+ fileTargets.get(dir).add(path.basename(p));
1099
+ }
1100
+ }
1101
+ for (const [dir, bases] of fileTargets) {
1102
+ watchers.push(fs.watch(dir,
1103
+ (ev, f) => { if (!f || bases.has(f)) schedule(f ? path.join(dir, f) : dir); }));
1104
+ }
1105
+ if (!watchers.length) fail('--watch: nothing to watch');
1106
+ for (const w of watchers) {
1107
+ w.on('error', e => {
1108
+ if (!opts.noMessages) process.stderr.write(`cssgrep: watch: ${e.message}\n`);
1109
+ });
1110
+ }
1111
+
1112
+ process.on('SIGINT', () => {
1113
+ for (const w of watchers) w.close();
1114
+ clearTimeout(timer);
1115
+ process.exitCode = 0; // watch(1) convention
1116
+ });
1117
+
1118
+ run(null); // initial pass, then wait
1119
+ }
801
1120
 
802
- // Resolve the list of files to search.
1121
+ // Resolve the paths to search into a concrete file list (or stdin). Watch
1122
+ // mode calls this again on every rerun, so a rerun sees exactly what a fresh
1123
+ // invocation would: new files picked up per the include/ignore/--ext rules,
1124
+ // deleted ones dropped.
1125
+ function resolveFiles(opts) {
803
1126
  let files = [];
804
1127
  let useStdin = false;
805
1128
  if (opts.recursive) {
@@ -816,9 +1139,57 @@ function main() {
816
1139
  } else {
817
1140
  useStdin = true;
818
1141
  }
1142
+ return { files, useStdin };
1143
+ }
819
1144
 
1145
+ // One search pass over a file list; appends output lines to `out` and returns
1146
+ // the match total. Shared by the single-shot main path and each watch rerun.
1147
+ function searchFiles(opts, files, out) {
820
1148
  // A label (file prefix) is shown when searching more than one file; -H forces
821
1149
  // it on (even for one file or stdin) and --no-filename forces it off.
1150
+ const showLabel = opts.withFilename ? true
1151
+ : opts.noFilename ? false
1152
+ : files.length > 1;
1153
+ let total = 0;
1154
+ // -M/--max-total: remaining matches allowed across all files (Infinity = off).
1155
+ const room = () => (opts.maxTotal ? Math.max(0, opts.maxTotal - total) : Infinity);
1156
+ for (const f of files) {
1157
+ if (room() <= 0) break; // -M: global budget exhausted
1158
+ let buf;
1159
+ try {
1160
+ buf = fs.readFileSync(f); // Buffer: sniff before decoding
1161
+ } catch (e) {
1162
+ if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
1163
+ continue;
1164
+ }
1165
+ if (looksBinary(buf)) {
1166
+ if (!opts.noMessages && !opts.quiet) {
1167
+ process.stderr.write(`cssgrep: ${f}: binary file (skipped)\n`);
1168
+ }
1169
+ continue;
1170
+ }
1171
+ total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
1172
+ if (opts.quiet && total > 0) break; // -q: first match decides the status
1173
+ }
1174
+ return total;
1175
+ }
1176
+
1177
+ function main() {
1178
+ const opts = parseArgs(process.argv.slice(2));
1179
+ resolveSelectorAndPaths(opts);
1180
+
1181
+ const { files, useStdin } = resolveFiles(opts);
1182
+
1183
+ if (opts.rewriteActive) {
1184
+ rewriteMain(opts, files, useStdin);
1185
+ return;
1186
+ }
1187
+ if (opts.watch) {
1188
+ if (useStdin) fail('--watch requires file or directory paths');
1189
+ watchMain(opts);
1190
+ return;
1191
+ }
1192
+
822
1193
  const showLabel = opts.withFilename ? true
823
1194
  : opts.noFilename ? false
824
1195
  : (!useStdin && files.length > 1);
@@ -826,9 +1197,6 @@ function main() {
826
1197
  const out = [];
827
1198
  let total = 0;
828
1199
 
829
- // -M/--max-total: remaining matches allowed across all files (Infinity = off).
830
- const room = () => (opts.maxTotal ? Math.max(0, opts.maxTotal - total) : Infinity);
831
-
832
1200
  try {
833
1201
  if (useStdin) {
834
1202
  const buf = readStdin();
@@ -837,31 +1205,18 @@ function main() {
837
1205
  process.stderr.write('cssgrep: (standard input): binary input (skipped)\n');
838
1206
  }
839
1207
  } else {
840
- total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out, room());
1208
+ total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out,
1209
+ opts.maxTotal || Infinity);
841
1210
  }
842
1211
  } else {
843
- for (const f of files) {
844
- if (room() <= 0) break; // -M: global budget exhausted
845
- let buf;
846
- try {
847
- buf = fs.readFileSync(f); // Buffer: sniff before decoding
848
- } catch (e) {
849
- if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
850
- continue;
851
- }
852
- if (looksBinary(buf)) {
853
- if (!opts.noMessages && !opts.quiet) {
854
- process.stderr.write(`cssgrep: ${f}: binary file (skipped)\n`);
855
- }
856
- continue;
857
- }
858
- total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
859
- if (opts.quiet && total > 0) break; // -q: first match decides the status
860
- }
1212
+ total = searchFiles(opts, files, out);
861
1213
  }
862
1214
  } catch (e) {
863
1215
  if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
864
- 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}`);
865
1220
  }
866
1221
  fail(e.message);
867
1222
  }