cssgrep 1.2.0 → 1.4.0

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