cssgrep 1.1.0 → 1.2.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 +32 -1
- package/README.md +9 -6
- package/completions/_cssgrep +1 -0
- package/completions/cssgrep.bash +1 -1
- package/completions/cssgrep.fish +1 -0
- package/index.js +105 -29
- package/man/cssgrep.1 +33 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.0] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `-S`/`--follow` to follow symbolic links while recursing with `-r`
|
|
14
|
+
(skipped by default). Cycle-safe: each physical directory is visited once.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Large result sets are no longer truncated when stdout is a pipe (the process
|
|
18
|
+
exited before async pipe writes had flushed).
|
|
19
|
+
- Columns (`-n`, `-A`/`-B`/`-C`, `--json`) now count bytes — what vim's
|
|
20
|
+
`grepformat %c` expects — instead of UTF-16 code units, so non-ASCII text
|
|
21
|
+
before a match no longer skews the locator.
|
|
22
|
+
- A value-taking option with no value (e.g. a trailing `--ignore` or `--attr`)
|
|
23
|
+
now fails cleanly with exit 2 instead of crashing or being silently ignored.
|
|
24
|
+
- `**/` in globs stops at path-segment boundaries: `--include '**/foo.html'`
|
|
25
|
+
no longer matches `barfoo.html`.
|
|
26
|
+
- `--attr` matches attribute names case-insensitively (the parser lowercases
|
|
27
|
+
them), and exits 1 when every match was skipped for lacking the attribute.
|
|
28
|
+
- `-w`/`--max-width` no longer cuts an astral character in half.
|
|
29
|
+
- Binary detection now also applies to standard input.
|
|
30
|
+
- Slash-containing globs (e.g. `--include 'src/*.html'`) now match on Windows:
|
|
31
|
+
paths are normalized to `/` separators before glob matching.
|
|
32
|
+
- Recursive walks visit directory entries in sorted order, so output order is
|
|
33
|
+
deterministic across platforms.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- Bare `--color` now means `auto`, matching GNU grep (was `always`).
|
|
37
|
+
- `-c` prints zero counts (`file:0`, or a lone `0` for a single input),
|
|
38
|
+
matching grep.
|
|
39
|
+
|
|
10
40
|
## [1.1.0] - 2026-07-02
|
|
11
41
|
|
|
12
42
|
### Added
|
|
@@ -34,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
34
64
|
- `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
|
|
35
65
|
- Standalone binaries (Bun, Node SEA), shell completions, and a man page.
|
|
36
66
|
|
|
37
|
-
[Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.
|
|
67
|
+
[Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.2.0...HEAD
|
|
68
|
+
[1.2.0]: https://github.com/msbatarce/cssgrep/compare/v1.1.0...v1.2.0
|
|
38
69
|
[1.1.0]: https://github.com/msbatarce/cssgrep/compare/v1.0.0...v1.1.0
|
|
39
70
|
[1.0.0]: https://github.com/msbatarce/cssgrep/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -83,9 +83,10 @@ locator appears only with `-n`:
|
|
|
83
83
|
| `-i`, `--ignore <glob>` | Skip files/dirs matching `<glob>` while recursing (repeatable). `node_modules`, `*.min.html`, `build/` (dir-only), or path globs like `src/vendor/**`. |
|
|
84
84
|
| `--exclude <glob>` | Alias for `--ignore` (grep's name for the same thing). |
|
|
85
85
|
| `--ignore-file <path>` | Load ignore globs from a file, one per line (`#` comments and blank lines ignored) — like a `.gitignore`. |
|
|
86
|
+
| `-S`, `--follow` | Follow symbolic links while recursing with `-r` (they are skipped by default). Each physical directory is visited once, so symlink cycles are safe. |
|
|
86
87
|
| `-n`, `--line-number` | Prefix each match with its `line:col` locator. Mutually exclusive with `-c` and `-p`. |
|
|
87
88
|
| `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input). No `line:col` locator is shown. |
|
|
88
|
-
| `--attr <name>` | Print the value of attribute `<name>` for each match (nodes without it are skipped). Honors `-n` and `-w`. |
|
|
89
|
+
| `--attr <name>` | Print the value of attribute `<name>` for each match (nodes without it are skipped; if every match is skipped the exit status is 1). The name is matched case-insensitively. Honors `-n` and `-w`. |
|
|
89
90
|
| `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
|
|
90
91
|
| `--json` | Print one JSON object per match (NDJSON), with `file`, `line`, `col`, `html`, `text`. |
|
|
91
92
|
| `--parent <n>` | Report the `n`-th element ancestor of each match instead of the match itself (de-duplicated). Pairs well with `-p`. |
|
|
@@ -94,8 +95,8 @@ locator appears only with `-n`:
|
|
|
94
95
|
| `-B`, `--before-context <n>` | Print `n` source lines before each match. |
|
|
95
96
|
| `-C`, `--context <n>` | Print `n` source lines before and after each match. |
|
|
96
97
|
| `-m`, `--max-count <n>` | Stop after `n` matches per file (caps `-c` too). |
|
|
97
|
-
| `-M`, `--max-total <n>` | Stop after `n` matches in total across all files. |
|
|
98
|
-
| `-c`, `--count` | Print only the match count (per file when relevant). |
|
|
98
|
+
| `-M`, `--max-total <n>` | Stop after `n` matches in total across all files. The budget counts matches, not files — combined with `-l`/`-L`, scanning stops once `n` matches have been seen, which can cut the file list short. |
|
|
99
|
+
| `-c`, `--count` | Print only the match count (per file when relevant, zeros included — `file:0`, like grep). |
|
|
99
100
|
| `-l`, `--files-with-matches` | Print only the names of files that contain a match. |
|
|
100
101
|
| `-L`, `--files-without-match` | Print only the names of files with no match. |
|
|
101
102
|
| `-q`, `--quiet` | Print nothing; exit `0` on the first match, `1` if none. Stops early. |
|
|
@@ -103,7 +104,7 @@ locator appears only with `-n`:
|
|
|
103
104
|
| `-0`, `--null` | Output a NUL after each file name instead of `:` (or, with `-l`/`-L`, instead of the newline) — pipe to `xargs -0`. |
|
|
104
105
|
| `-H`, `--with-filename` | Always print the `file:` prefix, even for a single file or stdin. |
|
|
105
106
|
| `--no-filename` | Never print the `file:` prefix, even when searching multiple files. |
|
|
106
|
-
| `--color[=<when>]` | Colorize output: `auto` (default — color only when stdout is a terminal), `always`, or `never`. A bare `--color` means `always
|
|
107
|
+
| `--color[=<when>]` | Colorize output: `auto` (default — color only when stdout is a terminal), `always`, or `never`. A bare `--color` means `auto`, like grep; use `--color=always` to force color into pipes. |
|
|
107
108
|
| `-h`, `--help` | Show help. |
|
|
108
109
|
| `-V`, `--version` | Print the version and exit. |
|
|
109
110
|
|
|
@@ -115,7 +116,7 @@ segment), `**` (across `/`), `?` (one non-slash char), and brace alternation
|
|
|
115
116
|
like `*.{html,htm}`. A trailing `/` matches directories only; a pattern with a
|
|
116
117
|
`/` matches against the path, otherwise the basename — gitignore-flavored.
|
|
117
118
|
|
|
118
|
-
Binary
|
|
119
|
+
Binary input is detected (a NUL byte or a high ratio of control bytes in the
|
|
119
120
|
first 8 KB) and skipped with a note on stderr — parsing them as HTML is never
|
|
120
121
|
useful. Suppress the note with `-s` (or `-q`).
|
|
121
122
|
|
|
@@ -206,4 +207,6 @@ a flag.)
|
|
|
206
207
|
- [`css-select`](https://github.com/fb55/css-select) matches the selector
|
|
207
208
|
against that DOM (the same engine cheerio uses).
|
|
208
209
|
- Offsets are converted to 1-based `line:col` via a precomputed line index;
|
|
209
|
-
`col` points at the opening `<` of the matched tag
|
|
210
|
+
`col` points at the opening `<` of the matched tag and counts *bytes* — what
|
|
211
|
+
vim's `grepformat` `%c` expects — so multibyte UTF-8 text earlier on the line
|
|
212
|
+
doesn't skew the cursor.
|
package/completions/_cssgrep
CHANGED
|
@@ -12,6 +12,7 @@ _cssgrep() {
|
|
|
12
12
|
'*'{-i,--ignore}'[skip files/dirs matching glob while recursing]:glob:' \
|
|
13
13
|
'*--exclude[alias for --ignore]:glob:' \
|
|
14
14
|
'--ignore-file[read ignore globs from a file]:file:_files' \
|
|
15
|
+
'(-S --follow)'{-S,--follow}'[follow symbolic links while recursing]' \
|
|
15
16
|
'(-n --line-number)'{-n,--line-number}'[prefix each match with line:col]' \
|
|
16
17
|
'(-p --print)'{-p,--print}'[pretty-print the matched node HTML]' \
|
|
17
18
|
'--attr[print the value of an attribute]:attribute name:' \
|
package/completions/cssgrep.bash
CHANGED
|
@@ -6,7 +6,7 @@ _cssgrep() {
|
|
|
6
6
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
7
7
|
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
8
8
|
|
|
9
|
-
opts="-r --recursive --max-depth --ext --include -i --ignore --exclude --ignore-file -n --line-number \
|
|
9
|
+
opts="-r --recursive --max-depth --ext --include -i --ignore --exclude --ignore-file -S --follow -n --line-number \
|
|
10
10
|
-p --print --attr --text --json --parent -w --max-width \
|
|
11
11
|
-A --after-context -B --before-context -C --context \
|
|
12
12
|
-m --max-count -M --max-total -c --count \
|
package/completions/cssgrep.fish
CHANGED
|
@@ -8,6 +8,7 @@ complete -c cssgrep -l include -x -d 'Only search files matching glob (replaces
|
|
|
8
8
|
complete -c cssgrep -s i -l ignore -x -d 'Skip files/dirs matching glob while recursing'
|
|
9
9
|
complete -c cssgrep -l exclude -x -d 'Alias for --ignore'
|
|
10
10
|
complete -c cssgrep -l ignore-file -r -F -d 'Read ignore globs from a file'
|
|
11
|
+
complete -c cssgrep -s S -l follow -d 'Follow symbolic links while recursing'
|
|
11
12
|
complete -c cssgrep -s n -l line-number -d 'Prefix each match with line:col'
|
|
12
13
|
complete -c cssgrep -s p -l print -d 'Pretty-print the matched node HTML'
|
|
13
14
|
complete -c cssgrep -l attr -x -d 'Print the value of an attribute'
|
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@ const { html: beautify } = require('js-beautify');
|
|
|
12
12
|
// package.json, so it survives compilation into a standalone binary (Bun
|
|
13
13
|
// --compile / Node SEA), where package.json won't sit next to the executable.
|
|
14
14
|
// Keep in sync with package.json on release.
|
|
15
|
-
const VERSION = '1.
|
|
15
|
+
const VERSION = '1.2.0';
|
|
16
16
|
|
|
17
17
|
const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
|
|
18
18
|
|
|
@@ -35,6 +35,8 @@ Options:
|
|
|
35
35
|
-i, --ignore <glob> Skip files/dirs matching <glob> when recursing (repeatable).
|
|
36
36
|
--exclude <glob> Alias for --ignore.
|
|
37
37
|
--ignore-file <path> Read ignore globs from <path> (one per line, # comments).
|
|
38
|
+
-S, --follow Follow symbolic links when recursing with -r
|
|
39
|
+
(default: skip them; loops are detected).
|
|
38
40
|
-n, --line-number Prefix each match with its line:col (excludes -c, -p).
|
|
39
41
|
-p, --print Pretty-print the matched node's HTML above its location.
|
|
40
42
|
--attr <name> Print the value of attribute <name> (skips nodes without it).
|
|
@@ -47,7 +49,8 @@ Options:
|
|
|
47
49
|
-C, --context <n> Print <n> source lines before and after each match.
|
|
48
50
|
-m, --max-count <n> Stop after <n> matches per file.
|
|
49
51
|
-M, --max-total <n> Stop after <n> matches in total (across all files).
|
|
50
|
-
-c, --count Print only a count of matches (per file when
|
|
52
|
+
-c, --count Print only a count of matches (per file when
|
|
53
|
+
relevant, zeros included, like grep).
|
|
51
54
|
-l, --files-with-matches Print only the names of files that have a match.
|
|
52
55
|
-L, --files-without-match Print only the names of files with no match.
|
|
53
56
|
-q, --quiet Print nothing; exit 0 on first match, 1 if none.
|
|
@@ -55,7 +58,8 @@ Options:
|
|
|
55
58
|
-0, --null Separate the file name with a NUL byte (for xargs -0).
|
|
56
59
|
-H, --with-filename Always print the file name prefix (even for one file).
|
|
57
60
|
--no-filename Never print the file name prefix (even for many files).
|
|
58
|
-
--color[=<when>] Colorize output: auto (default
|
|
61
|
+
--color[=<when>] Colorize output: auto (default, also what a bare
|
|
62
|
+
--color means, like grep), always or never.
|
|
59
63
|
-h, --help Show this help.
|
|
60
64
|
-V, --version Show version and exit.
|
|
61
65
|
|
|
@@ -92,6 +96,7 @@ function parseArgs(argv) {
|
|
|
92
96
|
positionals: [],
|
|
93
97
|
paths: [],
|
|
94
98
|
recursive: false,
|
|
99
|
+
follow: false,
|
|
95
100
|
exts: ['html', 'htm'],
|
|
96
101
|
extGiven: false,
|
|
97
102
|
maxDepth: 0,
|
|
@@ -153,16 +158,22 @@ function parseArgs(argv) {
|
|
|
153
158
|
const a = argv[i];
|
|
154
159
|
|
|
155
160
|
// Long options: --name or --name=value. A missing inline value is taken
|
|
156
|
-
// from the next argument (except --color, where a bare flag means "
|
|
161
|
+
// from the next argument (except --color, where a bare flag means "auto",
|
|
162
|
+
// as in GNU grep).
|
|
157
163
|
if (a.startsWith('--') && a.length > 2) {
|
|
158
164
|
const eq = a.indexOf('=');
|
|
159
165
|
const name = eq === -1 ? a : a.slice(0, eq);
|
|
160
166
|
const inline = eq === -1 ? null : a.slice(eq + 1);
|
|
161
|
-
const value = () =>
|
|
167
|
+
const value = () => {
|
|
168
|
+
if (inline != null) return inline;
|
|
169
|
+
if (i + 1 >= argv.length) fail(`option ${name} requires a value`);
|
|
170
|
+
return argv[++i];
|
|
171
|
+
};
|
|
162
172
|
switch (name) {
|
|
163
173
|
case '--help': process.stdout.write(USAGE + '\n'); process.exit(0); break;
|
|
164
174
|
case '--version': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
|
|
165
175
|
case '--recursive': opts.recursive = true; break;
|
|
176
|
+
case '--follow': opts.follow = true; break;
|
|
166
177
|
case '--line-number': opts.lineNumber = true; break;
|
|
167
178
|
case '--print': opts.print = true; break;
|
|
168
179
|
case '--count': opts.count = true; break;
|
|
@@ -181,14 +192,15 @@ function parseArgs(argv) {
|
|
|
181
192
|
case '--max-count': setMaxCount(value()); break;
|
|
182
193
|
case '--max-total': setMaxTotal(value()); break;
|
|
183
194
|
case '--max-depth': setMaxDepth(value()); break;
|
|
184
|
-
|
|
195
|
+
// htmlparser2 lowercases HTML attribute names, so match case-insensitively.
|
|
196
|
+
case '--attr': opts.attr = value().toLowerCase(); break;
|
|
185
197
|
case '--text': opts.text = true; break;
|
|
186
198
|
case '--json': opts.json = true; break;
|
|
187
199
|
case '--parent': setParent(value()); break;
|
|
188
200
|
case '--after-context': setAfter(value()); break;
|
|
189
201
|
case '--before-context': setBefore(value()); break;
|
|
190
202
|
case '--context': setContext(value()); break;
|
|
191
|
-
case '--color': case '--colour': opts.color = inline != null ? inline : '
|
|
203
|
+
case '--color': case '--colour': opts.color = inline != null ? inline : 'auto'; break;
|
|
192
204
|
default: fail(`unknown option: ${name}`);
|
|
193
205
|
}
|
|
194
206
|
continue;
|
|
@@ -202,6 +214,7 @@ function parseArgs(argv) {
|
|
|
202
214
|
const ch = a[j];
|
|
203
215
|
if (shortValueFlags[ch]) {
|
|
204
216
|
const rest = a.slice(j + 1); // attached value, if any
|
|
217
|
+
if (rest === '' && i + 1 >= argv.length) fail(`option -${ch} requires a value`);
|
|
205
218
|
shortValueFlags[ch](rest !== '' ? rest : argv[++i]);
|
|
206
219
|
break; // value swallowed the cluster tail
|
|
207
220
|
}
|
|
@@ -209,6 +222,7 @@ function parseArgs(argv) {
|
|
|
209
222
|
case 'h': process.stdout.write(USAGE + '\n'); process.exit(0); break;
|
|
210
223
|
case 'V': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
|
|
211
224
|
case 'r': opts.recursive = true; break;
|
|
225
|
+
case 'S': opts.follow = true; break;
|
|
212
226
|
case 'n': opts.lineNumber = true; break;
|
|
213
227
|
case 'p': opts.print = true; break;
|
|
214
228
|
case 'c': opts.count = true; break;
|
|
@@ -309,7 +323,12 @@ function offsetToPosition(starts, src, offset) {
|
|
|
309
323
|
if (text.endsWith('\r')) text = text.slice(0, -1);
|
|
310
324
|
return {
|
|
311
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.
|
|
312
330
|
col: offset - lineStart + 1, // 1-based
|
|
331
|
+
bcol: Buffer.byteLength(src.slice(lineStart, offset), 'utf8') + 1, // 1-based
|
|
313
332
|
text,
|
|
314
333
|
};
|
|
315
334
|
}
|
|
@@ -325,10 +344,18 @@ function lineTextAt(starts, src, lineNo) {
|
|
|
325
344
|
return { lineStart, text };
|
|
326
345
|
}
|
|
327
346
|
|
|
347
|
+
// Never cut between the halves of a surrogate pair: a lone half is invalid
|
|
348
|
+
// Unicode and serializes as U+FFFD garbage. Backing off one unit loses at most
|
|
349
|
+
// one display column.
|
|
350
|
+
function safeCut(text, at) {
|
|
351
|
+
const c = text.charCodeAt(at - 1);
|
|
352
|
+
return c >= 0xd800 && c <= 0xdbff ? at - 1 : at;
|
|
353
|
+
}
|
|
354
|
+
|
|
328
355
|
function truncate(text, maxWidth) {
|
|
329
356
|
if (!maxWidth || text.length <= maxWidth) return text;
|
|
330
|
-
if (maxWidth <= 1) return text.slice(0, maxWidth);
|
|
331
|
-
return text.slice(0, maxWidth - 1) + '…'; // …
|
|
357
|
+
if (maxWidth <= 1) return text.slice(0, safeCut(text, maxWidth));
|
|
358
|
+
return text.slice(0, safeCut(text, maxWidth - 1)) + '…'; // …
|
|
332
359
|
}
|
|
333
360
|
|
|
334
361
|
// Produce the visible match text: truncate first (so --max-width still measures
|
|
@@ -507,7 +534,7 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
507
534
|
if (label) prefix += c(COLORS.file, label) + (opts.nul ? '\0' : sepColored);
|
|
508
535
|
if (opts.lineNumber) {
|
|
509
536
|
prefix += c(COLORS.line, String(L));
|
|
510
|
-
prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.
|
|
537
|
+
prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + ' '
|
|
511
538
|
: c(COLORS.sep, '-');
|
|
512
539
|
}
|
|
513
540
|
const body = m
|
|
@@ -539,10 +566,11 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
539
566
|
const cap = Math.min(opts.maxCount || Infinity, limit);
|
|
540
567
|
const limited = Number.isFinite(cap) ? matches.slice(0, cap) : matches;
|
|
541
568
|
if (opts.count) {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
569
|
+
// grep parity: every searched file reports a count, zeros included, so
|
|
570
|
+
// scripts get one row per file (exit status still says whether anything
|
|
571
|
+
// matched at all).
|
|
572
|
+
const fileSep = opts.nul ? '\0' : ':';
|
|
573
|
+
out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
|
|
546
574
|
return limited.length;
|
|
547
575
|
}
|
|
548
576
|
const starts = opts.print ? null : lineIndex(src);
|
|
@@ -563,6 +591,10 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
563
591
|
emitContext(src, starts, name, showLabel, targets, opts, out);
|
|
564
592
|
return limited.length;
|
|
565
593
|
}
|
|
594
|
+
// From here on the return value is the number of *emitted* records (which is
|
|
595
|
+
// what the -M budget and the exit status should count): --attr can skip
|
|
596
|
+
// matches lacking the attribute, and --parent dedup can merge several
|
|
597
|
+
// matches into one printed ancestor.
|
|
566
598
|
if (opts.json) {
|
|
567
599
|
// NDJSON: one self-contained record per match. `html` is the exact source
|
|
568
600
|
// slice; newlines are escaped by JSON.stringify, so each record stays on
|
|
@@ -574,18 +606,20 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
574
606
|
out.push(JSON.stringify({
|
|
575
607
|
file: name,
|
|
576
608
|
line: pos.line,
|
|
577
|
-
col: pos.
|
|
609
|
+
col: pos.bcol,
|
|
578
610
|
html: src.slice(off, nodeEnd),
|
|
579
611
|
text: collapseWs(textOf(el)),
|
|
580
612
|
}));
|
|
581
613
|
}
|
|
582
|
-
return
|
|
614
|
+
return targets.length;
|
|
583
615
|
}
|
|
616
|
+
let emitted = 0;
|
|
584
617
|
for (const el of targets) {
|
|
585
618
|
if (opts.print) {
|
|
586
619
|
// -p shows the re-indented node only; no line:col locator. With --parent,
|
|
587
620
|
// the original matched descendants are highlighted inside the container.
|
|
588
621
|
out.push(prettyPrint(el, originsByTarget.get(el), opts), ''); // blank separator
|
|
622
|
+
emitted++;
|
|
589
623
|
continue;
|
|
590
624
|
}
|
|
591
625
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
@@ -615,11 +649,12 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
615
649
|
let prefix = '';
|
|
616
650
|
if (label) prefix += c(COLORS.file, label) + fileSep;
|
|
617
651
|
if (opts.lineNumber) {
|
|
618
|
-
prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.
|
|
652
|
+
prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.bcol)) + ' ';
|
|
619
653
|
}
|
|
620
654
|
out.push(prefix + text);
|
|
655
|
+
emitted++;
|
|
621
656
|
}
|
|
622
|
-
return
|
|
657
|
+
return emitted;
|
|
623
658
|
}
|
|
624
659
|
|
|
625
660
|
// Translate a glob to a regex body. `*` matches within a path segment, `**`
|
|
@@ -632,9 +667,15 @@ function globToRegex(glob) {
|
|
|
632
667
|
const c = glob[i];
|
|
633
668
|
if (c === '*') {
|
|
634
669
|
if (glob[i + 1] === '*') { // ** (crosses /)
|
|
635
|
-
re += '.*';
|
|
636
670
|
i++;
|
|
637
|
-
if (glob[i + 1] === '/')
|
|
671
|
+
if (glob[i + 1] === '/') {
|
|
672
|
+
// `**/` spans whole segments (or none): `**/foo` matches `foo` and
|
|
673
|
+
// `a/b/foo`, but not `barfoo` — the `.*` must end at a `/`.
|
|
674
|
+
re += '(?:.*/)?';
|
|
675
|
+
i++;
|
|
676
|
+
} else {
|
|
677
|
+
re += '.*';
|
|
678
|
+
}
|
|
638
679
|
} else {
|
|
639
680
|
re += '[^/]*';
|
|
640
681
|
}
|
|
@@ -672,16 +713,25 @@ function compileIgnore(pattern) {
|
|
|
672
713
|
}
|
|
673
714
|
|
|
674
715
|
// True if name/path matches any compiled glob matcher (shared by --ignore/
|
|
675
|
-
// --exclude and --include).
|
|
716
|
+
// --exclude and --include). Globs always use `/` as the separator (gitignore
|
|
717
|
+
// semantics), so normalize Windows backslash paths before matching.
|
|
676
718
|
function matchesAny(name, full, isDir, matchers) {
|
|
719
|
+
const fullPosix = path.sep === '/' ? full : full.split(path.sep).join('/');
|
|
677
720
|
for (const m of matchers) {
|
|
678
721
|
if (m.dirOnly && !isDir) continue;
|
|
679
|
-
if (m.re.test(m.hasSlash ?
|
|
722
|
+
if (m.re.test(m.hasSlash ? fullPosix : name)) return true;
|
|
680
723
|
}
|
|
681
724
|
return false;
|
|
682
725
|
}
|
|
683
726
|
|
|
684
|
-
|
|
727
|
+
// `visited` (only with -S/--follow) holds the realpath of every directory
|
|
728
|
+
// already entered, so symlink cycles — and two links to the same physical
|
|
729
|
+
// directory — are traversed once. Without --follow, symlinks are skipped.
|
|
730
|
+
function* walk(dir, opts, depth = 1, visited = null) {
|
|
731
|
+
if (opts.follow && visited === null) {
|
|
732
|
+
visited = new Set();
|
|
733
|
+
try { visited.add(fs.realpathSync(dir)); } catch (e) { /* walked anyway */ }
|
|
734
|
+
}
|
|
685
735
|
let entries;
|
|
686
736
|
try {
|
|
687
737
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -689,14 +739,30 @@ function* walk(dir, opts, depth = 1) {
|
|
|
689
739
|
if (!opts.noMessages) process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
|
|
690
740
|
return;
|
|
691
741
|
}
|
|
742
|
+
// readdir order is filesystem-dependent; sort so output order (and therefore
|
|
743
|
+
// -M/-m truncation points) is stable across platforms.
|
|
744
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
692
745
|
for (const e of entries) {
|
|
693
746
|
const full = path.join(dir, e.name);
|
|
694
|
-
|
|
747
|
+
let isDir = e.isDirectory();
|
|
748
|
+
let isFile = e.isFile();
|
|
749
|
+
if (opts.follow && e.isSymbolicLink()) {
|
|
750
|
+
const st = fs.statSync(full, { throwIfNoEntry: false }); // resolves the link
|
|
751
|
+
if (!st) continue; // broken link
|
|
752
|
+
isDir = st.isDirectory();
|
|
753
|
+
isFile = st.isFile();
|
|
754
|
+
}
|
|
695
755
|
if (opts.ignore.length && matchesAny(e.name, full, isDir, opts.ignore)) continue;
|
|
696
756
|
if (isDir) {
|
|
757
|
+
if (opts.follow) {
|
|
758
|
+
let real;
|
|
759
|
+
try { real = fs.realpathSync(full); } catch (err) { continue; }
|
|
760
|
+
if (visited.has(real)) continue; // cycle or already-walked dir
|
|
761
|
+
visited.add(real);
|
|
762
|
+
}
|
|
697
763
|
// --max-depth caps how far we descend; depth 1 = the target's children.
|
|
698
|
-
if (!opts.maxDepth || depth < opts.maxDepth) yield* walk(full, opts, depth + 1);
|
|
699
|
-
} else if (
|
|
764
|
+
if (!opts.maxDepth || depth < opts.maxDepth) yield* walk(full, opts, depth + 1, visited);
|
|
765
|
+
} else if (isFile) {
|
|
700
766
|
// --include replaces the extension filter; otherwise filter by --ext.
|
|
701
767
|
if (opts.include.length) {
|
|
702
768
|
if (matchesAny(e.name, full, false, opts.include)) yield full;
|
|
@@ -709,7 +775,7 @@ function* walk(dir, opts, depth = 1) {
|
|
|
709
775
|
}
|
|
710
776
|
|
|
711
777
|
function readStdin() {
|
|
712
|
-
return fs.readFileSync(0
|
|
778
|
+
return fs.readFileSync(0); // Buffer: sniffed for binary before decoding
|
|
713
779
|
}
|
|
714
780
|
|
|
715
781
|
// Heuristic binary-file detector (grep/git style). A NUL byte in the first 8 KB
|
|
@@ -765,7 +831,14 @@ function main() {
|
|
|
765
831
|
|
|
766
832
|
try {
|
|
767
833
|
if (useStdin) {
|
|
768
|
-
|
|
834
|
+
const buf = readStdin();
|
|
835
|
+
if (looksBinary(buf)) {
|
|
836
|
+
if (!opts.noMessages && !opts.quiet) {
|
|
837
|
+
process.stderr.write('cssgrep: (standard input): binary input (skipped)\n');
|
|
838
|
+
}
|
|
839
|
+
} else {
|
|
840
|
+
total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out, room());
|
|
841
|
+
}
|
|
769
842
|
} else {
|
|
770
843
|
for (const f of files) {
|
|
771
844
|
if (room() <= 0) break; // -M: global budget exhausted
|
|
@@ -805,8 +878,11 @@ function main() {
|
|
|
805
878
|
}
|
|
806
879
|
// Normally success means "a match was found". With -L it means "a file
|
|
807
880
|
// without a match was printed", which is decoupled from the match total.
|
|
881
|
+
// Set exitCode rather than calling process.exit(): stdout writes to a pipe
|
|
882
|
+
// are async, and exit() would drop whatever hasn't flushed yet, truncating
|
|
883
|
+
// large result sets mid-stream.
|
|
808
884
|
const success = opts.filesWithoutMatch ? out.length > 0 : total > 0;
|
|
809
|
-
process.
|
|
885
|
+
process.exitCode = success ? 0 : 1;
|
|
810
886
|
}
|
|
811
887
|
|
|
812
888
|
main();
|
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 index.js.
|
|
3
|
-
.TH CSSGREP 1 "2026-07-02" "cssgrep 1.
|
|
3
|
+
.TH CSSGREP 1 "2026-07-02" "cssgrep 1.2.0" "User Commands"
|
|
4
4
|
.SH NAME
|
|
5
5
|
cssgrep \- search HTML by CSS selector, grep-style
|
|
6
6
|
.SH SYNOPSIS
|
|
@@ -33,9 +33,9 @@ Input is read from the
|
|
|
33
33
|
.I file
|
|
34
34
|
arguments, recursively from directories with
|
|
35
35
|
.BR \-r ,
|
|
36
|
-
or from standard input when no path is given. Binary
|
|
37
|
-
high ratio of control bytes in the first 8 KB)
|
|
38
|
-
standard error, suppressible with
|
|
36
|
+
or from standard input when no path is given. Binary input, including standard
|
|
37
|
+
input (a NUL byte or a high ratio of control bytes in the first 8 KB), is
|
|
38
|
+
skipped with a note on standard error, suppressible with
|
|
39
39
|
.B \-s
|
|
40
40
|
or
|
|
41
41
|
.BR \-q .
|
|
@@ -91,10 +91,21 @@ one per line
|
|
|
91
91
|
comments and blank lines ignored), like a
|
|
92
92
|
.IR .gitignore .
|
|
93
93
|
.TP
|
|
94
|
+
.BR \-S ", " \-\-follow
|
|
95
|
+
Follow symbolic links while recursing with
|
|
96
|
+
.B \-r
|
|
97
|
+
(they are skipped by default). Each physical directory is visited once, so
|
|
98
|
+
symlink cycles are safe.
|
|
99
|
+
.TP
|
|
94
100
|
.BR \-n ", " \-\-line\-number
|
|
95
101
|
Prefix each match with its
|
|
96
102
|
.IR line : col
|
|
97
|
-
locator.
|
|
103
|
+
locator. The column counts bytes (what vim's
|
|
104
|
+
.B grepformat
|
|
105
|
+
.I %c
|
|
106
|
+
expects), pointing at the opening
|
|
107
|
+
.B <
|
|
108
|
+
of the matched tag. Excludes
|
|
98
109
|
.B \-c
|
|
99
110
|
and
|
|
100
111
|
.BR \-p .
|
|
@@ -107,7 +118,8 @@ locator is shown.
|
|
|
107
118
|
.BI \-\-attr " name"
|
|
108
119
|
Print the value of attribute
|
|
109
120
|
.I name
|
|
110
|
-
for each match (nodes without it are skipped
|
|
121
|
+
for each match (nodes without it are skipped; if every match is skipped the
|
|
122
|
+
exit status is 1). The name is matched case-insensitively.
|
|
111
123
|
.TP
|
|
112
124
|
.B \-\-text
|
|
113
125
|
Print the matched node's text content, whitespace collapsed.
|
|
@@ -149,10 +161,17 @@ matches per file.
|
|
|
149
161
|
.BR \-M ", " \-\-max\-total " \fIn\fR"
|
|
150
162
|
Stop after
|
|
151
163
|
.I n
|
|
152
|
-
matches in total across all files.
|
|
164
|
+
matches in total across all files. The budget counts matches, not files;
|
|
165
|
+
combined with
|
|
166
|
+
.B \-l
|
|
167
|
+
or
|
|
168
|
+
.B \-L
|
|
169
|
+
scanning stops once the budget is spent, which can cut the file list short.
|
|
153
170
|
.TP
|
|
154
171
|
.BR \-c ", " \-\-count
|
|
155
|
-
Print only a count of matches (per file when relevant).
|
|
172
|
+
Print only a count of matches (per file when relevant). Files with no match
|
|
173
|
+
report a zero count, as in
|
|
174
|
+
.BR grep .
|
|
156
175
|
.TP
|
|
157
176
|
.BR \-l ", " \-\-files\-with\-matches
|
|
158
177
|
Print only the names of files that have a match.
|
|
@@ -184,7 +203,12 @@ Colorize output:
|
|
|
184
203
|
A bare
|
|
185
204
|
.B \-\-color
|
|
186
205
|
means
|
|
187
|
-
.IR
|
|
206
|
+
.IR auto ,
|
|
207
|
+
as in
|
|
208
|
+
.BR grep ;
|
|
209
|
+
use
|
|
210
|
+
.B \-\-color=always
|
|
211
|
+
to force color into a pipe.
|
|
188
212
|
.TP
|
|
189
213
|
.BR \-h ", " \-\-help
|
|
190
214
|
Show help and exit.
|