cssgrep 1.0.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 +70 -0
- package/README.md +27 -5
- package/completions/_cssgrep +7 -0
- package/completions/cssgrep.bash +4 -3
- package/completions/cssgrep.fish +7 -0
- package/index.js +193 -43
- package/man/cssgrep.1 +70 -7
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
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
|
+
|
|
40
|
+
## [1.1.0] - 2026-07-02
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- `-H`/`--with-filename` and `--no-filename` to force the `file:` prefix on or off.
|
|
44
|
+
- `-s`/`--no-messages` to suppress errors for unreadable or missing files.
|
|
45
|
+
- `--include`/`--exclude` file globs with brace alternation (`{a,b,c}`).
|
|
46
|
+
`--exclude` is an alias of `--ignore`; `--include` replaces the `--ext` filter.
|
|
47
|
+
- `--max-depth <n>` to cap `-r` recursion depth.
|
|
48
|
+
- Automatic skipping of binary files (NUL byte / control-byte heuristic), with a
|
|
49
|
+
note on stderr suppressible by `-s`/`-q`.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- CI now runs on Linux, macOS and Windows across Node 20 and 22.
|
|
53
|
+
- Releases publish to npm with provenance and smoke-test the built binary.
|
|
54
|
+
|
|
55
|
+
## [1.0.0] - 2026-06-29
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
- Initial release: match a CSS selector against HTML and print each hit
|
|
59
|
+
grep-style, with byte-accurate `line:col` via `-n` (works on minified HTML).
|
|
60
|
+
- Recursive search: `-r`/`--recursive`, `--ext`, `-i`/`--ignore`, `--ignore-file`.
|
|
61
|
+
- Print modes: default line, `-p`/`--print`, `--attr`, `--text`, `--json`.
|
|
62
|
+
- `--parent <n>` structural context; line context `-A`/`-B`/`-C`.
|
|
63
|
+
- Limits `-m`/`--max-count`, `-M`/`--max-total`; aggregates `-c`, `-l`/`-L`, `-q`.
|
|
64
|
+
- `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
|
|
65
|
+
- Standalone binaries (Bun, Node SEA), shell completions, and a man page.
|
|
66
|
+
|
|
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
|
|
69
|
+
[1.1.0]: https://github.com/msbatarce/cssgrep/compare/v1.0.0...v1.1.0
|
|
70
|
+
[1.0.0]: https://github.com/msbatarce/cssgrep/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# cssgrep
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/cssgrep)
|
|
4
|
+
[](https://github.com/msbatarce/cssgrep/actions/workflows/test.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
Search HTML by CSS selector and print each match, grep-style — add `-n` to
|
|
4
8
|
report it as `file:line:col`.
|
|
5
9
|
|
|
@@ -73,12 +77,16 @@ locator appears only with `-n`:
|
|
|
73
77
|
| Flag | Description |
|
|
74
78
|
|------|-------------|
|
|
75
79
|
| `-r`, `--recursive` | Recurse into directory arguments (defaults to `.` if none given). |
|
|
80
|
+
| `--max-depth <n>` | Limit `-r` recursion depth (`1` = the given directory only, no subdirectories). |
|
|
76
81
|
| `--ext <list>` | Extensions to scan with `-r` (default `html,htm`). Value attaches with `=`: `--ext htm` or `--ext=htm`. |
|
|
82
|
+
| `--include <glob>` | Only search files whose name/path matches `<glob>` while recursing (repeatable). Replaces `--ext` — the two can't be combined. e.g. `--include '*.{html,htm,xhtml}'`. |
|
|
77
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
|
+
| `--exclude <glob>` | Alias for `--ignore` (grep's name for the same thing). |
|
|
78
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. |
|
|
79
87
|
| `-n`, `--line-number` | Prefix each match with its `line:col` locator. Mutually exclusive with `-c` and `-p`. |
|
|
80
88
|
| `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input). No `line:col` locator is shown. |
|
|
81
|
-
| `--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`. |
|
|
82
90
|
| `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
|
|
83
91
|
| `--json` | Print one JSON object per match (NDJSON), with `file`, `line`, `col`, `html`, `text`. |
|
|
84
92
|
| `--parent <n>` | Report the `n`-th element ancestor of each match instead of the match itself (de-duplicated). Pairs well with `-p`. |
|
|
@@ -87,19 +95,31 @@ locator appears only with `-n`:
|
|
|
87
95
|
| `-B`, `--before-context <n>` | Print `n` source lines before each match. |
|
|
88
96
|
| `-C`, `--context <n>` | Print `n` source lines before and after each match. |
|
|
89
97
|
| `-m`, `--max-count <n>` | Stop after `n` matches per file (caps `-c` too). |
|
|
90
|
-
| `-M`, `--max-total <n>` | Stop after `n` matches in total across all files. |
|
|
91
|
-
| `-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). |
|
|
92
100
|
| `-l`, `--files-with-matches` | Print only the names of files that contain a match. |
|
|
93
101
|
| `-L`, `--files-without-match` | Print only the names of files with no match. |
|
|
94
102
|
| `-q`, `--quiet` | Print nothing; exit `0` on the first match, `1` if none. Stops early. |
|
|
103
|
+
| `-s`, `--no-messages` | Suppress error messages for unreadable or missing files (handy with `-r`). |
|
|
95
104
|
| `-0`, `--null` | Output a NUL after each file name instead of `:` (or, with `-l`/`-L`, instead of the newline) — pipe to `xargs -0`. |
|
|
96
|
-
| `--
|
|
105
|
+
| `-H`, `--with-filename` | Always print the `file:` prefix, even for a single file or stdin. |
|
|
106
|
+
| `--no-filename` | Never print the `file:` prefix, even when searching multiple files. |
|
|
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. |
|
|
97
108
|
| `-h`, `--help` | Show help. |
|
|
98
109
|
| `-V`, `--version` | Print the version and exit. |
|
|
99
110
|
|
|
100
111
|
Boolean short flags can be combined into one token (`-rn` is `-r -n`), and a
|
|
101
112
|
value-taking flag may close such a cluster (`-rnw100`).
|
|
102
113
|
|
|
114
|
+
Globs for `--include`/`--ignore`/`--exclude` support `*` (within a path
|
|
115
|
+
segment), `**` (across `/`), `?` (one non-slash char), and brace alternation
|
|
116
|
+
like `*.{html,htm}`. A trailing `/` matches directories only; a pattern with a
|
|
117
|
+
`/` matches against the path, otherwise the basename — gitignore-flavored.
|
|
118
|
+
|
|
119
|
+
Binary input is detected (a NUL byte or a high ratio of control bytes in the
|
|
120
|
+
first 8 KB) and skipped with a note on stderr — parsing them as HTML is never
|
|
121
|
+
useful. Suppress the note with `-s` (or `-q`).
|
|
122
|
+
|
|
103
123
|
When coloring is on, the matched node is highlighted within its line (grep's
|
|
104
124
|
bold-red); the `file:` prefix and `line:col` locator get their own colors
|
|
105
125
|
(magenta and green, like grep). Plain `-p` prints no color (the whole block is
|
|
@@ -187,4 +207,6 @@ a flag.)
|
|
|
187
207
|
- [`css-select`](https://github.com/fb55/css-select) matches the selector
|
|
188
208
|
against that DOM (the same engine cheerio uses).
|
|
189
209
|
- Offsets are converted to 1-based `line:col` via a precomputed line index;
|
|
190
|
-
`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
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
_cssgrep() {
|
|
7
7
|
_arguments -s -S \
|
|
8
8
|
'(-r --recursive)'{-r,--recursive}'[recurse into directory arguments]' \
|
|
9
|
+
'--max-depth[limit -r recursion depth (1 = given dir only)]:depth:' \
|
|
9
10
|
'--ext[extensions to scan with -r]:extensions (comma-separated):' \
|
|
11
|
+
'*--include[only search files matching glob (replaces --ext)]:glob:' \
|
|
10
12
|
'*'{-i,--ignore}'[skip files/dirs matching glob while recursing]:glob:' \
|
|
13
|
+
'*--exclude[alias for --ignore]:glob:' \
|
|
11
14
|
'--ignore-file[read ignore globs from a file]:file:_files' \
|
|
15
|
+
'(-S --follow)'{-S,--follow}'[follow symbolic links while recursing]' \
|
|
12
16
|
'(-n --line-number)'{-n,--line-number}'[prefix each match with line:col]' \
|
|
13
17
|
'(-p --print)'{-p,--print}'[pretty-print the matched node HTML]' \
|
|
14
18
|
'--attr[print the value of an attribute]:attribute name:' \
|
|
@@ -25,7 +29,10 @@ _cssgrep() {
|
|
|
25
29
|
'(-l --files-with-matches)'{-l,--files-with-matches}'[print only names of files with a match]' \
|
|
26
30
|
'(-L --files-without-match)'{-L,--files-without-match}'[print only names of files without a match]' \
|
|
27
31
|
'(-q --quiet)'{-q,--quiet}'[print nothing; exit on first match]' \
|
|
32
|
+
'(-s --no-messages)'{-s,--no-messages}'[suppress errors for unreadable/missing files]' \
|
|
28
33
|
'(-0 --null)'{-0,--null}'[separate the file name with a NUL byte]' \
|
|
34
|
+
'(--no-filename)'{-H,--with-filename}'[always print the file name prefix]' \
|
|
35
|
+
'(-H --with-filename)--no-filename[never print the file name prefix]' \
|
|
29
36
|
'--color=-[colorize output]::when:(auto always never)' \
|
|
30
37
|
'(-h --help)'{-h,--help}'[show help and exit]' \
|
|
31
38
|
'(-V --version)'{-V,--version}'[show version and exit]' \
|
package/completions/cssgrep.bash
CHANGED
|
@@ -6,11 +6,12 @@ _cssgrep() {
|
|
|
6
6
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
7
7
|
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
8
8
|
|
|
9
|
-
opts="-r --recursive --ext -i --ignore --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 \
|
|
13
|
-
-l --files-with-matches -L --files-without-match -q --quiet -0 --null \
|
|
13
|
+
-l --files-with-matches -L --files-without-match -q --quiet -s --no-messages -0 --null \
|
|
14
|
+
-H --with-filename --no-filename \
|
|
14
15
|
--color -h --help -V --version"
|
|
15
16
|
|
|
16
17
|
case "$prev" in
|
|
@@ -22,7 +23,7 @@ _cssgrep() {
|
|
|
22
23
|
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
23
24
|
return 0
|
|
24
25
|
;;
|
|
25
|
-
--ext|-i|--ignore|--attr|--parent|-w|--max-width|-A|--after-context|\
|
|
26
|
+
--max-depth|--ext|--include|-i|--ignore|--exclude|--attr|--parent|-w|--max-width|-A|--after-context|\
|
|
26
27
|
-B|--before-context|-C|--context|-m|--max-count|-M|--max-total)
|
|
27
28
|
# value-taking flag with no enumerable completion; offer nothing
|
|
28
29
|
return 0
|
package/completions/cssgrep.fish
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
# Install: place this file in ~/.config/fish/completions/ (named cssgrep.fish).
|
|
3
3
|
|
|
4
4
|
complete -c cssgrep -s r -l recursive -d 'Recurse into directory arguments'
|
|
5
|
+
complete -c cssgrep -l max-depth -x -d 'Limit -r recursion depth (1 = given dir only)'
|
|
5
6
|
complete -c cssgrep -l ext -x -d 'Extensions to scan with -r (comma-separated)'
|
|
7
|
+
complete -c cssgrep -l include -x -d 'Only search files matching glob (replaces --ext)'
|
|
6
8
|
complete -c cssgrep -s i -l ignore -x -d 'Skip files/dirs matching glob while recursing'
|
|
9
|
+
complete -c cssgrep -l exclude -x -d 'Alias for --ignore'
|
|
7
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'
|
|
8
12
|
complete -c cssgrep -s n -l line-number -d 'Prefix each match with line:col'
|
|
9
13
|
complete -c cssgrep -s p -l print -d 'Pretty-print the matched node HTML'
|
|
10
14
|
complete -c cssgrep -l attr -x -d 'Print the value of an attribute'
|
|
@@ -21,7 +25,10 @@ complete -c cssgrep -s c -l count -d 'Print only a count of matches'
|
|
|
21
25
|
complete -c cssgrep -s l -l files-with-matches -d 'Print only names of files with a match'
|
|
22
26
|
complete -c cssgrep -s L -l files-without-match -d 'Print only names of files without a match'
|
|
23
27
|
complete -c cssgrep -s q -l quiet -d 'Print nothing; exit on first match'
|
|
28
|
+
complete -c cssgrep -s s -l no-messages -d 'Suppress errors for unreadable/missing files'
|
|
24
29
|
complete -c cssgrep -s 0 -l null -d 'Separate the file name with a NUL byte'
|
|
30
|
+
complete -c cssgrep -s H -l with-filename -d 'Always print the file name prefix'
|
|
31
|
+
complete -c cssgrep -l no-filename -d 'Never print the file name prefix'
|
|
25
32
|
complete -c cssgrep -l color -x -a 'auto always never' -d 'Colorize output'
|
|
26
33
|
complete -c cssgrep -s h -l help -d 'Show help and exit'
|
|
27
34
|
complete -c cssgrep -s V -l version -d 'Show version and exit'
|
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
|
|
|
@@ -29,9 +29,14 @@ Output (one line per match):
|
|
|
29
29
|
|
|
30
30
|
Options:
|
|
31
31
|
-r, --recursive Recurse into directory arguments.
|
|
32
|
+
--max-depth <n> Limit -r recursion depth (1 = the given dir only).
|
|
32
33
|
--ext <list> Comma-separated extensions for -r (default: html,htm).
|
|
34
|
+
--include <glob> Only search files matching <glob> (replaces --ext; repeatable).
|
|
33
35
|
-i, --ignore <glob> Skip files/dirs matching <glob> when recursing (repeatable).
|
|
36
|
+
--exclude <glob> Alias for --ignore.
|
|
34
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).
|
|
35
40
|
-n, --line-number Prefix each match with its line:col (excludes -c, -p).
|
|
36
41
|
-p, --print Pretty-print the matched node's HTML above its location.
|
|
37
42
|
--attr <name> Print the value of attribute <name> (skips nodes without it).
|
|
@@ -44,12 +49,17 @@ Options:
|
|
|
44
49
|
-C, --context <n> Print <n> source lines before and after each match.
|
|
45
50
|
-m, --max-count <n> Stop after <n> matches per file.
|
|
46
51
|
-M, --max-total <n> Stop after <n> matches in total (across all files).
|
|
47
|
-
-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).
|
|
48
54
|
-l, --files-with-matches Print only the names of files that have a match.
|
|
49
55
|
-L, --files-without-match Print only the names of files with no match.
|
|
50
56
|
-q, --quiet Print nothing; exit 0 on first match, 1 if none.
|
|
57
|
+
-s, --no-messages Suppress error messages for unreadable/missing files.
|
|
51
58
|
-0, --null Separate the file name with a NUL byte (for xargs -0).
|
|
52
|
-
|
|
59
|
+
-H, --with-filename Always print the file name prefix (even for one file).
|
|
60
|
+
--no-filename Never print the file name prefix (even for many files).
|
|
61
|
+
--color[=<when>] Colorize output: auto (default, also what a bare
|
|
62
|
+
--color means, like grep), always or never.
|
|
53
63
|
-h, --help Show this help.
|
|
54
64
|
-V, --version Show version and exit.
|
|
55
65
|
|
|
@@ -57,6 +67,9 @@ Short flags combine (-rn) and a value attaches to its flag (-w100) or follows it
|
|
|
57
67
|
(-w 100); a value-taking flag may close a cluster (-rnw100). Long options take a
|
|
58
68
|
value with = or as the next word (--max-width=100, --ext htm).
|
|
59
69
|
|
|
70
|
+
Globs (--include/--ignore/--exclude) support *, ** (crosses /), ?, and brace
|
|
71
|
+
alternation like *.{html,htm}.
|
|
72
|
+
|
|
60
73
|
Exit status: 0 if any match was found, 1 if none, 2 on error.`;
|
|
61
74
|
|
|
62
75
|
function fail(msg) {
|
|
@@ -83,7 +96,10 @@ function parseArgs(argv) {
|
|
|
83
96
|
positionals: [],
|
|
84
97
|
paths: [],
|
|
85
98
|
recursive: false,
|
|
99
|
+
follow: false,
|
|
86
100
|
exts: ['html', 'htm'],
|
|
101
|
+
extGiven: false,
|
|
102
|
+
maxDepth: 0,
|
|
87
103
|
lineNumber: false,
|
|
88
104
|
print: false,
|
|
89
105
|
maxWidth: 0,
|
|
@@ -101,9 +117,14 @@ function parseArgs(argv) {
|
|
|
101
117
|
before: 0,
|
|
102
118
|
after: 0,
|
|
103
119
|
ignore: [],
|
|
120
|
+
include: [],
|
|
121
|
+
withFilename: false,
|
|
122
|
+
noFilename: false,
|
|
123
|
+
noMessages: false,
|
|
104
124
|
color: 'auto',
|
|
105
125
|
};
|
|
106
126
|
const setExts = v => {
|
|
127
|
+
opts.extGiven = true;
|
|
107
128
|
opts.exts = (v || '').split(',').map(s => s.trim().replace(/^\./, '')).filter(Boolean);
|
|
108
129
|
};
|
|
109
130
|
const boundedInt = (v, what, min) => {
|
|
@@ -115,11 +136,13 @@ function parseArgs(argv) {
|
|
|
115
136
|
const setMaxWidth = v => { opts.maxWidth = positiveInt(v, '--max-width'); };
|
|
116
137
|
const setMaxCount = v => { opts.maxCount = positiveInt(v, '--max-count'); };
|
|
117
138
|
const setMaxTotal = v => { opts.maxTotal = positiveInt(v, '--max-total'); };
|
|
139
|
+
const setMaxDepth = v => { opts.maxDepth = positiveInt(v, '--max-depth'); };
|
|
118
140
|
const setParent = v => { opts.parent = positiveInt(v, '--parent'); };
|
|
119
141
|
const setAfter = v => { opts.after = boundedInt(v, '--after-context', 0); };
|
|
120
142
|
const setBefore = v => { opts.before = boundedInt(v, '--before-context', 0); };
|
|
121
143
|
const setContext = v => { opts.after = opts.before = boundedInt(v, '--context', 0); };
|
|
122
144
|
const addIgnore = v => { const c = compileIgnore(v); if (c) opts.ignore.push(c); };
|
|
145
|
+
const addInclude = v => { const c = compileIgnore(v); if (c) opts.include.push(c); };
|
|
123
146
|
const addIgnoreFile = v => {
|
|
124
147
|
let content;
|
|
125
148
|
try { content = fs.readFileSync(v, 'utf8'); }
|
|
@@ -135,16 +158,22 @@ function parseArgs(argv) {
|
|
|
135
158
|
const a = argv[i];
|
|
136
159
|
|
|
137
160
|
// Long options: --name or --name=value. A missing inline value is taken
|
|
138
|
-
// 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).
|
|
139
163
|
if (a.startsWith('--') && a.length > 2) {
|
|
140
164
|
const eq = a.indexOf('=');
|
|
141
165
|
const name = eq === -1 ? a : a.slice(0, eq);
|
|
142
166
|
const inline = eq === -1 ? null : a.slice(eq + 1);
|
|
143
|
-
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
|
+
};
|
|
144
172
|
switch (name) {
|
|
145
173
|
case '--help': process.stdout.write(USAGE + '\n'); process.exit(0); break;
|
|
146
174
|
case '--version': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
|
|
147
175
|
case '--recursive': opts.recursive = true; break;
|
|
176
|
+
case '--follow': opts.follow = true; break;
|
|
148
177
|
case '--line-number': opts.lineNumber = true; break;
|
|
149
178
|
case '--print': opts.print = true; break;
|
|
150
179
|
case '--count': opts.count = true; break;
|
|
@@ -152,20 +181,26 @@ function parseArgs(argv) {
|
|
|
152
181
|
case '--files-without-match': opts.filesWithoutMatch = true; break;
|
|
153
182
|
case '--quiet': case '--silent': opts.quiet = true; break;
|
|
154
183
|
case '--null': opts.nul = true; break;
|
|
184
|
+
case '--with-filename': opts.withFilename = true; break;
|
|
185
|
+
case '--no-filename': opts.noFilename = true; break;
|
|
186
|
+
case '--no-messages': opts.noMessages = true; break;
|
|
155
187
|
case '--ext': setExts(value()); break;
|
|
156
|
-
case '--ignore': addIgnore(value()); break;
|
|
188
|
+
case '--ignore': case '--exclude': addIgnore(value()); break;
|
|
157
189
|
case '--ignore-file': addIgnoreFile(value()); break;
|
|
190
|
+
case '--include': addInclude(value()); break;
|
|
158
191
|
case '--max-width': setMaxWidth(value()); break;
|
|
159
192
|
case '--max-count': setMaxCount(value()); break;
|
|
160
193
|
case '--max-total': setMaxTotal(value()); break;
|
|
161
|
-
case '--
|
|
194
|
+
case '--max-depth': setMaxDepth(value()); break;
|
|
195
|
+
// htmlparser2 lowercases HTML attribute names, so match case-insensitively.
|
|
196
|
+
case '--attr': opts.attr = value().toLowerCase(); break;
|
|
162
197
|
case '--text': opts.text = true; break;
|
|
163
198
|
case '--json': opts.json = true; break;
|
|
164
199
|
case '--parent': setParent(value()); break;
|
|
165
200
|
case '--after-context': setAfter(value()); break;
|
|
166
201
|
case '--before-context': setBefore(value()); break;
|
|
167
202
|
case '--context': setContext(value()); break;
|
|
168
|
-
case '--color': case '--colour': opts.color = inline != null ? inline : '
|
|
203
|
+
case '--color': case '--colour': opts.color = inline != null ? inline : 'auto'; break;
|
|
169
204
|
default: fail(`unknown option: ${name}`);
|
|
170
205
|
}
|
|
171
206
|
continue;
|
|
@@ -179,6 +214,7 @@ function parseArgs(argv) {
|
|
|
179
214
|
const ch = a[j];
|
|
180
215
|
if (shortValueFlags[ch]) {
|
|
181
216
|
const rest = a.slice(j + 1); // attached value, if any
|
|
217
|
+
if (rest === '' && i + 1 >= argv.length) fail(`option -${ch} requires a value`);
|
|
182
218
|
shortValueFlags[ch](rest !== '' ? rest : argv[++i]);
|
|
183
219
|
break; // value swallowed the cluster tail
|
|
184
220
|
}
|
|
@@ -186,6 +222,7 @@ function parseArgs(argv) {
|
|
|
186
222
|
case 'h': process.stdout.write(USAGE + '\n'); process.exit(0); break;
|
|
187
223
|
case 'V': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
|
|
188
224
|
case 'r': opts.recursive = true; break;
|
|
225
|
+
case 'S': opts.follow = true; break;
|
|
189
226
|
case 'n': opts.lineNumber = true; break;
|
|
190
227
|
case 'p': opts.print = true; break;
|
|
191
228
|
case 'c': opts.count = true; break;
|
|
@@ -193,6 +230,8 @@ function parseArgs(argv) {
|
|
|
193
230
|
case 'L': opts.filesWithoutMatch = true; break;
|
|
194
231
|
case 'q': opts.quiet = true; break;
|
|
195
232
|
case '0': case 'Z': opts.nul = true; break;
|
|
233
|
+
case 'H': opts.withFilename = true; break;
|
|
234
|
+
case 's': opts.noMessages = true; break;
|
|
196
235
|
default: fail(`unknown option: -${ch}`);
|
|
197
236
|
}
|
|
198
237
|
}
|
|
@@ -206,6 +245,10 @@ function parseArgs(argv) {
|
|
|
206
245
|
const aggregates = [opts.count, opts.filesWithMatches, opts.filesWithoutMatch, opts.quiet]
|
|
207
246
|
.filter(Boolean).length;
|
|
208
247
|
if (aggregates > 1) fail('only one of -c, -l, -L, -q may be given');
|
|
248
|
+
// Filename prefix can be forced on or off, but not both (grep -H / -h).
|
|
249
|
+
if (opts.withFilename && opts.noFilename) fail('-H cannot be combined with --no-filename');
|
|
250
|
+
// --include fully replaces the extension filter, so the two can't coexist.
|
|
251
|
+
if (opts.extGiven && opts.include.length) fail('--ext and --include cannot be combined');
|
|
209
252
|
// Per-match print modes choose what is printed for each match; only one.
|
|
210
253
|
const printModes = [opts.print, opts.attr != null, opts.text, opts.json].filter(Boolean).length;
|
|
211
254
|
if (printModes > 1) fail('only one of -p, --attr, --text, --json may be given');
|
|
@@ -280,7 +323,12 @@ function offsetToPosition(starts, src, offset) {
|
|
|
280
323
|
if (text.endsWith('\r')) text = text.slice(0, -1);
|
|
281
324
|
return {
|
|
282
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.
|
|
283
330
|
col: offset - lineStart + 1, // 1-based
|
|
331
|
+
bcol: Buffer.byteLength(src.slice(lineStart, offset), 'utf8') + 1, // 1-based
|
|
284
332
|
text,
|
|
285
333
|
};
|
|
286
334
|
}
|
|
@@ -296,10 +344,18 @@ function lineTextAt(starts, src, lineNo) {
|
|
|
296
344
|
return { lineStart, text };
|
|
297
345
|
}
|
|
298
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
|
+
|
|
299
355
|
function truncate(text, maxWidth) {
|
|
300
356
|
if (!maxWidth || text.length <= maxWidth) return text;
|
|
301
|
-
if (maxWidth <= 1) return text.slice(0, maxWidth);
|
|
302
|
-
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)) + '…'; // …
|
|
303
359
|
}
|
|
304
360
|
|
|
305
361
|
// Produce the visible match text: truncate first (so --max-width still measures
|
|
@@ -478,7 +534,7 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
|
|
|
478
534
|
if (label) prefix += c(COLORS.file, label) + (opts.nul ? '\0' : sepColored);
|
|
479
535
|
if (opts.lineNumber) {
|
|
480
536
|
prefix += c(COLORS.line, String(L));
|
|
481
|
-
prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.
|
|
537
|
+
prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + ' '
|
|
482
538
|
: c(COLORS.sep, '-');
|
|
483
539
|
}
|
|
484
540
|
const body = m
|
|
@@ -510,10 +566,11 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
510
566
|
const cap = Math.min(opts.maxCount || Infinity, limit);
|
|
511
567
|
const limited = Number.isFinite(cap) ? matches.slice(0, cap) : matches;
|
|
512
568
|
if (opts.count) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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));
|
|
517
574
|
return limited.length;
|
|
518
575
|
}
|
|
519
576
|
const starts = opts.print ? null : lineIndex(src);
|
|
@@ -534,6 +591,10 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
534
591
|
emitContext(src, starts, name, showLabel, targets, opts, out);
|
|
535
592
|
return limited.length;
|
|
536
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.
|
|
537
598
|
if (opts.json) {
|
|
538
599
|
// NDJSON: one self-contained record per match. `html` is the exact source
|
|
539
600
|
// slice; newlines are escaped by JSON.stringify, so each record stays on
|
|
@@ -545,18 +606,20 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
545
606
|
out.push(JSON.stringify({
|
|
546
607
|
file: name,
|
|
547
608
|
line: pos.line,
|
|
548
|
-
col: pos.
|
|
609
|
+
col: pos.bcol,
|
|
549
610
|
html: src.slice(off, nodeEnd),
|
|
550
611
|
text: collapseWs(textOf(el)),
|
|
551
612
|
}));
|
|
552
613
|
}
|
|
553
|
-
return
|
|
614
|
+
return targets.length;
|
|
554
615
|
}
|
|
616
|
+
let emitted = 0;
|
|
555
617
|
for (const el of targets) {
|
|
556
618
|
if (opts.print) {
|
|
557
619
|
// -p shows the re-indented node only; no line:col locator. With --parent,
|
|
558
620
|
// the original matched descendants are highlighted inside the container.
|
|
559
621
|
out.push(prettyPrint(el, originsByTarget.get(el), opts), ''); // blank separator
|
|
622
|
+
emitted++;
|
|
560
623
|
continue;
|
|
561
624
|
}
|
|
562
625
|
const off = el.startIndex == null ? 0 : el.startIndex;
|
|
@@ -586,35 +649,53 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
|
|
|
586
649
|
let prefix = '';
|
|
587
650
|
if (label) prefix += c(COLORS.file, label) + fileSep;
|
|
588
651
|
if (opts.lineNumber) {
|
|
589
|
-
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)) + ' ';
|
|
590
653
|
}
|
|
591
654
|
out.push(prefix + text);
|
|
655
|
+
emitted++;
|
|
592
656
|
}
|
|
593
|
-
return
|
|
657
|
+
return emitted;
|
|
594
658
|
}
|
|
595
659
|
|
|
596
660
|
// Translate a glob to a regex body. `*` matches within a path segment, `**`
|
|
597
|
-
// across segments, `?` a single non-slash char
|
|
661
|
+
// across segments, `?` a single non-slash char, `{a,b,c}` alternation; every
|
|
662
|
+
// other char is literal.
|
|
598
663
|
function globToRegex(glob) {
|
|
599
664
|
let re = '';
|
|
665
|
+
let depth = 0; // open `{` alternation groups
|
|
600
666
|
for (let i = 0; i < glob.length; i++) {
|
|
601
667
|
const c = glob[i];
|
|
602
668
|
if (c === '*') {
|
|
603
669
|
if (glob[i + 1] === '*') { // ** (crosses /)
|
|
604
|
-
re += '.*';
|
|
605
670
|
i++;
|
|
606
|
-
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
|
+
}
|
|
607
679
|
} else {
|
|
608
680
|
re += '[^/]*';
|
|
609
681
|
}
|
|
610
682
|
} else if (c === '?') {
|
|
611
683
|
re += '[^/]';
|
|
612
|
-
} else if (
|
|
684
|
+
} else if (c === '{') {
|
|
685
|
+
re += '(';
|
|
686
|
+
depth++;
|
|
687
|
+
} else if (c === '}' && depth > 0) {
|
|
688
|
+
re += ')';
|
|
689
|
+
depth--;
|
|
690
|
+
} else if (c === ',' && depth > 0) {
|
|
691
|
+
re += '|';
|
|
692
|
+
} else if (/[.+^$()|[\]\\]/.test(c)) { // note: { } handled above
|
|
613
693
|
re += '\\' + c;
|
|
614
694
|
} else {
|
|
615
695
|
re += c;
|
|
616
696
|
}
|
|
617
697
|
}
|
|
698
|
+
while (depth-- > 0) re += ')'; // close any unbalanced `{`
|
|
618
699
|
return re;
|
|
619
700
|
}
|
|
620
701
|
|
|
@@ -631,37 +712,87 @@ function compileIgnore(pattern) {
|
|
|
631
712
|
return { re, dirOnly, hasSlash };
|
|
632
713
|
}
|
|
633
714
|
|
|
634
|
-
|
|
715
|
+
// True if name/path matches any compiled glob matcher (shared by --ignore/
|
|
716
|
+
// --exclude and --include). Globs always use `/` as the separator (gitignore
|
|
717
|
+
// semantics), so normalize Windows backslash paths before matching.
|
|
718
|
+
function matchesAny(name, full, isDir, matchers) {
|
|
719
|
+
const fullPosix = path.sep === '/' ? full : full.split(path.sep).join('/');
|
|
635
720
|
for (const m of matchers) {
|
|
636
721
|
if (m.dirOnly && !isDir) continue;
|
|
637
|
-
if (m.re.test(m.hasSlash ?
|
|
722
|
+
if (m.re.test(m.hasSlash ? fullPosix : name)) return true;
|
|
638
723
|
}
|
|
639
724
|
return false;
|
|
640
725
|
}
|
|
641
726
|
|
|
642
|
-
|
|
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
|
+
}
|
|
643
735
|
let entries;
|
|
644
736
|
try {
|
|
645
737
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
646
738
|
} catch (e) {
|
|
647
|
-
process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
|
|
739
|
+
if (!opts.noMessages) process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
|
|
648
740
|
return;
|
|
649
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));
|
|
650
745
|
for (const e of entries) {
|
|
651
746
|
const full = path.join(dir, e.name);
|
|
652
|
-
|
|
653
|
-
|
|
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
|
+
}
|
|
755
|
+
if (opts.ignore.length && matchesAny(e.name, full, isDir, opts.ignore)) continue;
|
|
654
756
|
if (isDir) {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
+
}
|
|
763
|
+
// --max-depth caps how far we descend; depth 1 = the target's children.
|
|
764
|
+
if (!opts.maxDepth || depth < opts.maxDepth) yield* walk(full, opts, depth + 1, visited);
|
|
765
|
+
} else if (isFile) {
|
|
766
|
+
// --include replaces the extension filter; otherwise filter by --ext.
|
|
767
|
+
if (opts.include.length) {
|
|
768
|
+
if (matchesAny(e.name, full, false, opts.include)) yield full;
|
|
769
|
+
} else {
|
|
770
|
+
const ext = path.extname(e.name).slice(1).toLowerCase();
|
|
771
|
+
if (opts.exts.includes(ext)) yield full;
|
|
772
|
+
}
|
|
659
773
|
}
|
|
660
774
|
}
|
|
661
775
|
}
|
|
662
776
|
|
|
663
777
|
function readStdin() {
|
|
664
|
-
return fs.readFileSync(0
|
|
778
|
+
return fs.readFileSync(0); // Buffer: sniffed for binary before decoding
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Heuristic binary-file detector (grep/git style). A NUL byte in the first 8 KB
|
|
782
|
+
// is a decisive binary signal; failing that, a high ratio of non-text control
|
|
783
|
+
// bytes flags binary content that happens to lack NULs. HTML is text, so binary
|
|
784
|
+
// files are never worth parsing and are skipped. (stat() can't tell us this —
|
|
785
|
+
// only the bytes can.)
|
|
786
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
787
|
+
function looksBinary(buf) {
|
|
788
|
+
const n = Math.min(buf.length, BINARY_SNIFF_BYTES);
|
|
789
|
+
let suspicious = 0;
|
|
790
|
+
for (let i = 0; i < n; i++) {
|
|
791
|
+
const b = buf[i];
|
|
792
|
+
if (b === 0) return true; // NUL: definitely binary
|
|
793
|
+
if (b < 9 || (b > 13 && b < 32)) suspicious++; // control char (not \t\n\v\f\r)
|
|
794
|
+
}
|
|
795
|
+
return n > 0 && suspicious / n > 0.3;
|
|
665
796
|
}
|
|
666
797
|
|
|
667
798
|
function main() {
|
|
@@ -676,7 +807,7 @@ function main() {
|
|
|
676
807
|
const targets = opts.paths.length ? opts.paths : ['.'];
|
|
677
808
|
for (const p of targets) {
|
|
678
809
|
const st = fs.statSync(p, { throwIfNoEntry: false });
|
|
679
|
-
if (!st) { process.stderr.write(`cssgrep: ${p}: no such file or directory\n`); continue; }
|
|
810
|
+
if (!st) { if (!opts.noMessages) process.stderr.write(`cssgrep: ${p}: no such file or directory\n`); continue; }
|
|
680
811
|
if (st.isDirectory()) files.push(...walk(p, opts));
|
|
681
812
|
else files.push(p);
|
|
682
813
|
}
|
|
@@ -686,8 +817,11 @@ function main() {
|
|
|
686
817
|
useStdin = true;
|
|
687
818
|
}
|
|
688
819
|
|
|
689
|
-
// A label (file prefix) is shown when searching more than one file
|
|
690
|
-
|
|
820
|
+
// A label (file prefix) is shown when searching more than one file; -H forces
|
|
821
|
+
// it on (even for one file or stdin) and --no-filename forces it off.
|
|
822
|
+
const showLabel = opts.withFilename ? true
|
|
823
|
+
: opts.noFilename ? false
|
|
824
|
+
: (!useStdin && files.length > 1);
|
|
691
825
|
|
|
692
826
|
const out = [];
|
|
693
827
|
let total = 0;
|
|
@@ -697,18 +831,31 @@ function main() {
|
|
|
697
831
|
|
|
698
832
|
try {
|
|
699
833
|
if (useStdin) {
|
|
700
|
-
|
|
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
|
+
}
|
|
701
842
|
} else {
|
|
702
843
|
for (const f of files) {
|
|
703
844
|
if (room() <= 0) break; // -M: global budget exhausted
|
|
704
|
-
let
|
|
845
|
+
let buf;
|
|
705
846
|
try {
|
|
706
|
-
|
|
847
|
+
buf = fs.readFileSync(f); // Buffer: sniff before decoding
|
|
707
848
|
} catch (e) {
|
|
708
|
-
process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
|
|
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
|
+
}
|
|
709
856
|
continue;
|
|
710
857
|
}
|
|
711
|
-
total += searchSource(
|
|
858
|
+
total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
|
|
712
859
|
if (opts.quiet && total > 0) break; // -q: first match decides the status
|
|
713
860
|
}
|
|
714
861
|
}
|
|
@@ -731,8 +878,11 @@ function main() {
|
|
|
731
878
|
}
|
|
732
879
|
// Normally success means "a match was found". With -L it means "a file
|
|
733
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.
|
|
734
884
|
const success = opts.filesWithoutMatch ? out.length > 0 : total > 0;
|
|
735
|
-
process.
|
|
885
|
+
process.exitCode = success ? 0 : 1;
|
|
736
886
|
}
|
|
737
887
|
|
|
738
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-
|
|
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,7 +33,13 @@ 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.
|
|
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
|
+
.B \-s
|
|
40
|
+
or
|
|
41
|
+
.BR \-q .
|
|
42
|
+
One line is printed per match.
|
|
37
43
|
As with
|
|
38
44
|
.BR grep ,
|
|
39
45
|
the matched line is printed on its own; a
|
|
@@ -48,17 +54,35 @@ locator appears only with
|
|
|
48
54
|
Recurse into directory arguments (defaults to the current directory if none
|
|
49
55
|
are given).
|
|
50
56
|
.TP
|
|
57
|
+
.BI \-\-max\-depth " n"
|
|
58
|
+
Limit
|
|
59
|
+
.B \-r
|
|
60
|
+
recursion depth.
|
|
61
|
+
.I 1
|
|
62
|
+
means the given directory only (no subdirectories).
|
|
63
|
+
.TP
|
|
51
64
|
.BI \-\-ext " list"
|
|
52
65
|
Comma-separated extensions to scan with
|
|
53
66
|
.B \-r
|
|
54
67
|
(default:
|
|
55
68
|
.IR html,htm ).
|
|
56
69
|
.TP
|
|
70
|
+
.BI \-\-include " glob"
|
|
71
|
+
Only search files whose name/path matches
|
|
72
|
+
.I glob
|
|
73
|
+
while recursing (repeatable). Replaces
|
|
74
|
+
.BR \-\-ext ;
|
|
75
|
+
the two cannot be combined.
|
|
76
|
+
.TP
|
|
57
77
|
.BR \-i ", " \-\-ignore " \fIglob\fR"
|
|
58
78
|
Skip files/dirs matching
|
|
59
79
|
.I glob
|
|
60
80
|
while recursing (repeatable).
|
|
61
81
|
.TP
|
|
82
|
+
.BI \-\-exclude " glob"
|
|
83
|
+
Alias for
|
|
84
|
+
.BR \-\-ignore .
|
|
85
|
+
.TP
|
|
62
86
|
.BI \-\-ignore\-file " path"
|
|
63
87
|
Read ignore globs from
|
|
64
88
|
.IR path ,
|
|
@@ -67,10 +91,21 @@ one per line
|
|
|
67
91
|
comments and blank lines ignored), like a
|
|
68
92
|
.IR .gitignore .
|
|
69
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
|
|
70
100
|
.BR \-n ", " \-\-line\-number
|
|
71
101
|
Prefix each match with its
|
|
72
102
|
.IR line : col
|
|
73
|
-
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
|
|
74
109
|
.B \-c
|
|
75
110
|
and
|
|
76
111
|
.BR \-p .
|
|
@@ -83,7 +118,8 @@ locator is shown.
|
|
|
83
118
|
.BI \-\-attr " name"
|
|
84
119
|
Print the value of attribute
|
|
85
120
|
.I name
|
|
86
|
-
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.
|
|
87
123
|
.TP
|
|
88
124
|
.B \-\-text
|
|
89
125
|
Print the matched node's text content, whitespace collapsed.
|
|
@@ -125,10 +161,17 @@ matches per file.
|
|
|
125
161
|
.BR \-M ", " \-\-max\-total " \fIn\fR"
|
|
126
162
|
Stop after
|
|
127
163
|
.I n
|
|
128
|
-
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.
|
|
129
170
|
.TP
|
|
130
171
|
.BR \-c ", " \-\-count
|
|
131
|
-
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 .
|
|
132
175
|
.TP
|
|
133
176
|
.BR \-l ", " \-\-files\-with\-matches
|
|
134
177
|
Print only the names of files that have a match.
|
|
@@ -139,10 +182,19 @@ Print only the names of files with no match.
|
|
|
139
182
|
.BR \-q ", " \-\-quiet
|
|
140
183
|
Print nothing; exit 0 on the first match, 1 if none.
|
|
141
184
|
.TP
|
|
185
|
+
.BR \-s ", " \-\-no\-messages
|
|
186
|
+
Suppress error messages for unreadable or missing files.
|
|
187
|
+
.TP
|
|
142
188
|
.BR \-0 ", " \-\-null
|
|
143
189
|
Separate the file name with a NUL byte (for
|
|
144
190
|
.BR "xargs \-0" ).
|
|
145
191
|
.TP
|
|
192
|
+
.BR \-H ", " \-\-with\-filename
|
|
193
|
+
Always print the file name prefix, even for a single file or standard input.
|
|
194
|
+
.TP
|
|
195
|
+
.B \-\-no\-filename
|
|
196
|
+
Never print the file name prefix, even when searching multiple files.
|
|
197
|
+
.TP
|
|
146
198
|
.BR \-\-color [ =\fIwhen\fR ]
|
|
147
199
|
Colorize output:
|
|
148
200
|
.I auto
|
|
@@ -151,7 +203,12 @@ Colorize output:
|
|
|
151
203
|
A bare
|
|
152
204
|
.B \-\-color
|
|
153
205
|
means
|
|
154
|
-
.IR
|
|
206
|
+
.IR auto ,
|
|
207
|
+
as in
|
|
208
|
+
.BR grep ;
|
|
209
|
+
use
|
|
210
|
+
.B \-\-color=always
|
|
211
|
+
to force color into a pipe.
|
|
155
212
|
.TP
|
|
156
213
|
.BR \-h ", " \-\-help
|
|
157
214
|
Show help and exit.
|
|
@@ -169,6 +226,12 @@ Long options take a value with
|
|
|
169
226
|
.B =
|
|
170
227
|
or as the next word
|
|
171
228
|
.RB ( \-\-max\-width=100 ", " "\-\-ext htm" ).
|
|
229
|
+
.PP
|
|
230
|
+
Globs for
|
|
231
|
+
.BR \-\-include ", " \-\-ignore ", and " \-\-exclude
|
|
232
|
+
support
|
|
233
|
+
.BR * " (within a path segment), " ** " (across " / ),
|
|
234
|
+
.BR ? " (one non-slash char), and brace alternation like " *.{html,htm} .
|
|
172
235
|
.SH EXIT STATUS
|
|
173
236
|
.TP
|
|
174
237
|
.B 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cssgrep",
|
|
3
|
-
"version": "1.
|
|
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": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"index.js",
|
|
12
12
|
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
13
14
|
"LICENSE",
|
|
14
15
|
"man/cssgrep.1",
|
|
15
16
|
"completions/"
|