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.
Files changed (29) hide show
  1. {rgapi-0.2.1 → rgapi-0.2.2}/Cargo.lock +30 -18
  2. {rgapi-0.2.1 → rgapi-0.2.2}/Cargo.toml +3 -3
  3. {rgapi-0.2.1 → rgapi-0.2.2}/DEV.md +7 -2
  4. {rgapi-0.2.1 → rgapi-0.2.2}/PKG-INFO +21 -5
  5. {rgapi-0.2.1 → rgapi-0.2.2}/README.md +20 -4
  6. {rgapi-0.2.1 → rgapi-0.2.2}/py/Cargo.toml +0 -1
  7. {rgapi-0.2.1 → rgapi-0.2.2}/py/src/lib.rs +1 -1
  8. {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/__init__.py +10 -2
  9. {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/_cli.py +1 -1
  10. {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/nb.py +4 -2
  11. {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/skill.py +1 -1
  12. {rgapi-0.2.1 → rgapi-0.2.2}/src/block.rs +4 -3
  13. {rgapi-0.2.1 → rgapi-0.2.2}/src/lib.rs +3 -1
  14. rgapi-0.2.2/src/matcher.rs +142 -0
  15. {rgapi-0.2.1 → rgapi-0.2.2}/src/nb.rs +11 -22
  16. {rgapi-0.2.1 → rgapi-0.2.2}/src/search.rs +6 -28
  17. {rgapi-0.2.1 → rgapi-0.2.2}/src/walk.rs +36 -37
  18. {rgapi-0.2.1 → rgapi-0.2.2}/tests/test_async.py +10 -0
  19. {rgapi-0.2.1 → rgapi-0.2.2}/tests/test_rgapi.py +65 -4
  20. {rgapi-0.2.1 → rgapi-0.2.2}/tools/bench.py +17 -18
  21. {rgapi-0.2.1 → rgapi-0.2.2}/.github/workflows/ci.yml +0 -0
  22. {rgapi-0.2.1 → rgapi-0.2.2}/.gitignore +0 -0
  23. {rgapi-0.2.1 → rgapi-0.2.2}/LICENSE +0 -0
  24. {rgapi-0.2.1 → rgapi-0.2.2}/_config.yml +0 -0
  25. {rgapi-0.2.1 → rgapi-0.2.2}/_layouts/default.html +0 -0
  26. {rgapi-0.2.1 → rgapi-0.2.2}/py/build.rs +0 -0
  27. {rgapi-0.2.1 → rgapi-0.2.2}/pyproject.toml +0 -0
  28. {rgapi-0.2.1 → rgapi-0.2.2}/python/rgapi/block.py +0 -0
  29. {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.1"
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.1"
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.1"
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/walk.rs ignore/globset/grep-regex-backed path walking and filtering
9
- src/search.rs grep-regex/grep-searcher-backed searching
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.1
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 the same `ignore`, `grep-regex`, and `grep-searcher` crates that ripgrep uses for walking, regex matching, and file scanning. Walking and searching run in parallel by default. Most expensive work stays in Rust.
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 `nbrg` without `multiline=True` raise `ValueError` for a pattern that contains a literal `\n` or `\r`.
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 `rg`'s regex engine 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`.
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 stops the Rust workers within about one row. This includes timeouts from `asyncio.wait_for` or `asyncio.timeout`. It also includes task cancellation, such as starlette cancelling a disconnected client's request.
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 the same `ignore`, `grep-regex`, and `grep-searcher` crates that ripgrep uses for walking, regex matching, and file scanning. Walking and searching run in parallel by default. Most expensive work stays in Rust.
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 `nbrg` without `multiline=True` raise `ValueError` for a pattern that contains a literal `\n` or `\r`.
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 `rg`'s regex engine 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`.
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 stops the Rust workers within about one row. This includes timeouts from `asyncio.wait_for` or `asyncio.timeout`. It also includes task cancellation, such as starlette cancelling a disconnected client's request.
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
 
@@ -11,7 +11,6 @@ test = false
11
11
  [dependencies]
12
12
  rgapi = { path = ".." }
13
13
  grep-matcher.workspace = true
14
- grep-regex.workspace = true
15
14
  pyo3 = { version = ">=0.29", features = ["extension-module"] }
16
15
 
17
16
  [build-dependencies]
@@ -179,7 +179,7 @@ struct RegexPy {
179
179
  case_sensitive: Option<bool>,
180
180
  #[pyo3(get)]
181
181
  smart_case: bool,
182
- matcher: grep_regex::RegexMatcher,
182
+ matcher: rgapi::RegexMatcher,
183
183
  }
184
184
  #[pymethods]
185
185
  impl RegexPy {
@@ -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 for `search_text`, `search_path`, and direct matching."
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. With `multiline=True`, `^` and `$` still anchor lines within a cell.
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`, `grep-regex`, and `grep-searcher` crates: ignore files, hidden files, globs, extensions, and regex syntax behave as in ripgrep. 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.
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 grep_regex::RegexMatcher;
6
+ use crate::RegexMatcher;
7
7
  use ignore::{DirEntry, WalkState};
8
8
 
9
9
  use crate::RgApiError;
10
- use crate::search::{RgOptions, SearchLine, compile_regex, format_lnhash, search_text};
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 search::{MatchSpan, RgIter, RgOptions, SearchError, SearchKind, SearchLine, compile_regex, rg, rg_iter, search_path, search_text, spans_for};
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 grep_matcher::{Captures, Matcher};
7
- use grep_regex::RegexMatcher;
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, compile_regex, search_text};
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<RegexMatcher> = LazyLock::new(|| RegexMatcher::new(r"^#{1,6} \w").unwrap());
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.as_bytes()).unwrap_or(false) { return 0; }
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 = RegexMatcher::new(&format!("{sigil}`({body})`")).expect("sigil pattern compiles");
48
- let mut caps = re.new_captures().expect("captures allocate");
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 = compile_nb_regex(pattern, case_sensitive, smart_case, multiline)?;
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 = compile_nb_regex(&opts.pattern, opts.case_sensitive, opts.smart_case, opts.multiline)?;
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::{LineTerminator, Matcher};
9
- use grep_regex::{RegexMatcher, RegexMatcherBuilder};
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
- if !filters.path_allowed(Path::new(&rel)) { return WalkState::Continue; }
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
- matcher
248
- .find_iter(bytes, |m| { spans.push(MatchSpan { start: m.start(), end: m.end() }); true })
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(build_fd_re).transpose()?;
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 = if walk.min_depth.unwrap_or(0) > 0 { Vec::new() } else { links.iter().map(|r| relative_path(&base, r)).filter(|rel| find_matches(rel, &filters, pattern.as_ref())).map(Path::to_path_buf).collect() };
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).then(|| rel.to_path_buf()));
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).then(|| rel.to_path_buf()))
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| RegexMatcherBuilder::new().build(pattern).map_err(|e| RgApiError::new(e.to_string()))).transpose()
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()).unwrap_or(false) }
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
- def test_search_path_skips_binary_and_invalid_utf8(tmp_path):
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
- (tmp_path / "bad.txt").write_bytes(b"TODO\xff\n")
296
- matcher = compile("TODO")
296
+ matcher = compile(pattern)
297
297
  assert search_path(matcher, tmp_path / "bin.dat") == []
298
- assert search_path(matcher, tmp_path / "bad.txt") == []
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", PATTERN, str(root)]
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(PATTERN, str(root))
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
- bench_one("rg large", rg_cli, large)
82
- bench_one("rgapi large", rgapi, large)
83
- print()
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