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 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.1.0...HEAD
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 files are detected (a NUL byte or a high ratio of control bytes in the
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.
@@ -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:' \
@@ -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 \
@@ -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.1.0';
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 relevant).
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), always or never.
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 "always").
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 = () => (inline != null ? inline : argv[++i]);
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
- case '--attr': opts.attr = value(); break;
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 : 'always'; break;
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.col)) + ' '
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
- if (limited.length) {
543
- const fileSep = opts.nul ? '\0' : ':';
544
- out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
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.col,
609
+ col: pos.bcol,
578
610
  html: src.slice(off, nodeEnd),
579
611
  text: collapseWs(textOf(el)),
580
612
  }));
581
613
  }
582
- return limited.length;
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.col)) + ' ';
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 limited.length;
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] === '/') i++; // consume the slash in **/
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 ? full : name)) return true;
722
+ if (m.re.test(m.hasSlash ? fullPosix : name)) return true;
680
723
  }
681
724
  return false;
682
725
  }
683
726
 
684
- function* walk(dir, opts, depth = 1) {
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
- const isDir = e.isDirectory();
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 (e.isFile()) {
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, 'utf8');
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
- total += searchSource(readStdin(), '(standard input)', showLabel, opts, out, room());
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.exit(success ? 0 : 1);
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.1.0" "User Commands"
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 files (a NUL byte or a
37
- high ratio of control bytes in the first 8 KB) are skipped with a note on
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. Excludes
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 always .
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssgrep",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Search HTML by CSS selector and print matches grep-style (file:line:col with -n).",
5
5
  "main": "index.js",
6
6
  "bin": {