cssgrep 1.3.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-07-07
11
+
12
+ ### Fixed
13
+ - Large result sets no longer crash with `RangeError: Invalid string length`
14
+ (exit 1, zero output): output is written in byte-bounded chunks instead of
15
+ one giant join. Observed with 40k matches on an 8 MB minified line.
16
+ - Emitting many matches on one physical line was quadratic (two O(line)
17
+ scans per match); the same 40k-locator case dropped from 9.4 s to 0.4 s.
18
+ Found by the new `npm run bench` harness.
19
+
20
+ ### Changed
21
+ - grep parity: without `-n`, a matching line now prints once, however many
22
+ matches sit on it. With `-n` there is still one record per match, each
23
+ with its own `line:col` locator.
24
+ - Startup no longer loads the pretty-printing dependencies unless `-p` is
25
+ used (43.4 → 37.9 ms measured), and zero-match files skip the line index.
26
+
10
27
  ## [1.3.0] - 2026-07-07
11
28
 
12
29
  ### Added
@@ -108,7 +125,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
108
125
  - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
109
126
  - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
110
127
 
111
- [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.3.0...HEAD
128
+ [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.4.0...HEAD
129
+ [1.4.0]: https://github.com/msbatarce/cssgrep/compare/v1.3.0...v1.4.0
112
130
  [1.3.0]: https://github.com/msbatarce/cssgrep/compare/v1.2.0...v1.3.0
113
131
  [1.2.0]: https://github.com/msbatarce/cssgrep/compare/v1.1.0...v1.2.0
114
132
  [1.1.0]: https://github.com/msbatarce/cssgrep/compare/v1.0.0...v1.1.0
package/README.md CHANGED
@@ -61,9 +61,10 @@ cssgrep <selector> -r <dir ...> # recurse directories
61
61
  cat page.html | cssgrep <selector> # read from stdin
62
62
  ```
63
63
 
64
- Output, one line per match. Like `grep`, the matched line is printed on its own;
65
- a `file:` prefix is added when searching multiple files, and the `line:col`
66
- locator appears only with `-n`:
64
+ Like `grep`, each matching line is printed on its own, once — however many
65
+ matches sit on it. With `-n` there is one record *per match*, each with its
66
+ own `line:col` locator (that per-match precision is the point of the tool). A
67
+ `file:` prefix is added when searching multiple files:
67
68
 
68
69
  ```
69
70
  {line contents} # default; stdin or single file
package/cli.js CHANGED
@@ -3,17 +3,27 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const render = require('dom-serializer').default;
7
- const { html: beautify } = require('js-beautify');
8
6
  const {
9
7
  parse, offsetToPosition, lineTextAt, textOf, collapseWs, ancestor,
10
8
  } = require('./lib.js');
11
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
+ }
21
+
12
22
  // Single source of truth for the version. A constant rather than a read of
13
23
  // package.json, so it survives compilation into a standalone binary (Bun
14
24
  // --compile / Node SEA), where package.json won't sit next to the executable.
15
25
  // Keep in sync with package.json on release.
16
- const VERSION = '1.3.0';
26
+ const VERSION = '1.4.0';
17
27
 
18
28
  const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
19
29
 
@@ -23,7 +33,7 @@ Usage:
23
33
  cssgrep -e '[label=]<sel>' [-e ...] [file ...]
24
34
  cat file.html | cssgrep <selector>
25
35
 
26
- Output (one line per match):
36
+ Output (each matching line once, like grep; with -n, one record per match):
27
37
  {line contents} (default; stdin or single file)
28
38
  {file}:{line contents} (default; multiple files)
29
39
  {line}:{col} {line contents} (with -n; stdin or single file)
@@ -452,6 +462,7 @@ const HL_END = '';
452
462
  // highlight) is given and coloring is on, those nodes are wrapped in the match
453
463
  // color within the printed block.
454
464
  function prettyPrint(el, origins, opts) {
465
+ loadPrettyPrinter();
455
466
  const highlight = origins && origins.length && opts && opts.colorOn;
456
467
  const inserted = [];
457
468
  if (highlight) {
@@ -529,9 +540,10 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
529
540
  // Map each match line to a representative node span (the first match on it),
530
541
  // which drives the in-line highlight — and the [label] tag — when emitting.
531
542
  const info = new Map();
543
+ const posState = {};
532
544
  for (const { el, label: selLabel } of targets) {
533
545
  const off = el.startIndex == null ? 0 : el.startIndex;
534
- const pos = offsetToPosition(starts, src, off);
546
+ const pos = offsetToPosition(starts, src, off, posState);
535
547
  if (info.has(pos.line)) continue;
536
548
  const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
537
549
  info.set(pos.line, { off, nodeEnd, pos, selLabel });
@@ -615,6 +627,9 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
615
627
  out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
616
628
  return limited.length;
617
629
  }
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;
618
633
  const starts = opts.print ? null : doc.lineStarts();
619
634
  // --parent re-targets matches to ancestors (no-op without it). Dedup is per
620
635
  // (ancestor, label), so two -e selectors sharing a container still report it
@@ -657,9 +672,10 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
657
672
  // slice; newlines are escaped by JSON.stringify, so each record stays on
658
673
  // one line. Ignores --color and -n (line/col are always present). `label`
659
674
  // appears only with -e.
675
+ const posState = {};
660
676
  for (const { el, label: selLabel } of targets) {
661
677
  const off = el.startIndex == null ? 0 : el.startIndex;
662
- const pos = offsetToPosition(starts, src, off);
678
+ const pos = offsetToPosition(starts, src, off, posState);
663
679
  const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
664
680
  out.push(JSON.stringify({
665
681
  file: name,
@@ -673,6 +689,12 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
673
689
  return targets.length;
674
690
  }
675
691
  let emitted = 0;
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 = {};
676
698
  for (const { el, label: selLabel } of targets) {
677
699
  const c = opts.colorOn ? paint : (_, s) => s;
678
700
  // The [label] tag from -e; a null label (positional selector) prints none.
@@ -687,7 +709,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
687
709
  continue;
688
710
  }
689
711
  const off = el.startIndex == null ? 0 : el.startIndex;
690
- const pos = offsetToPosition(starts, src, off);
712
+ const pos = offsetToPosition(starts, src, off, posState);
691
713
 
692
714
  // Choose the content printed for this match. --attr/--text replace the
693
715
  // source line with the extracted value (whole value highlighted as the
@@ -699,6 +721,10 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
699
721
  } else if (opts.text) {
700
722
  text = c(COLORS.match, truncate(collapseWs(textOf(el)), opts.maxWidth));
701
723
  } else {
724
+ if (seenLines) {
725
+ if (seenLines.has(pos.line)) continue; // this line already printed
726
+ seenLines.add(pos.line);
727
+ }
702
728
  const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1; // exclusive
703
729
  text = renderText(pos, off, nodeEnd, opts);
704
730
  }
@@ -1225,11 +1251,29 @@ function main() {
1225
1251
  // For -l/-L, -0 NUL-terminates each file name (no newline) so the list is
1226
1252
  // safe for `xargs -0`. Other modes keep newline-separated records (with -0
1227
1253
  // the NUL appears only as the in-record file-name separator).
1228
- if (opts.nul && (opts.filesWithMatches || opts.filesWithoutMatch)) {
1229
- process.stdout.write(out.map(s => s + '\0').join(''));
1230
- } else {
1231
- 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();
1232
1275
  }
1276
+ flush();
1233
1277
  }
1234
1278
  // Normally success means "a match was found". With -L it means "a file
1235
1279
  // without a match was printed", which is decoupled from the match total.
package/lib.js CHANGED
@@ -26,7 +26,7 @@ function lineIndex(src) {
26
26
  return starts;
27
27
  }
28
28
 
29
- function offsetToPosition(starts, src, offset) {
29
+ function offsetToPosition(starts, src, offset, state) {
30
30
  // binary search for the greatest line start <= offset
31
31
  let lo = 0, hi = starts.length - 1;
32
32
  while (lo < hi) {
@@ -35,11 +35,27 @@ function offsetToPosition(starts, src, offset) {
35
35
  else hi = mid - 1;
36
36
  }
37
37
  const lineStart = starts[lo];
38
- let lineEnd = src.indexOf('\n', lineStart);
39
- if (lineEnd === -1) lineEnd = src.length;
38
+ // The next line's start bounds this line (O(1)); an indexOf scan here is
39
+ // O(line length) *per match* — quadratic on minified single-line files.
40
+ const lineEnd = lo + 1 < starts.length ? starts[lo + 1] - 1 : src.length;
40
41
  // strip a trailing \r so CRLF files render cleanly
41
42
  let text = src.slice(lineStart, lineEnd);
42
43
  if (text.endsWith('\r')) text = text.slice(0, -1);
44
+ // bcol counts bytes from the line start — also quadratic if recomputed per
45
+ // match. `state` (one mutable object per source, supplied by the caller)
46
+ // remembers the previous match's byte count, so in-order matches on the
47
+ // same line pay only the delta; out-of-order falls back to a full count.
48
+ let bytes;
49
+ if (state && state.lineStart === lineStart && offset >= state.offset) {
50
+ bytes = state.bytes + Buffer.byteLength(src.slice(state.offset, offset), 'utf8');
51
+ } else {
52
+ bytes = Buffer.byteLength(src.slice(lineStart, offset), 'utf8');
53
+ }
54
+ if (state) {
55
+ state.lineStart = lineStart;
56
+ state.offset = offset;
57
+ state.bytes = bytes;
58
+ }
43
59
  return {
44
60
  line: lo + 1, // 1-based
45
61
  // col in UTF-16 code units: a JS string index into `text`, used by the
@@ -47,7 +63,7 @@ function offsetToPosition(starts, src, offset) {
47
63
  // and terminals count bytes, so non-ASCII text before the match would
48
64
  // otherwise land the cursor short.
49
65
  col: offset - lineStart + 1, // 1-based
50
- bcol: Buffer.byteLength(src.slice(lineStart, offset), 'utf8') + 1, // 1-based
66
+ bcol: bytes + 1, // 1-based
51
67
  text,
52
68
  };
53
69
  }
@@ -56,8 +72,7 @@ function offsetToPosition(starts, src, offset) {
56
72
  // line's byte start (so a node's offset maps to a column within it).
57
73
  function lineTextAt(starts, src, lineNo) {
58
74
  const lineStart = starts[lineNo - 1];
59
- let lineEnd = src.indexOf('\n', lineStart);
60
- if (lineEnd === -1) lineEnd = src.length;
75
+ const lineEnd = lineNo < starts.length ? starts[lineNo] - 1 : src.length;
61
76
  let text = src.slice(lineStart, lineEnd);
62
77
  if (text.endsWith('\r')) text = text.slice(0, -1);
63
78
  return { lineStart, text };
@@ -296,7 +311,8 @@ function parse(html) {
296
311
  });
297
312
  let starts = null;
298
313
  const lineStarts = () => starts || (starts = lineIndex(html));
299
- const position = offset => offsetToPosition(lineStarts(), html, offset);
314
+ const posState = {}; // incremental byte-column cache, scoped to this doc
315
+ const position = offset => offsetToPosition(lineStarts(), html, offset, posState);
300
316
  return {
301
317
  html,
302
318
  // Internal (unstable): the raw htmlparser2 document and the cached
package/man/cssgrep.1 CHANGED
@@ -1,6 +1,6 @@
1
1
  .\" Man page for cssgrep. Keep the OPTIONS section in sync with the USAGE
2
2
  .\" string in cli.js.
3
- .TH CSSGREP 1 "2026-07-07" "cssgrep 1.3.0" "User Commands"
3
+ .TH CSSGREP 1 "2026-07-07" "cssgrep 1.4.0" "User Commands"
4
4
  .SH NAME
5
5
  cssgrep \- search HTML by CSS selector, grep-style
6
6
  .SH SYNOPSIS
@@ -46,15 +46,16 @@ skipped with a note on standard error, suppressible with
46
46
  .B \-s
47
47
  or
48
48
  .BR \-q .
49
- One line is printed per match.
50
49
  As with
51
50
  .BR grep ,
52
- the matched line is printed on its own; a
51
+ each matching line is printed on its own, once \(em however many matches sit
52
+ on it. With
53
+ .B \-n
54
+ there is one record per
55
+ .IR match ,
56
+ each with its own locator. A
53
57
  .RI " file :"
54
- prefix is added when searching multiple files, and the
55
- .IR line : col
56
- locator appears only with
57
- .BR \-n .
58
+ prefix is added when searching multiple files.
58
59
  .SH OPTIONS
59
60
  .TP
60
61
  .BR \-e ", " \-\-selector " [\fIlabel\fR=]\fIsel\fR"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssgrep",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Search HTML by CSS selector and print matches grep-style (file:line:col with -n).",
5
5
  "main": "lib.js",
6
6
  "types": "index.d.ts",
@@ -37,7 +37,8 @@
37
37
  "build:darwin-arm64": "bun build ./cli.js --compile --target=bun-darwin-arm64 --outfile dist/cssgrep-darwin-arm64",
38
38
  "build:windows-x64": "bun build ./cli.js --compile --target=bun-windows-x64 --outfile dist/cssgrep-windows-x64.exe",
39
39
  "build:binaries": "npm run build:linux-x64 && npm run build:linux-arm64 && npm run build:darwin-x64 && npm run build:darwin-arm64 && npm run build:windows-x64",
40
- "build:sea": "node scripts/build-sea.js"
40
+ "build:sea": "node scripts/build-sea.js",
41
+ "bench": "node bench/bench.js"
41
42
  },
42
43
  "keywords": [
43
44
  "css",