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 +19 -1
- package/README.md +4 -3
- package/cli.js +55 -11
- package/lib.js +23 -7
- package/man/cssgrep.1 +8 -7
- package/package.json +3 -2
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.
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
locator
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
+
.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
|
-
|
|
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
|
|
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
|
+
"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",
|