rgapi 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {rgapi-0.2.1 → rgapi-0.2.2}/Cargo.lock +30 -18
- {rgapi-0.2.1 → rgapi-0.2.2}/Cargo.toml +3 -3
- {rgapi-0.2.1 → rgapi-0.2.2}/DEV.md +7 -2
- {rgapi-0.2.1 → rgapi-0.2.2}/PKG-INFO +21 -5
- {rgapi-0.2.1 → rgapi-0.2.2}/README.md +20 -4
- {rgapi-0.2.1 → rgapi-0.2.2}/py/Cargo.toml +0 -1
- {rgapi-0.2.1 → rgapi-0.2.2}/py/src/lib.rs +1 -1
- {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/__init__.py +10 -2
- {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/_cli.py +1 -1
- {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/nb.py +4 -2
- {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/skill.py +1 -1
- {rgapi-0.2.1 → rgapi-0.2.2}/src/block.rs +4 -3
- {rgapi-0.2.1 → rgapi-0.2.2}/src/lib.rs +3 -1
- rgapi-0.2.2/src/matcher.rs +142 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/src/nb.rs +11 -22
- {rgapi-0.2.1 → rgapi-0.2.2}/src/search.rs +6 -28
- {rgapi-0.2.1 → rgapi-0.2.2}/src/walk.rs +36 -37
- {rgapi-0.2.1 → rgapi-0.2.2}/tests/test_async.py +10 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/tests/test_rgapi.py +65 -4
- {rgapi-0.2.1 → rgapi-0.2.2}/tools/bench.py +17 -18
- {rgapi-0.2.1 → rgapi-0.2.2}/.github/workflows/ci.yml +0 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/.gitignore +0 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/LICENSE +0 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/_config.yml +0 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/_layouts/default.html +0 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/py/build.rs +0 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/pyproject.toml +0 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/block.py +0 -0
- {rgapi-0.2.1 → rgapi-0.2.2}/rustfmt.toml +0 -0
|
@@ -11,6 +11,21 @@ dependencies = [
|
|
|
11
11
|
"memchr",
|
|
12
12
|
]
|
|
13
13
|
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "bit-set"
|
|
16
|
+
version = "0.8.0"
|
|
17
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
18
|
+
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
|
19
|
+
dependencies = [
|
|
20
|
+
"bit-vec",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "bit-vec"
|
|
25
|
+
version = "0.8.0"
|
|
26
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
27
|
+
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
|
28
|
+
|
|
14
29
|
[[package]]
|
|
15
30
|
name = "bstr"
|
|
16
31
|
version = "1.13.1"
|
|
@@ -18,7 +33,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
|
18
33
|
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
|
|
19
34
|
dependencies = [
|
|
20
35
|
"memchr",
|
|
21
|
-
"regex-automata",
|
|
22
36
|
"serde_core",
|
|
23
37
|
]
|
|
24
38
|
|
|
@@ -91,6 +105,17 @@ dependencies = [
|
|
|
91
105
|
"encoding_rs",
|
|
92
106
|
]
|
|
93
107
|
|
|
108
|
+
[[package]]
|
|
109
|
+
name = "fancy-regex"
|
|
110
|
+
version = "0.19.2"
|
|
111
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
112
|
+
checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
|
|
113
|
+
dependencies = [
|
|
114
|
+
"bit-set",
|
|
115
|
+
"regex-automata",
|
|
116
|
+
"regex-syntax",
|
|
117
|
+
]
|
|
118
|
+
|
|
94
119
|
[[package]]
|
|
95
120
|
name = "globset"
|
|
96
121
|
version = "0.4.20"
|
|
@@ -113,19 +138,6 @@ dependencies = [
|
|
|
113
138
|
"memchr",
|
|
114
139
|
]
|
|
115
140
|
|
|
116
|
-
[[package]]
|
|
117
|
-
name = "grep-regex"
|
|
118
|
-
version = "0.1.14"
|
|
119
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
120
|
-
checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef"
|
|
121
|
-
dependencies = [
|
|
122
|
-
"bstr",
|
|
123
|
-
"grep-matcher",
|
|
124
|
-
"log",
|
|
125
|
-
"regex-automata",
|
|
126
|
-
"regex-syntax",
|
|
127
|
-
]
|
|
128
|
-
|
|
129
141
|
[[package]]
|
|
130
142
|
name = "grep-searcher"
|
|
131
143
|
version = "0.1.17"
|
|
@@ -308,24 +320,24 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|
|
308
320
|
|
|
309
321
|
[[package]]
|
|
310
322
|
name = "rgapi"
|
|
311
|
-
version = "0.2.
|
|
323
|
+
version = "0.2.2"
|
|
312
324
|
dependencies = [
|
|
313
325
|
"crc32fast",
|
|
326
|
+
"fancy-regex",
|
|
314
327
|
"globset",
|
|
315
328
|
"grep-matcher",
|
|
316
|
-
"grep-regex",
|
|
317
329
|
"grep-searcher",
|
|
318
330
|
"ignore",
|
|
331
|
+
"regex-syntax",
|
|
319
332
|
"serde",
|
|
320
333
|
"serde_json",
|
|
321
334
|
]
|
|
322
335
|
|
|
323
336
|
[[package]]
|
|
324
337
|
name = "rgapi-py"
|
|
325
|
-
version = "0.2.
|
|
338
|
+
version = "0.2.2"
|
|
326
339
|
dependencies = [
|
|
327
340
|
"grep-matcher",
|
|
328
|
-
"grep-regex",
|
|
329
341
|
"pyo3",
|
|
330
342
|
"pyo3-build-config",
|
|
331
343
|
"rgapi",
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
members = ["py"]
|
|
3
3
|
|
|
4
4
|
[workspace.package]
|
|
5
|
-
version = "0.2.
|
|
5
|
+
version = "0.2.2"
|
|
6
6
|
edition = "2024"
|
|
7
7
|
|
|
8
8
|
[workspace.dependencies]
|
|
9
9
|
grep-matcher = ">=0.1.8"
|
|
10
|
-
grep-regex = ">=0.1.14"
|
|
11
10
|
|
|
12
11
|
[package]
|
|
13
12
|
name = "rgapi"
|
|
@@ -25,11 +24,12 @@ name = "rgapi"
|
|
|
25
24
|
|
|
26
25
|
[dependencies]
|
|
27
26
|
crc32fast = "1"
|
|
27
|
+
fancy-regex = "0.19"
|
|
28
28
|
globset = ">=0.4.18"
|
|
29
29
|
grep-matcher.workspace = true
|
|
30
|
-
grep-regex.workspace = true
|
|
31
30
|
grep-searcher = ">=0.1.16"
|
|
32
31
|
ignore = ">=0.4.26"
|
|
32
|
+
regex-syntax = "0.8"
|
|
33
33
|
serde = { version = "=1", features = ["derive"] }
|
|
34
34
|
serde_json = "=1"
|
|
35
35
|
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
## Layout
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
|
-
src/
|
|
9
|
-
src/
|
|
8
|
+
src/matcher.rs shared fancy-regex engine, smart-case policy, and grep-matcher adapter
|
|
9
|
+
src/walk.rs ignore/globset-backed path walking and filtering
|
|
10
|
+
src/search.rs grep-searcher-backed searching
|
|
10
11
|
src/block.rs blank-line-delimited block grouping, matching, and block context
|
|
11
12
|
py/src/lib.rs PyO3 classes and private core functions
|
|
12
13
|
python/rgapi/ public Python wrappers over `rgapi._core`, plus the `rgapi-nbrg` CLI
|
|
@@ -38,6 +39,10 @@ The GitHub workflow builds wheels for Python 3.10-3.13 on Linux and macOS and pu
|
|
|
38
39
|
|
|
39
40
|
## Design notes
|
|
40
41
|
|
|
42
|
+
All user-supplied regexes use the shared `RegexMatcher` in `src/matcher.rs`, backed by `fancy-regex`. There is no separate fallback engine or native C dependency. The adapter implements `grep_matcher::Matcher` so `grep-searcher` continues to handle file scanning, line numbers, context, and binary detection. `compile_regex` returns rgapi's own exported `RegexMatcher` type.
|
|
43
|
+
|
|
44
|
+
The matcher centralizes smart-case analysis and line-ending policy. Line-safe patterns can search a whole buffer; patterns that can consume line endings or inspect neighbouring lines are evaluated on individual lines. Notebook multiline matching instead uses the whole cell. Filename/path regexes match their whole input. Backtracking-limit errors propagate through the existing result channels, including directory-pruning errors; cancellation must not hide a queued error from async consumers.
|
|
45
|
+
|
|
41
46
|
Python discovery and `paths=True` results contain absolute `pathlib.Path` objects. Structured search rows hold base-relative string labels with `/` separators. Traversal uses `ignore::WalkParallel`, so result order is not part of the API contract. Search results are structured rows; collected result lists use rg-style `str()` and notebook display. `SearchLine.lnhash` is computed with the same CRC-32-based line-content hash format as exhash (`lineno|hash|`, low 12 bits of CRC-32 over the line's UTF-8 bytes, encoded as two Base64url characters); `lnhashs=True` only changes row display, not `line_number` or matching behavior. Path regexes filter returned/searched paths; `skip_dir` and `skip_dir_re` prune traversal through `ignore::WalkBuilder::filter_entry`. Depth, size, filesystem, hidden, and ignore options use `ignore::WalkBuilder` settings. The `ignore` walker follows every root it is given. Discovery checks each root with `symlink_metadata`. The worker sends each unfollowed link root as a result before walking the other roots. This also supports dangling roots, which the underlying walker would reject. `ls` sets `walk_root_links`, which gives a root link to a directory to the walker. The walker then reports paths under the link. Other discovery roots use absolute paths without canonicalizing. Content searches retain canonical root resolution. `rg_iter` exposes the same parallel search stream that `rg` collects by default; `paths=True` and `count=True` consume that stream with different reducers. Text search skips binary files and invalid UTF-8 content.
|
|
42
47
|
|
|
43
48
|
Streaming engine: `walk.rs` owns the generic machinery. `StreamIter<T>` is the worker-thread-plus-bounded-channel iterator (`sync_channel(8192)`, so producers block rather than buffer without limit when a consumer lags), and `spawn_walk` owns the shared scaffold: walker config, panic catching, cancel flag, and worker thread. `rg_iter` (`T = SearchLine`), `block_iter` (`T = SearchBlock`), `nb_iter` (`T = NbCell`), and `find_iter` (`T = PathBuf`, the path walk) plug entry closures into that engine. Block search reads each file once, searches it once, groups nonblank lines into blocks, maps matching lines to their blocks, and expands context by block index. Each `SearchBlock` carries numeric boundaries plus hashes for its first and last source lines. Python keeps both and chooses the displayed address without another file read.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rgapi
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
6
|
Requires-Dist: fastcore>=2.2.29
|
|
@@ -23,7 +23,7 @@ Project-URL: Repository, https://github.com/AnswerDotAI/rgapi
|
|
|
23
23
|
|
|
24
24
|
`rgapi` provides `fd`-style file discovery and `rg`-style text search from Python without starting a shell command.
|
|
25
25
|
|
|
26
|
-
It uses
|
|
26
|
+
It uses ripgrep's `ignore` and `grep-searcher` crates for walking and file scanning, with the pure-Rust `fancy-regex` engine for regex matching. Walking and searching run in parallel by default. Most expensive work stays in Rust.
|
|
27
27
|
|
|
28
28
|
## Overview
|
|
29
29
|
|
|
@@ -108,6 +108,16 @@ Set `min_depth` and `max_depth` to bound recursion. `max_filesize` skips files a
|
|
|
108
108
|
|
|
109
109
|
Search is case-sensitive by default, matching `rg`. Use `smart_case=True` for `rg --smart-case` behaviour. Use `case_sensitive=False` to force case-insensitive matching.
|
|
110
110
|
|
|
111
|
+
Patterns support backreferences and lookaround everywhere, including filename and path filters:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
rg(r"\b(\w+)\s+\1\b", ".") # repeated words
|
|
115
|
+
rg(r"(?<=TODO: )\w+", ".") # word following TODO:
|
|
116
|
+
fd(".", pattern=r"^(\w+)_\1\.py$") # names such as utils_utils.py
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
All regex parameters use `fancy-regex`; no engine-selection flag or C library is required. Ordinary patterns use its non-backtracking engine. Patterns requiring backtracking have a bounded backtracking budget; exceeding it raises `ValueError`, rather than silently skipping a file or path.
|
|
120
|
+
|
|
111
121
|
Each `SearchLine` has these fields:
|
|
112
122
|
|
|
113
123
|
```text
|
|
@@ -127,7 +137,9 @@ Pass `lnhashs=True` to `rg` or `rg_iter` to display hash addresses instead of li
|
|
|
127
137
|
|
|
128
138
|
For other result forms, use `rg(..., paths=True)` to return unique matched paths or `rg(..., count=True)` to count match spans. `paths` and `count` cannot both be set.
|
|
129
139
|
|
|
130
|
-
`^` and `$` match at the start and end of each line. A line ends at `\n` or `\r\n`. `$` also matches before a `\r` that has no `\n` after it. `rg`, `rgstr`, and `
|
|
140
|
+
`^` and `$` match at the start and end of each line. A line ends at `\n` or `\r\n`. `$` also matches before a `\r` that has no `\n` after it. `rg`, `rgstr`, and `compile` reject patterns containing a literal `\n` or `\r`. `nbrg` allows them with `multiline=True`.
|
|
141
|
+
|
|
142
|
+
Content searches and compiled content matchers are confined to individual lines: whitespace classes, backreferences and lookaround cannot cross a line ending, and `\A`/`\z` refer to each line. With `nbrg(..., multiline=True)`, `\A`/`\z` refer to the whole cell, while `^`/`$` still anchor lines. Filename and path filters match the full name or path instead.
|
|
131
143
|
|
|
132
144
|
### Path results
|
|
133
145
|
|
|
@@ -159,6 +171,8 @@ Set `timeout_ms` on `rg`, `fd`, `walk`, or `ls` to stop at a deadline and return
|
|
|
159
171
|
|
|
160
172
|
`complete` is true exactly when `stop_reason` is `None`. `count=True` returns a plain integer without a completion flag. It cannot be combined with `timeout_ms`.
|
|
161
173
|
|
|
174
|
+
Deadlines stop result collection and request worker cancellation. Workers cannot interrupt a regex evaluation or filesystem call already in progress; a timeout is not a hard per-match execution limit.
|
|
175
|
+
|
|
162
176
|
### Context lines
|
|
163
177
|
|
|
164
178
|
`before_context`, `after_context`, and `context` correspond to `rg -B`, `rg -A`, and `rg -C`. Files containing NUL bytes or invalid UTF-8 are skipped.
|
|
@@ -190,7 +204,9 @@ nbrg("read_csv", ".") # cells whose source matches, across all
|
|
|
190
204
|
nbrg("read_csv", ".", cell_context=1) # also include neighbouring cells as context
|
|
191
205
|
```
|
|
192
206
|
|
|
193
|
-
Notebook discovery, parsing, and matching run together in one parallel Rust pass. Matching uses `
|
|
207
|
+
Notebook discovery, parsing, and matching run together in one parallel Rust pass. Matching uses the same `fancy-regex` engine as `rgapi.rg`, including backreferences and lookaround, with the same `case_sensitive` and `smart_case` behaviour. `nbrg` accepts the discovery filters from `fd` and `rg`, including `include`, `exclude`, `glob`, `hidden`, `max_depth`, and `skip_dir`.
|
|
208
|
+
|
|
209
|
+
Matching is line-oriented by default. Use `nbrg(r"(foo)\n\1", ".", multiline=True)` to match across lines within a cell. Matches never cross cell boundaries.
|
|
194
210
|
|
|
195
211
|
`nbrg` returns `NbResults`, a list of `NbCell`. Each `NbCell` has:
|
|
196
212
|
|
|
@@ -248,7 +264,7 @@ async for row in rga_iter("TODO", "."): ...
|
|
|
248
264
|
|
|
249
265
|
Walking and searching use Rust threads, without `asyncio.to_thread` or the event loop's executor. A callback uses `loop.call_soon_threadsafe` to complete the awaited future or supply results to the generator's queue. The event loop remains unblocked, with normal `contextvars` behaviour.
|
|
250
266
|
|
|
251
|
-
Cancellation
|
|
267
|
+
Cancellation is checked between rows and files; it does not interrupt a regex evaluation or filesystem call already in progress. This includes timeouts from `asyncio.wait_for` or `asyncio.timeout`. It also includes task cancellation, such as starlette cancelling a disconnected client's request.
|
|
252
268
|
|
|
253
269
|
Wrap an async iterator in `contextlib.aclosing` when leaving its loop early. `break` alone delays generator finalization until garbage collection. The context manager provides prompt cleanup:
|
|
254
270
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`rgapi` provides `fd`-style file discovery and `rg`-style text search from Python without starting a shell command.
|
|
4
4
|
|
|
5
|
-
It uses
|
|
5
|
+
It uses ripgrep's `ignore` and `grep-searcher` crates for walking and file scanning, with the pure-Rust `fancy-regex` engine for regex matching. Walking and searching run in parallel by default. Most expensive work stays in Rust.
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
@@ -87,6 +87,16 @@ Set `min_depth` and `max_depth` to bound recursion. `max_filesize` skips files a
|
|
|
87
87
|
|
|
88
88
|
Search is case-sensitive by default, matching `rg`. Use `smart_case=True` for `rg --smart-case` behaviour. Use `case_sensitive=False` to force case-insensitive matching.
|
|
89
89
|
|
|
90
|
+
Patterns support backreferences and lookaround everywhere, including filename and path filters:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
rg(r"\b(\w+)\s+\1\b", ".") # repeated words
|
|
94
|
+
rg(r"(?<=TODO: )\w+", ".") # word following TODO:
|
|
95
|
+
fd(".", pattern=r"^(\w+)_\1\.py$") # names such as utils_utils.py
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
All regex parameters use `fancy-regex`; no engine-selection flag or C library is required. Ordinary patterns use its non-backtracking engine. Patterns requiring backtracking have a bounded backtracking budget; exceeding it raises `ValueError`, rather than silently skipping a file or path.
|
|
99
|
+
|
|
90
100
|
Each `SearchLine` has these fields:
|
|
91
101
|
|
|
92
102
|
```text
|
|
@@ -106,7 +116,9 @@ Pass `lnhashs=True` to `rg` or `rg_iter` to display hash addresses instead of li
|
|
|
106
116
|
|
|
107
117
|
For other result forms, use `rg(..., paths=True)` to return unique matched paths or `rg(..., count=True)` to count match spans. `paths` and `count` cannot both be set.
|
|
108
118
|
|
|
109
|
-
`^` and `$` match at the start and end of each line. A line ends at `\n` or `\r\n`. `$` also matches before a `\r` that has no `\n` after it. `rg`, `rgstr`, and `
|
|
119
|
+
`^` and `$` match at the start and end of each line. A line ends at `\n` or `\r\n`. `$` also matches before a `\r` that has no `\n` after it. `rg`, `rgstr`, and `compile` reject patterns containing a literal `\n` or `\r`. `nbrg` allows them with `multiline=True`.
|
|
120
|
+
|
|
121
|
+
Content searches and compiled content matchers are confined to individual lines: whitespace classes, backreferences and lookaround cannot cross a line ending, and `\A`/`\z` refer to each line. With `nbrg(..., multiline=True)`, `\A`/`\z` refer to the whole cell, while `^`/`$` still anchor lines. Filename and path filters match the full name or path instead.
|
|
110
122
|
|
|
111
123
|
### Path results
|
|
112
124
|
|
|
@@ -138,6 +150,8 @@ Set `timeout_ms` on `rg`, `fd`, `walk`, or `ls` to stop at a deadline and return
|
|
|
138
150
|
|
|
139
151
|
`complete` is true exactly when `stop_reason` is `None`. `count=True` returns a plain integer without a completion flag. It cannot be combined with `timeout_ms`.
|
|
140
152
|
|
|
153
|
+
Deadlines stop result collection and request worker cancellation. Workers cannot interrupt a regex evaluation or filesystem call already in progress; a timeout is not a hard per-match execution limit.
|
|
154
|
+
|
|
141
155
|
### Context lines
|
|
142
156
|
|
|
143
157
|
`before_context`, `after_context`, and `context` correspond to `rg -B`, `rg -A`, and `rg -C`. Files containing NUL bytes or invalid UTF-8 are skipped.
|
|
@@ -169,7 +183,9 @@ nbrg("read_csv", ".") # cells whose source matches, across all
|
|
|
169
183
|
nbrg("read_csv", ".", cell_context=1) # also include neighbouring cells as context
|
|
170
184
|
```
|
|
171
185
|
|
|
172
|
-
Notebook discovery, parsing, and matching run together in one parallel Rust pass. Matching uses `
|
|
186
|
+
Notebook discovery, parsing, and matching run together in one parallel Rust pass. Matching uses the same `fancy-regex` engine as `rgapi.rg`, including backreferences and lookaround, with the same `case_sensitive` and `smart_case` behaviour. `nbrg` accepts the discovery filters from `fd` and `rg`, including `include`, `exclude`, `glob`, `hidden`, `max_depth`, and `skip_dir`.
|
|
187
|
+
|
|
188
|
+
Matching is line-oriented by default. Use `nbrg(r"(foo)\n\1", ".", multiline=True)` to match across lines within a cell. Matches never cross cell boundaries.
|
|
173
189
|
|
|
174
190
|
`nbrg` returns `NbResults`, a list of `NbCell`. Each `NbCell` has:
|
|
175
191
|
|
|
@@ -227,7 +243,7 @@ async for row in rga_iter("TODO", "."): ...
|
|
|
227
243
|
|
|
228
244
|
Walking and searching use Rust threads, without `asyncio.to_thread` or the event loop's executor. A callback uses `loop.call_soon_threadsafe` to complete the awaited future or supply results to the generator's queue. The event loop remains unblocked, with normal `contextvars` behaviour.
|
|
229
245
|
|
|
230
|
-
Cancellation
|
|
246
|
+
Cancellation is checked between rows and files; it does not interrupt a regex evaluation or filesystem call already in progress. This includes timeouts from `asyncio.wait_for` or `asyncio.timeout`. It also includes task cancellation, such as starlette cancelling a disconnected client's request.
|
|
231
247
|
|
|
232
248
|
Wrap an async iterator in `contextlib.aclosing` when leaving its loop early. `break` alone delays generator finalization until garbage collection. The context manager provides prompt cleanup:
|
|
233
249
|
|
|
@@ -17,7 +17,10 @@ def compile(
|
|
|
17
17
|
case_sensitive:bool|None=None, # True/False forces case; None allows `smart_case`
|
|
18
18
|
smart_case:bool=False # Match `rg --smart-case` behavior
|
|
19
19
|
) -> Regex:
|
|
20
|
-
"Compile a regex matcher
|
|
20
|
+
"""Compile a line-oriented fancy-regex matcher, supporting backreferences and lookaround.
|
|
21
|
+
|
|
22
|
+
`is_match` and `finditer` search each line independently; match offsets are UTF-8 bytes.
|
|
23
|
+
Compilation or backtracking-limit failures raise `ValueError`."""
|
|
21
24
|
return _core.compile(pattern, case_sensitive=case_sensitive, smart_case=smart_case)
|
|
22
25
|
|
|
23
26
|
class _Results(list):
|
|
@@ -159,7 +162,9 @@ def fd(
|
|
|
159
162
|
timeout_ms:int|None=None, # Cancel the walk after this long and return partial results
|
|
160
163
|
**kwargs
|
|
161
164
|
) -> PathResults:
|
|
162
|
-
"Find absolute Paths with fd-style filters and gitignore support.
|
|
165
|
+
"""Find absolute Paths with fd-style filters and gitignore support.
|
|
166
|
+
|
|
167
|
+
Filename/path regexes support backreferences and lookaround, matching the full name/path. Regex errors raise `ValueError`."""
|
|
163
168
|
return _find(root, pattern, files, dirs, timeout_ms, show_target, **kwargs)
|
|
164
169
|
|
|
165
170
|
|
|
@@ -306,6 +311,9 @@ def rg(
|
|
|
306
311
|
):
|
|
307
312
|
"""Search files and return `SearchResults`, matched paths, or a count; `lnhashs=True` shows exhash-style addresses.
|
|
308
313
|
|
|
314
|
+
Patterns support backreferences and lookaround, confined to individual lines. Compilation or backtracking-limit failures
|
|
315
|
+
raise `ValueError`. Timeouts request cancellation but do not interrupt a regex evaluation already in progress.
|
|
316
|
+
|
|
309
317
|
`summary=True` returns one `SearchBlock` per block of lines separated by blank or whitespace-only lines, however many matches
|
|
310
318
|
it holds, and `context` then counts blocks. Summary mode can't combine with `paths` or `count`, and with `lnhashs=True` shows
|
|
311
319
|
each block's boundary addresses. `^` and `$` anchor each line, and a line ends at LF or CRLF. Patterns containing a newline or
|
|
@@ -22,7 +22,7 @@ def nbrg_cli(
|
|
|
22
22
|
max_depth:int=None, # Maximum directory depth to search
|
|
23
23
|
timeout_ms:int=None, # Stop searching after this many milliseconds
|
|
24
24
|
):
|
|
25
|
-
"Search notebook cell sources and report stable cell IDs."
|
|
25
|
+
"Search notebook cell sources with backreferences/lookaround and report stable cell IDs."
|
|
26
26
|
print(nbrg(pattern, roots or '.', cell_context=cell_context, multiline=multiline, smart_case=smart_case,
|
|
27
27
|
case_sensitive=True if case else None, paths=paths, count=count, max_results=max_results, maxlen=maxlen,
|
|
28
28
|
glob=glob, exclude=exclude, hidden=hidden, max_depth=max_depth, timeout_ms=timeout_ms))
|
|
@@ -64,7 +64,7 @@ def search_nb(
|
|
|
64
64
|
display_path:str|Path|None=None, # Path stored in results; defaults to `path`
|
|
65
65
|
maxlen:int=MAXLEN, # Maximum source characters per displayed cell
|
|
66
66
|
) -> NbResults:
|
|
67
|
-
"Search one `.ipynb` file's cell sources, returning matched cells."
|
|
67
|
+
"Search one `.ipynb` file's cell sources with line-local backreferences and lookaround, returning matched cells."
|
|
68
68
|
disp = _display_path(path if display_path is None else display_path)
|
|
69
69
|
rows = _core.nb_search_file(pattern, _fs_path(path), disp, case_sensitive=case_sensitive,
|
|
70
70
|
smart_case=smart_case, cell_context=cell_context, multiline=False)
|
|
@@ -100,7 +100,9 @@ def nbrg(
|
|
|
100
100
|
):
|
|
101
101
|
"""Search `.ipynb` cell sources under `root` in parallel, returning matched cells, paths, or a count.
|
|
102
102
|
|
|
103
|
-
Only cell sources are searched, never metadata or outputs.
|
|
103
|
+
Only cell sources are searched, never metadata or outputs. Backreferences and lookaround are line-local by default.
|
|
104
|
+
`multiline=True` allows matching across lines within a cell, never across cells; `^` and `$` still anchor individual lines.
|
|
105
|
+
Compilation or backtracking-limit failures raise `ValueError`; timeouts cannot interrupt an in-progress regex evaluation.
|
|
104
106
|
A match preview starts at the first matched line and marks omitted earlier lines as `…[Ln]` (1-based), but keeps a leading
|
|
105
107
|
directive, as in `#| export…[L4]needle here`."""
|
|
106
108
|
assert not (count and max_results), "count and max_results are mutually exclusive"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Find files, search text, and search notebook cell sources from Python with ripgrep semantics and structured results. Use for fd-style discovery, regex searches, and notebook searches returning stable cell IDs rather than escaped JSON.
|
|
2
2
|
|
|
3
|
-
Built on ripgrep's `ignore
|
|
3
|
+
Built on ripgrep's `ignore` and `grep-searcher` crates with pure-Rust `fancy-regex`: ignore files, hidden files, globs and extensions follow ripgrep conventions. All regex parameters support backreferences and lookaround without an engine flag. Content matching is line-local unless `nbrg(multiline=True)` is used; filename/path regexes match their full input. Regex compilation or backtracking-limit failures raise `ValueError`; cancellation cannot interrupt an in-progress regex evaluation. Prefer these to shell parsing or manual file scans: `fd`/`ls` for listings, `rg` for files, `nbrg` for notebook cells, `rgstr` for text already in hand.
|
|
4
4
|
|
|
5
5
|
Orient with `rg(summary=True)`: one row per block (a paragraph of prose/config) with boundary addresses, where line results show fragments that may need another view. Switch to line results where needed; add `lnhashs=True` when edits may follow. Display results bare; narrow oversized results with parameters, not by joining, slicing, or reformatting.
|
|
6
6
|
|
|
@@ -3,11 +3,12 @@ use std::path::Path;
|
|
|
3
3
|
use std::sync::Arc;
|
|
4
4
|
use std::sync::atomic::Ordering;
|
|
5
5
|
|
|
6
|
-
use
|
|
6
|
+
use crate::RegexMatcher;
|
|
7
7
|
use ignore::{DirEntry, WalkState};
|
|
8
8
|
|
|
9
9
|
use crate::RgApiError;
|
|
10
|
-
use crate::search::{RgOptions, SearchLine,
|
|
10
|
+
use crate::search::{RgOptions, SearchLine, format_lnhash, search_text};
|
|
11
|
+
use crate::compile_regex;
|
|
11
12
|
use crate::walk::{PathFilters, StreamIter, entry_err, rel_path, resolve_roots, spawn_walk};
|
|
12
13
|
|
|
13
14
|
/// One blank-line-delimited block containing a match, or context for one.
|
|
@@ -97,7 +98,7 @@ fn block_entry(
|
|
|
97
98
|
let Some(ft) = dent.file_type() else { return Ok(Vec::new()); };
|
|
98
99
|
if !ft.is_file() { return Ok(Vec::new()); }
|
|
99
100
|
let rel = rel_path(base, path);
|
|
100
|
-
if !filters.path_allowed(Path::new(&rel)) { return Ok(Vec::new()); }
|
|
101
|
+
if !filters.path_allowed(Path::new(&rel))? { return Ok(Vec::new()); }
|
|
101
102
|
let bytes = match std::fs::read(path) { Ok(bytes) => bytes, Err(_) => return Ok(Vec::new()) };
|
|
102
103
|
process_file(rel, &bytes, matcher, before_context, after_context)
|
|
103
104
|
}
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
mod block;
|
|
4
4
|
mod nb;
|
|
5
|
+
mod matcher;
|
|
5
6
|
mod search;
|
|
6
7
|
mod walk;
|
|
7
8
|
|
|
8
9
|
pub use block::{BlockIter, SearchBlock, block_iter};
|
|
9
10
|
pub use nb::{CellRefs, NbCell, NbIter, NbOptions, ancestor_indices, cell_refs, heading_level, nb_iter, nb_search, nb_search_file, section_range};
|
|
10
|
-
pub use
|
|
11
|
+
pub use matcher::{RegexMatcher, compile_regex};
|
|
12
|
+
pub use search::{MatchSpan, RgIter, RgOptions, SearchError, SearchKind, SearchLine, rg, rg_iter, search_path, search_text, spans_for};
|
|
11
13
|
pub use walk::{FindIter, FindOptions, StreamIter, WalkOptions, find, find_iter, find_iter_with, resolve_roots};
|
|
12
14
|
|
|
13
15
|
#[derive(Debug, Clone)]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
use fancy_regex::{Assertion, Expr, Regex, RegexBuilder};
|
|
2
|
+
use grep_matcher::{LineTerminator, Match, Matcher, NoCaptures};
|
|
3
|
+
use regex_syntax::{ast::{self, Ast, Visitor}, hir::{Class, Hir, HirKind}};
|
|
4
|
+
|
|
5
|
+
use crate::RgApiError;
|
|
6
|
+
|
|
7
|
+
/// A fancy-regex matcher for grep-searcher. Ordinary line-safe patterns search whole
|
|
8
|
+
/// buffers; other line-oriented patterns search individual lines, without terminators.
|
|
9
|
+
/// Both paths use the same regex engine and return UTF-8 byte offsets.
|
|
10
|
+
#[derive(Debug, Clone)]
|
|
11
|
+
pub struct RegexMatcher { regex: Regex, line_oriented: bool, whole_buffer: bool }
|
|
12
|
+
|
|
13
|
+
impl RegexMatcher {
|
|
14
|
+
pub(crate) fn path(pattern: &str, smart_case: bool) -> Result<Self, RgApiError> { Self::build(pattern, None, smart_case, false, false) }
|
|
15
|
+
|
|
16
|
+
fn build(pattern: &str, case_sensitive: Option<bool>, smart_case: bool, line_oriented: bool, line_anchors: bool) -> Result<Self, RgApiError> {
|
|
17
|
+
// Parse with the same anchor defaults as the compiled regex. Fancy's resolved
|
|
18
|
+
// tree excludes group names, escape names and comments from smart-case analysis.
|
|
19
|
+
let parsed = if line_anchors { format!("(?mR){pattern}") } else { pattern.to_string() };
|
|
20
|
+
let tree = Expr::parse_tree(&parsed).map_err(regex_error)?;
|
|
21
|
+
let mut analysis = Analysis::default();
|
|
22
|
+
let mut stack = vec![&tree.expr];
|
|
23
|
+
while let Some(expr) = stack.pop() {
|
|
24
|
+
analysis.expression(expr)?;
|
|
25
|
+
stack.extend(expr.children_iter());
|
|
26
|
+
}
|
|
27
|
+
if line_oriented && analysis.literal_newline { return Err(RgApiError::new("a literal newline or carriage return is not allowed in a regex without multiline=True")); }
|
|
28
|
+
let insensitive = case_sensitive.map_or(smart_case && analysis.literal && !analysis.uppercase, |s| !s);
|
|
29
|
+
let regex = RegexBuilder::new(pattern).multi_line(line_anchors).crlf(line_anchors).case_insensitive(insensitive).build().map_err(regex_error)?;
|
|
30
|
+
Ok(Self { regex, line_oriented, whole_buffer: !line_oriented || !analysis.line_local })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub(crate) fn for_each_match(&self, text: &str, mut matched: impl FnMut(usize, usize)) -> Result<(), fancy_regex::Error> {
|
|
34
|
+
let mut scan = |part: &str, offset| {
|
|
35
|
+
for m in self.regex.find_iter(part) { let m = m?; matched(offset + m.start(), offset + m.end()); }
|
|
36
|
+
Ok(())
|
|
37
|
+
};
|
|
38
|
+
if self.whole_buffer { return scan(text, 0); }
|
|
39
|
+
let mut offset = 0;
|
|
40
|
+
for line in text.split_inclusive('\n') {
|
|
41
|
+
let body = line.strip_suffix('\n').unwrap_or(line);
|
|
42
|
+
scan(body.strip_suffix('\r').unwrap_or(body), offset)?;
|
|
43
|
+
offset += line.len();
|
|
44
|
+
}
|
|
45
|
+
if text.is_empty() { scan(text, 0)?; }
|
|
46
|
+
Ok(())
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Compile a content regex, including backreferences and lookaround.
|
|
51
|
+
/// With `multiline=false`, matching is confined to individual lines and literal
|
|
52
|
+
/// line endings are rejected. With `multiline=true`, the whole input is searched.
|
|
53
|
+
pub fn compile_regex(pattern: &str, case_sensitive: Option<bool>, smart_case: bool, multiline: bool) -> Result<RegexMatcher, RgApiError> {
|
|
54
|
+
if pattern.is_empty() { return Err(RgApiError::new("pattern may not be empty")); }
|
|
55
|
+
RegexMatcher::build(pattern, case_sensitive, smart_case, !multiline, true)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn regex_error(error: impl std::fmt::Display) -> RgApiError { RgApiError::new(error.to_string()) }
|
|
59
|
+
|
|
60
|
+
impl Matcher for RegexMatcher {
|
|
61
|
+
type Captures = NoCaptures;
|
|
62
|
+
type Error = fancy_regex::Error;
|
|
63
|
+
|
|
64
|
+
fn new_captures(&self) -> Result<NoCaptures, Self::Error> { Ok(NoCaptures::new()) }
|
|
65
|
+
fn line_terminator(&self) -> Option<LineTerminator> { self.line_oriented.then(LineTerminator::crlf) }
|
|
66
|
+
|
|
67
|
+
fn find_at(&self, haystack: &[u8], mut at: usize) -> Result<Option<Match>, Self::Error> {
|
|
68
|
+
if at > haystack.len() { return Ok(None); }
|
|
69
|
+
// grep-matcher advances empty matches by one byte; fancy's VM must never
|
|
70
|
+
// start its next search in the middle of a UTF-8 character.
|
|
71
|
+
while at < haystack.len() && haystack[at] & 0xC0 == 0x80 { at += 1; }
|
|
72
|
+
if self.whole_buffer { return self.regex.find_from_pos(haystack, at).map(|m| m.map(|m| Match::new(m.start(), m.end()))); }
|
|
73
|
+
// Keep the beginning of the current line for anchors and lookbehind when
|
|
74
|
+
// grep-matcher asks for another match part-way through it.
|
|
75
|
+
let mut start = haystack[..at].iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1);
|
|
76
|
+
loop {
|
|
77
|
+
if start > 0 && start == haystack.len() { return Ok(None); }
|
|
78
|
+
let end = haystack[start..].iter().position(|&b| b == b'\n').map_or(haystack.len(), |i| start + i);
|
|
79
|
+
let line = &haystack[start..end];
|
|
80
|
+
let line = line.strip_suffix(b"\r").unwrap_or(line);
|
|
81
|
+
let pos = at.saturating_sub(start);
|
|
82
|
+
if pos <= line.len() && let Some(m) = self.regex.find_from_pos(line, pos)? { return Ok(Some(Match::new(start + m.start(), start + m.end()))); }
|
|
83
|
+
if end == haystack.len() { return Ok(None); }
|
|
84
|
+
start = end + 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[derive(Default)]
|
|
90
|
+
struct Analysis { literal: bool, uppercase: bool, literal_newline: bool, line_local: bool }
|
|
91
|
+
|
|
92
|
+
impl Analysis {
|
|
93
|
+
fn literal(&mut self, c: char) { self.literal = true; self.uppercase |= c.is_uppercase(); }
|
|
94
|
+
|
|
95
|
+
fn expression(&mut self, expr: &Expr) -> Result<(), RgApiError> {
|
|
96
|
+
match expr {
|
|
97
|
+
Expr::Literal { val, .. } => {
|
|
98
|
+
for c in val.chars() { self.literal(c); }
|
|
99
|
+
self.literal_newline |= val.contains(['\r', '\n']);
|
|
100
|
+
}
|
|
101
|
+
Expr::Delegate { inner, .. } => {
|
|
102
|
+
let ast = ast::parse::Parser::new().parse(inner).map_err(regex_error)?;
|
|
103
|
+
let literals = ast::visit(&ast, ClassLiterals::default()).unwrap();
|
|
104
|
+
self.literal |= literals.literal;
|
|
105
|
+
self.uppercase |= literals.uppercase;
|
|
106
|
+
let hir = regex_syntax::Parser::new().parse(inner).map_err(regex_error)?;
|
|
107
|
+
self.line_local |= contains_newline(&hir);
|
|
108
|
+
if let HirKind::Literal(lit) = hir.kind() { self.literal_newline |= lit.0.iter().any(|b| matches!(b, b'\r' | b'\n')); }
|
|
109
|
+
}
|
|
110
|
+
Expr::Any { newline, crlf } => self.line_local |= *newline || !crlf,
|
|
111
|
+
Expr::Assertion(Assertion::StartText | Assertion::EndText | Assertion::EndTextIgnoreTrailingNewlines { .. }) => self.line_local = true,
|
|
112
|
+
Expr::Empty | Expr::Assertion(_) | Expr::Concat(_) | Expr::Alt(_) | Expr::Group(_) | Expr::Repeat { .. } => {}
|
|
113
|
+
// Backreferences, lookaround and other fancy constructs use line-local
|
|
114
|
+
// input so captures and assertions cannot inspect neighbouring lines.
|
|
115
|
+
_ => self.line_local = true,
|
|
116
|
+
}
|
|
117
|
+
Ok(())
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fn contains_newline(hir: &Hir) -> bool {
|
|
122
|
+
match hir.kind() {
|
|
123
|
+
HirKind::Literal(lit) => lit.0.iter().any(|b| matches!(b, b'\r' | b'\n')),
|
|
124
|
+
HirKind::Class(Class::Unicode(c)) => c.ranges().iter().any(|r| ['\r', '\n'].iter().any(|&c| r.start() <= c && c <= r.end())),
|
|
125
|
+
HirKind::Class(Class::Bytes(c)) => c.ranges().iter().any(|r| [b'\r', b'\n'].iter().any(|&c| r.start() <= c && c <= r.end())),
|
|
126
|
+
_ => hir.kind().subs().iter().any(contains_newline),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[derive(Default)]
|
|
131
|
+
struct ClassLiterals { literal: bool, uppercase: bool }
|
|
132
|
+
impl ClassLiterals { fn add(&mut self, c: char) { self.literal = true; self.uppercase |= c.is_uppercase(); } }
|
|
133
|
+
impl Visitor for ClassLiterals {
|
|
134
|
+
type Output = Self;
|
|
135
|
+
type Err = std::convert::Infallible;
|
|
136
|
+
fn finish(self) -> Result<Self, Self::Err> { Ok(self) }
|
|
137
|
+
fn visit_pre(&mut self, ast: &Ast) -> Result<(), Self::Err> { if let Ast::Literal(lit) = ast { self.add(lit.c); } Ok(()) }
|
|
138
|
+
fn visit_class_set_item_pre(&mut self, item: &ast::ClassSetItem) -> Result<(), Self::Err> {
|
|
139
|
+
match item { ast::ClassSetItem::Literal(lit) => self.add(lit.c), ast::ClassSetItem::Range(r) => { self.add(r.start.c); self.add(r.end.c); } _ => {} }
|
|
140
|
+
Ok(())
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -3,21 +3,22 @@ use std::path::Path;
|
|
|
3
3
|
use std::sync::atomic::Ordering;
|
|
4
4
|
use std::sync::{Arc, LazyLock};
|
|
5
5
|
|
|
6
|
-
use
|
|
7
|
-
use
|
|
6
|
+
use fancy_regex::Regex;
|
|
7
|
+
use crate::RegexMatcher;
|
|
8
8
|
use ignore::{DirEntry, WalkState};
|
|
9
9
|
use serde::Deserialize;
|
|
10
10
|
|
|
11
11
|
use crate::RgApiError;
|
|
12
|
-
use crate::search::{SearchLine,
|
|
12
|
+
use crate::search::{SearchLine, search_text};
|
|
13
|
+
use crate::compile_regex;
|
|
13
14
|
use crate::walk::{PathFilters, StreamIter, WalkOptions, entry_err, rel_path, resolve_roots, spawn_walk};
|
|
14
15
|
|
|
15
|
-
static HEADING_RE: LazyLock<
|
|
16
|
+
static HEADING_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^#{1,6} \w").unwrap());
|
|
16
17
|
|
|
17
18
|
/// Heading level of the first nonblank line after skipping `#|` directives; zero unless it matches `^#{1,6} \w`.
|
|
18
19
|
pub fn heading_level(source: &str) -> usize {
|
|
19
20
|
let line = source.lines().find(|l| !l.trim().is_empty() && !l.starts_with("#|")).unwrap_or("");
|
|
20
|
-
if !HEADING_RE.is_match(line
|
|
21
|
+
if !HEADING_RE.is_match(line).unwrap_or(false) { return 0; }
|
|
21
22
|
line.bytes().take_while(|&b| b == b'#').count()
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -44,12 +45,8 @@ pub fn ancestor_indices(levels: &[usize], idx: usize) -> Vec<usize> {
|
|
|
44
45
|
|
|
45
46
|
/// Group 1 of every `` sigil`body` `` match in `text`, in order of appearance. `sigil` and `body` are regex fragments.
|
|
46
47
|
fn sigil_caps(text: &str, sigil: &str, body: &str) -> Vec<String> {
|
|
47
|
-
let re =
|
|
48
|
-
|
|
49
|
-
let mut res = Vec::new();
|
|
50
|
-
re.captures_iter(text.as_bytes(), &mut caps, |c| { if let Some(m) = c.get(1) { res.push(text[m.start()..m.end()].to_string()); } true })
|
|
51
|
-
.expect("regex search is infallible");
|
|
52
|
-
res
|
|
48
|
+
let re = Regex::new(&format!("{sigil}`({body})`")).expect("sigil pattern compiles");
|
|
49
|
+
re.captures_iter(text).map(|c| c.expect("internal regex search succeeds").get(1).unwrap().as_str().to_string()).collect()
|
|
53
50
|
}
|
|
54
51
|
|
|
55
52
|
/// Names written as `` &`name` `` or `` &`[a, b]` `` in `text`. A name holds word characters and dots.
|
|
@@ -175,14 +172,6 @@ fn process_file(disp: String, bytes: &[u8], matcher: &RegexMatcher, cell_context
|
|
|
175
172
|
Ok(out)
|
|
176
173
|
}
|
|
177
174
|
|
|
178
|
-
fn compile_nb_regex(pattern: &str, case_sensitive: Option<bool>, smart_case: bool, multiline: bool) -> Result<RegexMatcher, RgApiError> {
|
|
179
|
-
compile_regex(pattern, case_sensitive, smart_case, multiline).map_err(|e| {
|
|
180
|
-
if !multiline && e.to_string().contains("not allowed in a regex") {
|
|
181
|
-
RgApiError::new(format!("{e}; pass multiline=True to let the pattern match across lines within a cell"))
|
|
182
|
-
} else { e }
|
|
183
|
-
})
|
|
184
|
-
}
|
|
185
|
-
|
|
186
175
|
pub fn nb_search_file(
|
|
187
176
|
path: &Path,
|
|
188
177
|
display_path: String,
|
|
@@ -192,7 +181,7 @@ pub fn nb_search_file(
|
|
|
192
181
|
cell_context: usize,
|
|
193
182
|
multiline: bool,
|
|
194
183
|
) -> Result<Vec<NbCell>, RgApiError> {
|
|
195
|
-
let matcher =
|
|
184
|
+
let matcher = compile_regex(pattern, case_sensitive, smart_case, multiline)?;
|
|
196
185
|
let bytes = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(Vec::new()) };
|
|
197
186
|
process_file(display_path, &bytes, &matcher, cell_context, multiline)
|
|
198
187
|
}
|
|
@@ -211,7 +200,7 @@ fn nb_entry(
|
|
|
211
200
|
let Some(ft) = dent.file_type() else { return Ok(Vec::new()); };
|
|
212
201
|
if !ft.is_file() { return Ok(Vec::new()); }
|
|
213
202
|
let rel = rel_path(base, path);
|
|
214
|
-
if !filters.path_allowed(Path::new(&rel)) { return Ok(Vec::new()); }
|
|
203
|
+
if !filters.path_allowed(Path::new(&rel))? { return Ok(Vec::new()); }
|
|
215
204
|
let bytes = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(Vec::new()) };
|
|
216
205
|
process_file(rel, &bytes, matcher, cell_context, multiline)
|
|
217
206
|
}
|
|
@@ -221,7 +210,7 @@ pub type NbIter = StreamIter<NbCell>;
|
|
|
221
210
|
pub fn nb_iter(opts: &NbOptions) -> Result<NbIter, RgApiError> {
|
|
222
211
|
let (roots, base) = resolve_roots(&opts.walk, true, false)?;
|
|
223
212
|
let filters = Arc::new(PathFilters::new(&opts.walk)?);
|
|
224
|
-
let matcher =
|
|
213
|
+
let matcher = compile_regex(&opts.pattern, opts.case_sensitive, opts.smart_case, opts.multiline)?;
|
|
225
214
|
let (cell_context, multiline, max_depth) = (opts.cell_context, opts.multiline, opts.walk.max_depth);
|
|
226
215
|
Ok(spawn_walk(roots, base, &opts.walk, filters, Vec::new(), move |dent, base, filters, tx, cancel| {
|
|
227
216
|
match nb_entry(dent, base, filters, &matcher, cell_context, multiline, max_depth) {
|
|
@@ -5,12 +5,12 @@ use std::sync::{
|
|
|
5
5
|
mpsc::SyncSender,
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
use grep_matcher::
|
|
9
|
-
use
|
|
8
|
+
use grep_matcher::LineTerminator;
|
|
9
|
+
use crate::matcher::RegexMatcher;
|
|
10
10
|
use grep_searcher::{BinaryDetection, SearcherBuilder, Sink, SinkContext, SinkContextKind, SinkError, SinkMatch};
|
|
11
11
|
use ignore::{DirEntry, WalkState};
|
|
12
12
|
|
|
13
|
-
use crate::RgApiError;
|
|
13
|
+
use crate::{RgApiError, compile_regex};
|
|
14
14
|
use crate::walk::{PathFilters, StreamIter, WalkOptions, entry_err, rel_path, resolve_roots, spawn_walk};
|
|
15
15
|
|
|
16
16
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
@@ -84,7 +84,7 @@ fn search_entry(
|
|
|
84
84
|
let Some(ft) = dent.file_type() else { return WalkState::Continue; };
|
|
85
85
|
if !ft.is_file() { return WalkState::Continue; }
|
|
86
86
|
let rel = rel_path(base, path);
|
|
87
|
-
|
|
87
|
+
match filters.path_allowed(Path::new(&rel)) { Ok(true) => {}, Ok(false) => return WalkState::Continue, Err(err) => return send_search_error(tx, err) }
|
|
88
88
|
match search_path_cancelable(path, rel, matcher.clone(), before_context, after_context, Some(cancel.clone())) {
|
|
89
89
|
Ok(lines) => {
|
|
90
90
|
if is_cancelled(cancel) { return WalkState::Quit; }
|
|
@@ -99,27 +99,6 @@ fn is_cancelled(cancel: &Arc<AtomicBool>) -> bool { cancel.load(Ordering::Relaxe
|
|
|
99
99
|
|
|
100
100
|
fn send_search_error(tx: &SyncSender<Result<SearchLine, RgApiError>>, err: RgApiError) -> WalkState { let _ = tx.send(Err(err)); WalkState::Quit }
|
|
101
101
|
|
|
102
|
-
pub fn compile_regex(pattern: &str, case_sensitive: Option<bool>, smart_case: bool, multiline: bool) -> Result<RegexMatcher, RgApiError> {
|
|
103
|
-
if pattern.is_empty() { return Err(RgApiError::new("pattern may not be empty")); }
|
|
104
|
-
let mut builder = RegexMatcherBuilder::new();
|
|
105
|
-
builder.multi_line(true).crlf(true);
|
|
106
|
-
if multiline { builder.line_terminator(None); }
|
|
107
|
-
match case_sensitive {
|
|
108
|
-
Some(true) => {
|
|
109
|
-
builder.case_insensitive(false);
|
|
110
|
-
builder.case_smart(false);
|
|
111
|
-
}
|
|
112
|
-
Some(false) => {
|
|
113
|
-
builder.case_insensitive(true);
|
|
114
|
-
builder.case_smart(false);
|
|
115
|
-
}
|
|
116
|
-
None => {
|
|
117
|
-
builder.case_smart(smart_case);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
builder.build(pattern).map_err(|e| RgApiError::new(e.to_string()))
|
|
121
|
-
}
|
|
122
|
-
|
|
123
102
|
pub(crate) fn format_lnhash(lineno: u64, line: &str) -> String {
|
|
124
103
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
125
104
|
let hash = crc32fast::hash(line.as_bytes()) as usize;
|
|
@@ -244,8 +223,7 @@ fn bytes_to_line(bytes: &[u8]) -> Result<String, SearchError> {
|
|
|
244
223
|
/// Return byte offsets for every match in a line.
|
|
245
224
|
pub fn spans_for(matcher: &RegexMatcher, bytes: &[u8]) -> Result<Vec<MatchSpan>, SearchError> {
|
|
246
225
|
let mut spans = Vec::new();
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.map_err(|e| SearchError::Message(e.to_string()))?;
|
|
226
|
+
let text = std::str::from_utf8(bytes).map_err(|_| SearchError::InvalidUtf8)?;
|
|
227
|
+
matcher.for_each_match(text, |start, end| spans.push(MatchSpan { start, end })).map_err(|e| SearchError::Message(e.to_string()))?;
|
|
250
228
|
Ok(spans)
|
|
251
229
|
}
|
|
@@ -6,10 +6,9 @@ use std::sync::{Arc, Mutex, mpsc};
|
|
|
6
6
|
|
|
7
7
|
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
|
|
8
8
|
use grep_matcher::Matcher;
|
|
9
|
-
use grep_regex::{RegexMatcher, RegexMatcherBuilder};
|
|
10
9
|
use ignore::{DirEntry, WalkBuilder, WalkState};
|
|
11
10
|
|
|
12
|
-
use crate::RgApiError;
|
|
11
|
+
use crate::{RegexMatcher, RgApiError};
|
|
13
12
|
|
|
14
13
|
/// Where to walk and which paths to keep. `FindOptions`, `RgOptions` and `NbOptions` each hold one.
|
|
15
14
|
#[derive(Debug, Clone)]
|
|
@@ -83,10 +82,13 @@ pub fn find_iter_with(opts: &FindOptions, walk_root_links: bool) -> Result<FindI
|
|
|
83
82
|
let walk = &opts.walk;
|
|
84
83
|
let (roots, base) = resolve_roots(walk, false, walk_root_links)?;
|
|
85
84
|
let filters = Arc::new(PathFilters::new(walk)?);
|
|
86
|
-
let pattern = opts.pattern.as_deref().map(
|
|
85
|
+
let pattern = opts.pattern.as_deref().map(|p| RegexMatcher::path(p, true)).transpose()?;
|
|
87
86
|
// The walker follows every root it is given. A link root returned as itself must not reach it.
|
|
88
87
|
let (links, roots): (Vec<_>, Vec<_>) = roots.into_iter().partition(|r| r.is_symlink() && !walk.follow_links && !(walk_root_links && r.is_dir()));
|
|
89
|
-
let ready =
|
|
88
|
+
let mut ready = Vec::new();
|
|
89
|
+
if walk.min_depth.unwrap_or(0) == 0 {
|
|
90
|
+
for link in links { let rel = relative_path(&base, &link); if find_matches(rel, &filters, pattern.as_ref())? { ready.push(rel.to_path_buf()); } }
|
|
91
|
+
}
|
|
90
92
|
let (files, dirs, special_files, panic_probe, max_depth) = (opts.files, opts.dirs, opts.special_files, opts.panic_probe, walk.max_depth);
|
|
91
93
|
Ok(spawn_walk(roots, base, walk, filters, ready, move |dent, base, filters, tx, cancel| {
|
|
92
94
|
if panic_probe { panic!("rgapi: deliberate panic for tests (panic_probe)"); }
|
|
@@ -196,7 +198,7 @@ where
|
|
|
196
198
|
if worker_cancel.load(Ordering::Relaxed) { return; }
|
|
197
199
|
let mut walker = WalkBuilder::new(root);
|
|
198
200
|
configure_walker(&mut walker, root, &walk);
|
|
199
|
-
filter_dirs(&mut walker, &base, filters.clone());
|
|
201
|
+
filter_dirs(&mut walker, &base, filters.clone(), tx.clone(), worker_cancel.clone());
|
|
200
202
|
walker.build_parallel().run(|| {
|
|
201
203
|
let (tx, base, filters, cancel, entry, seen) = (tx.clone(), base.clone(), filters.clone(), worker_cancel.clone(), entry.clone(), seen.clone());
|
|
202
204
|
Box::new(move |dent| {
|
|
@@ -230,7 +232,7 @@ fn find_entry(
|
|
|
230
232
|
Err(err) => {
|
|
231
233
|
if let Some(path) = dangling_link(&err) {
|
|
232
234
|
let rel = relative_path(base, path);
|
|
233
|
-
return Ok(find_matches(rel, filters, pattern)
|
|
235
|
+
return Ok(find_matches(rel, filters, pattern)?.then(|| rel.to_path_buf()));
|
|
234
236
|
}
|
|
235
237
|
return entry_err(err, max_depth).map_or(Ok(None), Err);
|
|
236
238
|
}
|
|
@@ -242,7 +244,7 @@ fn find_entry(
|
|
|
242
244
|
if ft.is_dir() && !dirs { return Ok(None); }
|
|
243
245
|
if !ft.is_file() && !ft.is_dir() && !ft.is_symlink() && !special_files { return Ok(None); }
|
|
244
246
|
let rel = relative_path(base, path);
|
|
245
|
-
Ok(find_matches(rel, filters, pattern)
|
|
247
|
+
Ok(find_matches(rel, filters, pattern)?.then(|| rel.to_path_buf()))
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
// With `follow_links` the walker reports a dangling link as an error; it is a dangling link when the path is a symlink whose target is missing.
|
|
@@ -254,8 +256,8 @@ fn dangling_link(err: &ignore::Error) -> Option<&Path> {
|
|
|
254
256
|
}
|
|
255
257
|
}
|
|
256
258
|
|
|
257
|
-
fn find_matches(path: &Path, filters: &PathFilters, pattern: Option<&RegexMatcher>) -> bool {
|
|
258
|
-
if let Some(pattern) = pattern { if !re_match(pattern, &path.file_name().unwrap_or_default().to_string_lossy()) { return false; } }
|
|
259
|
+
fn find_matches(path: &Path, filters: &PathFilters, pattern: Option<&RegexMatcher>) -> Result<bool, RgApiError> {
|
|
260
|
+
if let Some(pattern) = pattern { if !re_match(pattern, &path.file_name().unwrap_or_default().to_string_lossy())? { return Ok(false); } }
|
|
259
261
|
filters.path_allowed(path)
|
|
260
262
|
}
|
|
261
263
|
|
|
@@ -318,9 +320,12 @@ fn configure_walker(walker: &mut WalkBuilder, root: &Path, walk: &WalkOptions) {
|
|
|
318
320
|
walker.same_file_system(walk.same_file_system);
|
|
319
321
|
}
|
|
320
322
|
|
|
321
|
-
fn filter_dirs(walker: &mut WalkBuilder, base: &Path, filters: Arc<PathFilters>) {
|
|
323
|
+
fn filter_dirs<T: Send + 'static>(walker: &mut WalkBuilder, base: &Path, filters: Arc<PathFilters>, tx: mpsc::SyncSender<Result<T, RgApiError>>, cancel: Arc<AtomicBool>) {
|
|
322
324
|
let base = base.to_path_buf();
|
|
323
|
-
walker.filter_entry(move |entry| filters.entry_allowed(&base, entry)
|
|
325
|
+
walker.filter_entry(move |entry| if cancel.load(Ordering::Relaxed) { false } else { match filters.entry_allowed(&base, entry) {
|
|
326
|
+
Ok(allowed) => allowed,
|
|
327
|
+
Err(err) => { let _ = tx.send(Err(err)); false }
|
|
328
|
+
} });
|
|
324
329
|
}
|
|
325
330
|
|
|
326
331
|
pub(crate) struct PathFilters {
|
|
@@ -346,37 +351,37 @@ impl PathFilters {
|
|
|
346
351
|
})
|
|
347
352
|
}
|
|
348
353
|
|
|
349
|
-
pub(crate) fn path_allowed(&self, path: &Path) -> bool {
|
|
354
|
+
pub(crate) fn path_allowed(&self, path: &Path) -> Result<bool, RgApiError> {
|
|
350
355
|
if let Some(excludes) = &self.excludes
|
|
351
356
|
&& excludes.is_match(path)
|
|
352
|
-
{ return false; }
|
|
357
|
+
{ return Ok(false); }
|
|
353
358
|
if let Some(skip_path_re) = &self.skip_path_re
|
|
354
|
-
&& re_match(skip_path_re, &path_label(path))
|
|
355
|
-
{ return false; }
|
|
359
|
+
&& re_match(skip_path_re, &path_label(path))?
|
|
360
|
+
{ return Ok(false); }
|
|
356
361
|
if let Some(path_re) = &self.path_re
|
|
357
|
-
&& !re_match(path_re, &path_label(path))
|
|
358
|
-
{ return false; }
|
|
362
|
+
&& !re_match(path_re, &path_label(path))?
|
|
363
|
+
{ return Ok(false); }
|
|
359
364
|
if let Some(exts) = &self.exts
|
|
360
365
|
&& !exts.is_match(path)
|
|
361
|
-
{ return false; }
|
|
362
|
-
if let Some(includes) = &self.includes { return includes.is_match(path); }
|
|
363
|
-
true
|
|
366
|
+
{ return Ok(false); }
|
|
367
|
+
if let Some(includes) = &self.includes { return Ok(includes.is_match(path)); }
|
|
368
|
+
Ok(true)
|
|
364
369
|
}
|
|
365
370
|
|
|
366
|
-
fn entry_allowed(&self, base: &Path, dent: &DirEntry) -> bool {
|
|
371
|
+
fn entry_allowed(&self, base: &Path, dent: &DirEntry) -> Result<bool, RgApiError> {
|
|
367
372
|
let path = dent.path();
|
|
368
|
-
if dent.depth() == 0 { return true; }
|
|
369
|
-
let Some(ft) = dent.file_type() else { return true; };
|
|
370
|
-
if !ft.is_dir() { return true; }
|
|
373
|
+
if dent.depth() == 0 { return Ok(true); }
|
|
374
|
+
let Some(ft) = dent.file_type() else { return Ok(true); };
|
|
375
|
+
if !ft.is_dir() { return Ok(true); }
|
|
371
376
|
let rel = relative_path(base, path);
|
|
372
|
-
if self.excludes.as_ref().is_some_and(|globs| globs.is_match(rel)) { return false; }
|
|
377
|
+
if self.excludes.as_ref().is_some_and(|globs| globs.is_match(rel)) { return Ok(false); }
|
|
373
378
|
if let Some(skip_dirs) = &self.skip_dirs
|
|
374
379
|
&& skip_dirs.is_match(rel)
|
|
375
|
-
{ return false; }
|
|
380
|
+
{ return Ok(false); }
|
|
376
381
|
if let Some(skip_dir_re) = &self.skip_dir_re
|
|
377
|
-
&& re_match(skip_dir_re, &path_label(rel))
|
|
378
|
-
{ return false; }
|
|
379
|
-
true
|
|
382
|
+
&& re_match(skip_dir_re, &path_label(rel))?
|
|
383
|
+
{ return Ok(false); }
|
|
384
|
+
Ok(true)
|
|
380
385
|
}
|
|
381
386
|
}
|
|
382
387
|
|
|
@@ -393,17 +398,11 @@ fn add_glob(builder: &mut GlobSetBuilder, glob: &str) -> Result<(), RgApiError>
|
|
|
393
398
|
Ok(())
|
|
394
399
|
}
|
|
395
400
|
|
|
396
|
-
fn build_fd_re(pattern: &str) -> Result<RegexMatcher, RgApiError> {
|
|
397
|
-
let mut builder = RegexMatcherBuilder::new();
|
|
398
|
-
builder.case_smart(true);
|
|
399
|
-
builder.build(pattern).map_err(|e| RgApiError::new(e.to_string()))
|
|
400
|
-
}
|
|
401
|
-
|
|
402
401
|
fn build_path_re(pattern: Option<&str>) -> Result<Option<RegexMatcher>, RgApiError> {
|
|
403
|
-
pattern.map(|pattern|
|
|
402
|
+
pattern.map(|pattern| RegexMatcher::path(pattern, false)).transpose()
|
|
404
403
|
}
|
|
405
404
|
|
|
406
|
-
fn re_match(matcher: &RegexMatcher, rel: &str) -> bool { matcher.is_match(rel.as_bytes()).
|
|
405
|
+
fn re_match(matcher: &RegexMatcher, rel: &str) -> Result<bool, RgApiError> { matcher.is_match(rel.as_bytes()).map_err(|e| RgApiError::new(e.to_string())) }
|
|
407
406
|
|
|
408
407
|
#[cfg(test)]
|
|
409
408
|
mod tests {
|
|
@@ -72,6 +72,16 @@ def test_fda_iter(tmp_path):
|
|
|
72
72
|
assert run_sync(first_then_close()) in fd(tmp_path)
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def test_async_pruning_regex_errors(tmp_path):
|
|
76
|
+
sub = tmp_path/("ab"*26)
|
|
77
|
+
sub.mkdir()
|
|
78
|
+
(sub/"a.txt").write_text("TODO")
|
|
79
|
+
pattern = r"(a|b|ab)*(?>c)"
|
|
80
|
+
with pytest.raises(ValueError, match="backtrack"): run_sync(fda(tmp_path, skip_dir_re=pattern))
|
|
81
|
+
async def rows(): return [r async for r in rga_iter("TODO", tmp_path, skip_dir_re=pattern)]
|
|
82
|
+
with pytest.raises(ValueError, match="backtrack"): run_sync(rows())
|
|
83
|
+
|
|
84
|
+
|
|
75
85
|
def test_nbrga_paths(tmp_path):
|
|
76
86
|
from rgapi import nbrg, nbrga
|
|
77
87
|
from test_rgapi import _cell, write_nb
|
|
@@ -290,12 +290,14 @@ def test_worker_panic_surfaces_as_error_not_truncation(tmp_path):
|
|
|
290
290
|
with pytest.raises(Exception): _core.panic_probe(str(tmp_path), walk=True) # walk workers
|
|
291
291
|
|
|
292
292
|
|
|
293
|
-
|
|
293
|
+
@pytest.mark.parametrize("pattern", ["TODO", r"(TO)D(?!X)O"])
|
|
294
|
+
def test_search_path_skips_binary_and_invalid_utf8(tmp_path, pattern):
|
|
294
295
|
(tmp_path / "bin.dat").write_bytes(b"TODO before\n\0TODO after\n")
|
|
295
|
-
|
|
296
|
-
matcher = compile("TODO")
|
|
296
|
+
matcher = compile(pattern)
|
|
297
297
|
assert search_path(matcher, tmp_path / "bin.dat") == []
|
|
298
|
-
|
|
298
|
+
for data in [b"TODO\xff\n", b"\xffTODO\n"]:
|
|
299
|
+
(tmp_path / "bad.txt").write_bytes(data)
|
|
300
|
+
assert search_path(matcher, tmp_path / "bad.txt") == []
|
|
299
301
|
|
|
300
302
|
|
|
301
303
|
def test_rg_keyboard_interrupt_cancels(tmp_path):
|
|
@@ -345,6 +347,56 @@ def test_rgstr():
|
|
|
345
347
|
assert str(res[0]) == "1-zero"
|
|
346
348
|
|
|
347
349
|
|
|
350
|
+
def test_backreferences_and_lookaround(tmp_path):
|
|
351
|
+
pattern = r"\b(\w+)\s+\1\b"
|
|
352
|
+
text = "before\né é, foo foo\nfoo\nfoo\nafter\n"
|
|
353
|
+
p = tmp_path/"echo_echo.txt"
|
|
354
|
+
p.write_text(text)
|
|
355
|
+
matcher = compile(pattern)
|
|
356
|
+
assert matcher.finditer(text) == [(7, 12), (14, 21)]
|
|
357
|
+
assert [(r.line_number, r.matches) for r in search_text(matcher, text)] == [(2, [(0, 5), (7, 14)])]
|
|
358
|
+
assert [(r.line_number, r.matches) for r in search_path(matcher, p)] == [(2, [(0, 5), (7, 14)])]
|
|
359
|
+
assert rg(pattern, p, count=True) == 2
|
|
360
|
+
assert [b.source for b in rg(pattern, p, summary=True)] == [text.rstrip()]
|
|
361
|
+
assert compile(r"(?<=é )é(?=,)").finditer(text) == [(10, 12)]
|
|
362
|
+
assert compile(r"(?=.)").finditer("éλ") == [(0, 0), (2, 2)]
|
|
363
|
+
assert [r.matches for r in rgstr(r"(?=$)", "é\n")] == [[(2, 2)]]
|
|
364
|
+
assert [p.name for p in fd(tmp_path, r"^(\w+)_\1\.txt$")] == ["echo_echo.txt"]
|
|
365
|
+
assert fd(tmp_path, path_re=r"(?<=echo_)echo") == [p]
|
|
366
|
+
assert fd(tmp_path, skip_path_re=r"(?<=echo_)echo") == []
|
|
367
|
+
sub = tmp_path/"skip_skip"
|
|
368
|
+
sub.mkdir()
|
|
369
|
+
(sub/"a.txt").write_text("foo foo")
|
|
370
|
+
assert fd(tmp_path, skip_dir_re=r"^(\w+)_\1$") == [p]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_line_local_regexes_and_smart_case():
|
|
374
|
+
for pattern in [r"foo\s+foo", r"foo[^x]+foo", r"(?s:foo.+foo)", r"foo(?=\s+foo)"]:
|
|
375
|
+
assert rgstr(pattern, "foo\nfoo\n") == []
|
|
376
|
+
assert [r.matches for r in rgstr(r"(?s:foo.*|bar)", "foo\nbar\n")] == [[(0, 3)], [(0, 3)]]
|
|
377
|
+
assert compile(r"\Afoo\z").finditer("foo\nfoo\n") == [(0, 3), (4, 7)]
|
|
378
|
+
empty = compile(r"\A\z")
|
|
379
|
+
for text, spans in [("foo\n", []), ("", [(0, 0)]), ("\n", [(0, 0)]), ("foo\n\n", [(4, 4)])]:
|
|
380
|
+
assert empty.finditer(text) == spans
|
|
381
|
+
assert empty.is_match(text) == bool(spans)
|
|
382
|
+
assert compile(r"(?P<UPPER>foo)\s+(?P=UPPER)", smart_case=True).is_match("FOO foo")
|
|
383
|
+
assert compile(r"foo\S", smart_case=True).is_match("FOOx")
|
|
384
|
+
assert compile("(?x)foo # UPPER comment", smart_case=True).is_match("FOO")
|
|
385
|
+
assert not compile(r"foo[A-Z]", smart_case=True).is_match("FOOX")
|
|
386
|
+
for pattern in [r"foo\nbar", r"foo\x0Dbar"]:
|
|
387
|
+
with pytest.raises(ValueError, match="multiline=True"): compile(pattern)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_regex_runtime_errors(tmp_path):
|
|
391
|
+
pattern, text = r"(a|b|ab)*(?>c)", "ab"*26
|
|
392
|
+
p = tmp_path/text
|
|
393
|
+
p.mkdir()
|
|
394
|
+
(tmp_path/(text+".txt")).write_text(text)
|
|
395
|
+
for search in [lambda: compile(pattern).is_match(text), lambda: rgstr(pattern, text),
|
|
396
|
+
lambda: fd(tmp_path, pattern), lambda: fd(tmp_path, path_re=pattern), lambda: fd(tmp_path, skip_dir_re=pattern)]:
|
|
397
|
+
with pytest.raises(ValueError, match="backtrack"): search()
|
|
398
|
+
|
|
399
|
+
|
|
348
400
|
def write_nb(path, cells):
|
|
349
401
|
nb = dict(cells=cells, metadata={}, nbformat=4, nbformat_minor=5)
|
|
350
402
|
path.write_text(json.dumps(nb))
|
|
@@ -358,6 +410,15 @@ def _cell(cell_type, source, cid=None, outputs=None):
|
|
|
358
410
|
return c
|
|
359
411
|
|
|
360
412
|
|
|
413
|
+
def test_notebook_backreferences(tmp_path):
|
|
414
|
+
from rgapi import nbrg
|
|
415
|
+
p = tmp_path/"echo.ipynb"
|
|
416
|
+
write_nb(p, [_cell("code", "é é\nfoo\nfoo\n", cid="c1")])
|
|
417
|
+
assert nbrg(r"(\w+) \1", p)[0].matches[0].matches == [(0, 5)]
|
|
418
|
+
assert nbrg(r"(foo)\s+\1", p) == []
|
|
419
|
+
assert nbrg(r"(foo)\s+\1", p, multiline=True)[0].matches[0].matches == [(0, 7)]
|
|
420
|
+
|
|
421
|
+
|
|
361
422
|
def test_nbrg_source_only_with_cells(tmp_path):
|
|
362
423
|
from rgapi import nbrg
|
|
363
424
|
write_nb(tmp_path / "nb.ipynb", [
|
|
@@ -6,7 +6,7 @@ rgapi's in-process search. Import and fixture creation time are outside the time
|
|
|
6
6
|
sections.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import shutil, subprocess, tempfile, timeit
|
|
9
|
+
import argparse, shutil, subprocess, tempfile, timeit
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from rgapi import rg as rgapi_rg
|
|
@@ -48,49 +48,48 @@ def make_tiny_dir(root):
|
|
|
48
48
|
return d
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def rg_cli(root):
|
|
52
|
-
cmd = ["rg", "--color", "never", "--no-heading", "--line-number",
|
|
51
|
+
def rg_cli(root, pattern):
|
|
52
|
+
cmd = ["rg", "--color", "never", "--no-heading", "--line-number", pattern, str(root)]
|
|
53
53
|
return subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def rgapi(root): return rgapi_rg(
|
|
56
|
+
def rgapi(root, pattern): return rgapi_rg(pattern, str(root))
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def bench_one(name, func, root, number=1):
|
|
60
|
-
timer = timeit.Timer(lambda: func(root))
|
|
59
|
+
def bench_one(name, func, root, pattern, number=1):
|
|
60
|
+
timer = timeit.Timer(lambda: func(root, pattern))
|
|
61
61
|
times = timer.repeat(repeat=REPEATS, number=number)
|
|
62
62
|
best = min(times) / number
|
|
63
63
|
avg = sum(times) / len(times) / number
|
|
64
64
|
print(f"{name:24} best {best * 1000:8.2f} ms avg {avg * 1000:8.2f} ms")
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def bench(root):
|
|
67
|
+
def bench(root, pattern):
|
|
68
68
|
large = make_large_dir(root)
|
|
69
69
|
many_small = make_many_small_dir(root)
|
|
70
70
|
tiny = make_tiny_dir(root)
|
|
71
71
|
|
|
72
72
|
# Warm imports, dynamic libraries, and disk caches before timing.
|
|
73
|
-
rgapi(large)
|
|
74
|
-
rg_cli(large)
|
|
73
|
+
rgapi(large, pattern)
|
|
74
|
+
rg_cli(large, pattern)
|
|
75
75
|
|
|
76
76
|
print(f"fixture: {root}")
|
|
77
|
+
print(f"pattern: {pattern}")
|
|
77
78
|
print(f"large files: {LARGE_FILES} x {LARGE_FILE_BYTES:,} bytes")
|
|
78
79
|
print(f"many small files: {SMALL_FILES} x {SMALL_FILE_BYTES:,} bytes")
|
|
79
80
|
print(f"repeats: {REPEATS}\n")
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
bench_one("rg many-small", rg_cli, many_small)
|
|
85
|
-
bench_one("rgapi many-small", rgapi, many_small)
|
|
86
|
-
print()
|
|
87
|
-
bench_one("rg tiny x30", rg_cli, tiny, number=SMALL_REPEATS_PER_TIMING)
|
|
88
|
-
bench_one("rgapi tiny x30", rgapi, tiny, number=SMALL_REPEATS_PER_TIMING)
|
|
82
|
+
for label, fixture, number in [("large", large, 1), ("many-small", many_small, 1), ("tiny x30", tiny, SMALL_REPEATS_PER_TIMING)]:
|
|
83
|
+
for name, func in [("rg", rg_cli), ("rgapi", rgapi)]: bench_one(f"{name} {label}", func, fixture, pattern, number)
|
|
84
|
+
print()
|
|
89
85
|
|
|
90
86
|
|
|
91
87
|
def main():
|
|
88
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
89
|
+
parser.add_argument("--pattern", default=PATTERN, help="Regex to search; fixtures contain 'needle_rgapi_bench only here'")
|
|
90
|
+
args = parser.parse_args()
|
|
92
91
|
if shutil.which("rg") is None: raise SystemExit("rg executable not found")
|
|
93
|
-
with tempfile.TemporaryDirectory(prefix="rgapi-bench-") as d: bench(Path(d))
|
|
92
|
+
with tempfile.TemporaryDirectory(prefix="rgapi-bench-") as d: bench(Path(d), args.pattern)
|
|
94
93
|
|
|
95
94
|
|
|
96
95
|
if __name__ == "__main__": main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|