rgapi 0.2.3__tar.gz → 0.2.4__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 (30) hide show
  1. {rgapi-0.2.3 → rgapi-0.2.4}/Cargo.lock +12 -12
  2. {rgapi-0.2.3 → rgapi-0.2.4}/Cargo.toml +1 -1
  3. {rgapi-0.2.3 → rgapi-0.2.4}/DEV.md +2 -0
  4. {rgapi-0.2.3 → rgapi-0.2.4}/PKG-INFO +4 -2
  5. {rgapi-0.2.3 → rgapi-0.2.4}/README.md +3 -1
  6. {rgapi-0.2.3 → rgapi-0.2.4}/py/src/lib.rs +29 -14
  7. {rgapi-0.2.3 → rgapi-0.2.4}/python/rgapi/skill.py +1 -1
  8. {rgapi-0.2.3 → rgapi-0.2.4}/src/block.rs +7 -6
  9. rgapi-0.2.4/src/lib.rs +32 -0
  10. {rgapi-0.2.3 → rgapi-0.2.4}/src/matcher.rs +6 -4
  11. {rgapi-0.2.3 → rgapi-0.2.4}/src/nb.rs +19 -18
  12. {rgapi-0.2.3 → rgapi-0.2.4}/src/search.rs +16 -14
  13. {rgapi-0.2.3 → rgapi-0.2.4}/src/walk.rs +29 -16
  14. {rgapi-0.2.3 → rgapi-0.2.4}/tests/test_rgapi.py +39 -10
  15. rgapi-0.2.3/src/lib.rs +0 -24
  16. {rgapi-0.2.3 → rgapi-0.2.4}/.github/workflows/ci.yml +0 -0
  17. {rgapi-0.2.3 → rgapi-0.2.4}/.gitignore +0 -0
  18. {rgapi-0.2.3 → rgapi-0.2.4}/LICENSE +0 -0
  19. {rgapi-0.2.3 → rgapi-0.2.4}/_config.yml +0 -0
  20. {rgapi-0.2.3 → rgapi-0.2.4}/_layouts/default.html +0 -0
  21. {rgapi-0.2.3 → rgapi-0.2.4}/py/Cargo.toml +0 -0
  22. {rgapi-0.2.3 → rgapi-0.2.4}/py/build.rs +0 -0
  23. {rgapi-0.2.3 → rgapi-0.2.4}/pyproject.toml +0 -0
  24. {rgapi-0.2.3 → rgapi-0.2.4}/python/rgapi/__init__.py +0 -0
  25. {rgapi-0.2.3 → rgapi-0.2.4}/python/rgapi/_cli.py +0 -0
  26. {rgapi-0.2.3 → rgapi-0.2.4}/python/rgapi/block.py +0 -0
  27. {rgapi-0.2.3 → rgapi-0.2.4}/python/rgapi/nb.py +0 -0
  28. {rgapi-0.2.3 → rgapi-0.2.4}/rustfmt.toml +0 -0
  29. {rgapi-0.2.3 → rgapi-0.2.4}/tests/test_async.py +0 -0
  30. {rgapi-0.2.3 → rgapi-0.2.4}/tools/bench.py +0 -0
@@ -50,9 +50,9 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
50
50
 
51
51
  [[package]]
52
52
  name = "crc32fast"
53
- version = "1.5.3"
53
+ version = "1.5.4"
54
54
  source = "registry+https://github.com/rust-lang/crates.io-index"
55
- checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
55
+ checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
56
56
  dependencies = [
57
57
  "cfg-if",
58
58
  ]
@@ -84,9 +84,9 @@ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
84
84
 
85
85
  [[package]]
86
86
  name = "encoding_rs"
87
- version = "0.8.42"
87
+ version = "0.8.44"
88
88
  source = "registry+https://github.com/rust-lang/crates.io-index"
89
- checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
89
+ checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5"
90
90
  dependencies = [
91
91
  "cfg-if",
92
92
  "core_detect",
@@ -118,9 +118,9 @@ dependencies = [
118
118
 
119
119
  [[package]]
120
120
  name = "globset"
121
- version = "0.4.20"
121
+ version = "0.4.21"
122
122
  source = "registry+https://github.com/rust-lang/crates.io-index"
123
- checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
123
+ checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d"
124
124
  dependencies = [
125
125
  "aho-corasick",
126
126
  "bstr",
@@ -161,9 +161,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
161
161
 
162
162
  [[package]]
163
163
  name = "ignore"
164
- version = "0.4.33"
164
+ version = "0.4.34"
165
165
  source = "registry+https://github.com/rust-lang/crates.io-index"
166
- checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
166
+ checksum = "484be5657551d7868d67587df967fb4fcbe9d266a761cc855aaa148fda0b3cc7"
167
167
  dependencies = [
168
168
  "crossbeam-deque",
169
169
  "globset",
@@ -189,9 +189,9 @@ checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
189
189
 
190
190
  [[package]]
191
191
  name = "log"
192
- version = "0.4.34"
192
+ version = "0.4.35"
193
193
  source = "registry+https://github.com/rust-lang/crates.io-index"
194
- checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
194
+ checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
195
195
 
196
196
  [[package]]
197
197
  name = "memchr"
@@ -320,7 +320,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
320
320
 
321
321
  [[package]]
322
322
  name = "rgapi"
323
- version = "0.2.3"
323
+ version = "0.2.4"
324
324
  dependencies = [
325
325
  "crc32fast",
326
326
  "fancy-regex",
@@ -335,7 +335,7 @@ dependencies = [
335
335
 
336
336
  [[package]]
337
337
  name = "rgapi-py"
338
- version = "0.2.3"
338
+ version = "0.2.4"
339
339
  dependencies = [
340
340
  "grep-matcher",
341
341
  "pyo3",
@@ -2,7 +2,7 @@
2
2
  members = ["py"]
3
3
 
4
4
  [workspace.package]
5
- version = "0.2.3"
5
+ version = "0.2.4"
6
6
  edition = "2024"
7
7
 
8
8
  [workspace.dependencies]
@@ -43,6 +43,8 @@ All user-supplied regexes use the shared `RegexMatcher` in `src/matcher.rs`, bac
43
43
 
44
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, inspect neighbouring lines or need the backtracking engine are evaluated on individual lines, so that engine's step budget covers one line, not a whole buffer. 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
45
 
46
+ A walk error, a file the walk can't read, or a notebook `nbrg` can't parse is a skipped error (`RgApiError::is_skipped`). `send_error` sends a skipped error without ending the walk. Any other error ends it. The Python bindings issue a `RuntimeWarning` for each skipped error and raise `ValueError` for any other (`skip_or_raise`, and `worker_error` on async worker threads). Rust's collecting functions, such as `find`, `rg`, `nb_search` and `StreamIter::collect_timeout`, return the first error of either kind. `entry_err` drops a permission error silently at the `max_depth` cap, where the walker opens a directory it never descends into.
47
+
46
48
  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.
47
49
 
48
50
  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
3
+ Version: 0.2.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Requires-Dist: fastcore>=2.2.29
@@ -85,6 +85,8 @@ Discovery uses the `ignore` crate with ripgrep's default filters. It reads `.git
85
85
 
86
86
  Hidden files are skipped unless `hidden=True`. Discovery returns symlinks themselves, including explicitly named roots and dangling links. Set `follow_links=True` to traverse their targets. Paths retain the symlink spelling. Use `same_file_system=True` to avoid crossing filesystem boundaries.
87
87
 
88
+ A walk skips each directory or file that it can't read, such as one without read permission, and issues a `RuntimeWarning` that names it. The rest of the walk continues, as ripgrep's does.
89
+
88
90
  Traversal runs in parallel without guaranteed result order. Use `sorted(...)` when order matters.
89
91
 
90
92
  `fd` takes `pattern` first and `root` second, as `rg` does. With no pattern, it returns every file under `root`. `pattern` is a smart-case regex matched against each basename. Lowercase patterns match case-insensitively. A pattern containing uppercase letters is case-sensitive. Use `path_re` to match the slash-separated relative path instead.
@@ -235,7 +237,7 @@ A matching cell's display starts at its first matched line. Earlier lines are re
235
237
  - `count=True` returns the number of matching cells.
236
238
  - `timeout_ms` applies a deadline with the same `stop_reason` values as `rg`.
237
239
 
238
- The parser reads only each cell's `id`, `cell_type`, and `source`. It skips outputs and metadata without loading embedded images or plots. `search_nb(pattern, path, ...)` searches a single notebook file in the same way.
240
+ The parser reads only each cell's `id`, `cell_type`, and `source`. It skips outputs and metadata without loading embedded images or plots. `search_nb(pattern, path, ...)` searches a single notebook file in the same way. `nbrg` skips a `.ipynb` file that isn't valid JSON and issues a `RuntimeWarning` naming the file and the parse error. `search_nb` raises `ValueError` for such a file.
239
241
 
240
242
  `rgapi-nbrg` exposes notebook search without requiring a Python kernel:
241
243
 
@@ -64,6 +64,8 @@ Discovery uses the `ignore` crate with ripgrep's default filters. It reads `.git
64
64
 
65
65
  Hidden files are skipped unless `hidden=True`. Discovery returns symlinks themselves, including explicitly named roots and dangling links. Set `follow_links=True` to traverse their targets. Paths retain the symlink spelling. Use `same_file_system=True` to avoid crossing filesystem boundaries.
66
66
 
67
+ A walk skips each directory or file that it can't read, such as one without read permission, and issues a `RuntimeWarning` that names it. The rest of the walk continues, as ripgrep's does.
68
+
67
69
  Traversal runs in parallel without guaranteed result order. Use `sorted(...)` when order matters.
68
70
 
69
71
  `fd` takes `pattern` first and `root` second, as `rg` does. With no pattern, it returns every file under `root`. `pattern` is a smart-case regex matched against each basename. Lowercase patterns match case-insensitively. A pattern containing uppercase letters is case-sensitive. Use `path_re` to match the slash-separated relative path instead.
@@ -214,7 +216,7 @@ A matching cell's display starts at its first matched line. Earlier lines are re
214
216
  - `count=True` returns the number of matching cells.
215
217
  - `timeout_ms` applies a deadline with the same `stop_reason` values as `rg`.
216
218
 
217
- The parser reads only each cell's `id`, `cell_type`, and `source`. It skips outputs and metadata without loading embedded images or plots. `search_nb(pattern, path, ...)` searches a single notebook file in the same way.
219
+ The parser reads only each cell's `id`, `cell_type`, and `source`. It skips outputs and metadata without loading embedded images or plots. `search_nb(pattern, path, ...)` searches a single notebook file in the same way. `nbrg` skips a `.ipynb` file that isn't valid JSON and issues a `RuntimeWarning` naming the file and the parse error. `search_nb` raises `ValueError` for such a file.
218
220
 
219
221
  `rgapi-nbrg` exposes notebook search without requiring a Python kernel:
220
222
 
@@ -3,14 +3,15 @@ use std::path::PathBuf;
3
3
  use std::sync::Arc;
4
4
  use std::sync::atomic::{AtomicBool, Ordering};
5
5
  use std::sync::mpsc::RecvTimeoutError;
6
+ use std::thread;
6
7
  use std::time::{Duration, Instant};
7
8
 
8
- use pyo3::exceptions::PyValueError;
9
+ use pyo3::exceptions::{PyRuntimeWarning, PyValueError};
9
10
  use pyo3::prelude::*;
10
11
  use pyo3::types::{PyAny, PyDict};
11
12
 
12
13
  use rgapi::{
13
- FindIter, FindOptions, NbCell, NbIter, NbOptions, RgIter, RgOptions, SearchBlock, SearchLine, StreamIter, WalkOptions as WalkOptionsCore,
14
+ FindIter, FindOptions, NbCell, NbIter, NbOptions, RgApiError, RgIter, RgOptions, SearchBlock, SearchLine, StreamIter, WalkOptions as WalkOptionsCore,
14
15
  block_iter as block_iter_core, compile_regex, find, find_iter as find_iter_core, find_iter_with, nb_iter as nb_iter_core, nb_search_file, resolve_roots,
15
16
  rg_iter as rg_iter_core, search_path as search_path_core, search_text as search_text_core, spans_for,
16
17
  };
@@ -105,14 +106,26 @@ fn check_signals_or_cancel<T>(py: Python<'_>, iter: &StreamIter<T>) -> PyResult<
105
106
  Ok(())
106
107
  }
107
108
 
109
+ /// Warn about a path that a walk skipped because it couldn't read it, as ripgrep reports such a path. Any other error raises `ValueError`.
110
+ fn skip_or_raise(py: Python<'_>, err: RgApiError) -> PyResult<()> {
111
+ if !err.is_skipped() { return Err(PyValueError::new_err(err.to_string())); }
112
+ PyErr::warn(py, py.get_type::<PyRuntimeWarning>().as_any(), &std::ffi::CString::new(err.to_string())?, 1)
113
+ }
114
+
115
+ /// `skip_or_raise` on a worker thread. Gives the message of an error that ends the stream, or `None` when the walk only skipped a path.
116
+ fn worker_error(err: RgApiError) -> Option<String> {
117
+ if !err.is_skipped() { return Some(err.to_string()); }
118
+ Python::attach(|py| skip_or_raise(py, err).err().map(|e| e.to_string()))
119
+ }
120
+
108
121
  fn next_stream_py<T: Send>(py: Python<'_>, iter: &mut StreamIter<T>) -> PyResult<Option<T>> {
109
122
  loop {
110
123
  check_signals_or_cancel(py, iter)?;
111
124
  let res = py.detach(|| iter.next_timeout(Duration::from_millis(50)));
112
125
  check_signals_or_cancel(py, iter)?;
113
126
  match res {
114
- Ok(Ok(line)) => return Ok(Some(line)), Ok(Err(err)) => return Err(PyValueError::new_err(err.to_string())),
115
- Err(RecvTimeoutError::Disconnected) => return Ok(None), Err(RecvTimeoutError::Timeout) => continue
127
+ Ok(Ok(line)) => return Ok(Some(line)), Ok(Err(err)) => skip_or_raise(py, err)?, Err(RecvTimeoutError::Disconnected) => return Ok(None),
128
+ Err(RecvTimeoutError::Timeout) => continue
116
129
  }
117
130
  }
118
131
  }
@@ -131,8 +144,8 @@ fn collect_stream_py<T: Send, P>(py: Python<'_>, mut iter: StreamIter<T>, conv:
131
144
  let next = py.detach(|| iter.next_timeout(wait));
132
145
  check_signals_or_cancel(py, &iter)?;
133
146
  match next {
134
- Ok(Ok(line)) => res.push(conv(line)), Ok(Err(err)) => return Err(PyValueError::new_err(err.to_string())),
135
- Err(RecvTimeoutError::Disconnected) => return Ok((res, false)), Err(RecvTimeoutError::Timeout) => {}
147
+ Ok(Ok(line)) => res.push(conv(line)), Ok(Err(err)) => skip_or_raise(py, err)?, Err(RecvTimeoutError::Disconnected) => return Ok((res, false)),
148
+ Err(RecvTimeoutError::Timeout) => {}
136
149
  }
137
150
  }
138
151
  }
@@ -278,9 +291,11 @@ fn drain_stream<T>(iter: &mut StreamIter<T>, flag: &Arc<AtomicBool>, deadline: O
278
291
  match iter.next_timeout(wait) {
279
292
  Ok(Ok(line)) => rows.push(line),
280
293
  Ok(Err(e)) => {
281
- err = Some(e.to_string());
282
- iter.cancel();
283
- break;
294
+ if let Some(e) = worker_error(e) {
295
+ err = Some(e);
296
+ iter.cancel();
297
+ break;
298
+ }
284
299
  }
285
300
  Err(RecvTimeoutError::Disconnected) => break,
286
301
  Err(RecvTimeoutError::Timeout) => {}
@@ -293,7 +308,7 @@ fn stream_async<T, C>(cb: Py<PyAny>, mut iter: StreamIter<T>, deadline: Option<I
293
308
  C: Fn(Python<'_>, Vec<T>, bool) -> PyResult<Py<PyAny>> + Send + 'static {
294
309
  let cancel = iter.cancel_flag();
295
310
  let flag = cancel.clone();
296
- std::thread::spawn(move || {
311
+ thread::spawn(move || {
297
312
  let (rows, timed_out, err) = drain_stream(&mut iter, &flag, deadline);
298
313
  drop(iter);
299
314
  Python::attach(|py| {
@@ -312,18 +327,18 @@ fn stream_iter_async<T, C>(cb: Py<PyAny>, mut iter: StreamIter<T>, batch_max: us
312
327
  C: Fn(Python<'_>, Vec<T>) -> PyResult<Py<PyAny>> + Send + 'static {
313
328
  let cancel = iter.cancel_flag();
314
329
  let flag = cancel.clone();
315
- std::thread::spawn(move || {
330
+ thread::spawn(move || {
316
331
  let mut err: Option<String> = None;
317
332
  loop {
318
333
  if flag.load(Ordering::Relaxed) { break; }
319
334
  let first = match iter.next_timeout(Duration::from_millis(50)) {
320
- Ok(Ok(line)) => line, Ok(Err(e)) => { err = Some(e.to_string()); break; } Err(RecvTimeoutError::Disconnected) => break,
321
- Err(RecvTimeoutError::Timeout) => continue
335
+ Ok(Ok(line)) => line, Ok(Err(e)) => match worker_error(e) { Some(e) => { err = Some(e); break; } None => continue },
336
+ Err(RecvTimeoutError::Disconnected) => break, Err(RecvTimeoutError::Timeout) => continue
322
337
  };
323
338
  let mut batch = vec![first];
324
339
  while batch.len() < batch_max {
325
340
  match iter.next_timeout(Duration::ZERO) {
326
- Ok(Ok(line)) => batch.push(line), Ok(Err(e)) => { err = Some(e.to_string()); break; } Err(_) => break
341
+ Ok(Ok(line)) => batch.push(line), Ok(Err(e)) => { if let Some(e) = worker_error(e) { err = Some(e); break; } } Err(_) => break
327
342
  }
328
343
  }
329
344
  let failed = Python::attach(|py| match conv(py, batch) { Ok(res) => cb.call1(py, (res, None::<String>)).is_err(), Err(_) => true });
@@ -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` 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.
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. A walk skips each directory or file that it can't read, and each notebook that `nbrg` can't parse. Each skip issues a `RuntimeWarning` that names the path. 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
 
@@ -1,15 +1,16 @@
1
1
  use std::collections::{BTreeMap, HashMap};
2
+ use std::fs;
2
3
  use std::path::Path;
4
+ use std::str;
3
5
  use std::sync::Arc;
4
6
  use std::sync::atomic::Ordering;
5
7
 
6
8
  use crate::RegexMatcher;
7
9
  use ignore::{DirEntry, WalkState};
8
10
 
9
- use crate::RgApiError;
10
- use crate::compile_regex;
11
11
  use crate::search::{RgOptions, SearchLine, format_lnhash, search_text};
12
- use crate::walk::{PathFilters, StreamIter, entry_err, rel_path, resolve_roots, spawn_walk};
12
+ use crate::walk::{PathFilters, StreamIter, entry_err, rel_path, resolve_roots, send_error, spawn_walk};
13
+ use crate::{RgApiError, compile_regex};
13
14
 
14
15
  /// One blank-line-delimited block containing a match, or context for one.
15
16
  pub struct SearchBlock {
@@ -48,7 +49,7 @@ fn split_blocks(text: &str) -> Vec<BlockInfo> {
48
49
 
49
50
  fn process_file(disp: String, bytes: &[u8], matcher: &RegexMatcher, before_context: usize, after_context: usize) -> Result<Vec<SearchBlock>, RgApiError> {
50
51
  if bytes.contains(&0) { return Ok(Vec::new()); }
51
- let text = match std::str::from_utf8(bytes) { Ok(text) => text, Err(_) => return Ok(Vec::new()) };
52
+ let text = match str::from_utf8(bytes) { Ok(text) => text, Err(_) => return Ok(Vec::new()) };
52
53
  let blocks = split_blocks(text);
53
54
  let hits = search_text(disp.clone(), text, matcher.clone(), 0, 0, false)?;
54
55
  if hits.is_empty() { return Ok(Vec::new()); }
@@ -83,7 +84,7 @@ fn block_entry(
83
84
  if !ft.is_file() { return Ok(Vec::new()); }
84
85
  let rel = rel_path(base, path);
85
86
  if !filters.path_allowed(Path::new(&rel))? { return Ok(Vec::new()); }
86
- let bytes = match std::fs::read(path) { Ok(bytes) => bytes, Err(_) => return Ok(Vec::new()) };
87
+ let bytes = fs::read(path).map_err(|err| RgApiError::unreadable(path, err))?;
87
88
  process_file(rel, &bytes, matcher, before_context, after_context)
88
89
  }
89
90
 
@@ -100,7 +101,7 @@ pub fn block_iter(opts: &RgOptions) -> Result<BlockIter, RgApiError> {
100
101
  for block in blocks { if cancel.load(Ordering::Relaxed) || tx.send(Ok(block)).is_err() { return WalkState::Quit; } }
101
102
  WalkState::Continue
102
103
  }
103
- Err(err) => { let _ = tx.send(Err(err)); WalkState::Quit }
104
+ Err(err) => send_error(tx, err),
104
105
  }
105
106
  }))
106
107
  }
rgapi-0.2.4/src/lib.rs ADDED
@@ -0,0 +1,32 @@
1
+ //! rgapi - Python-friendly wrappers around ripgrep's walking and searching crates.
2
+
3
+ use std::fmt::Display;
4
+ mod block;
5
+ mod matcher;
6
+ mod nb;
7
+ mod search;
8
+ mod walk;
9
+
10
+ pub use block::{BlockIter, SearchBlock, block_iter};
11
+ pub use matcher::{RegexMatcher, compile_regex};
12
+ pub use nb::{CellRefs, NbCell, NbIter, NbOptions, ancestor_indices, cell_refs, heading_level, nb_iter, nb_search, nb_search_file, section_range};
13
+ pub use search::{MatchSpan, RgIter, RgOptions, SearchError, SearchKind, SearchLine, rg, rg_iter, search_path, search_text, spans_for};
14
+ pub use walk::{FindIter, FindOptions, StreamIter, WalkOptions, find, find_iter, find_iter_with, resolve_roots};
15
+
16
+ /// An rgapi failure. `is_skipped` marks an error naming one path that a walk couldn't read, such as a directory without read permission.
17
+ /// The walk sends that error and continues, as ripgrep does. Rust's collecting functions, such as `find`, return the first error of either kind.
18
+ #[derive(Debug, Clone)]
19
+ pub struct RgApiError { msg: String, skipped: bool }
20
+
21
+ impl RgApiError {
22
+ pub(crate) fn new(msg: impl Into<String>) -> Self { Self { msg: msg.into(), skipped: false } }
23
+ pub(crate) fn skipped(msg: impl Display) -> Self { Self { msg: msg.to_string(), skipped: true } }
24
+ pub(crate) fn unreadable(path: &std::path::Path, err: impl Display) -> Self { Self::skipped(format!("{}: {err}", path.display())) }
25
+ pub fn is_skipped(&self) -> bool { self.skipped }
26
+ }
27
+
28
+ impl Display for RgApiError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.msg) } }
29
+
30
+ impl std::error::Error for RgApiError {}
31
+
32
+ impl From<std::io::Error> for RgApiError { fn from(err: std::io::Error) -> Self { Self::new(err.to_string()) } }
@@ -1,6 +1,8 @@
1
+ use ast::ClassSetItem;
1
2
  use fancy_regex::{Assertion, Expr, Regex, RegexBuilder};
2
3
  use grep_matcher::{LineTerminator, Match, Matcher, NoCaptures};
3
4
  use regex_syntax::{ast::{self, Ast, Visitor}, hir::{Class, Hir, HirKind}};
5
+ use std::fmt::Display;
4
6
 
5
7
  use crate::RgApiError;
6
8
 
@@ -68,7 +70,7 @@ pub fn compile_regex(pattern: &str, case_sensitive: Option<bool>, smart_case: bo
68
70
  RegexMatcher::build(pattern, case_sensitive, smart_case, !multiline, true)
69
71
  }
70
72
 
71
- fn regex_error(error: impl std::fmt::Display) -> RgApiError { RgApiError::new(error.to_string()) }
73
+ fn regex_error(error: impl Display) -> RgApiError { RgApiError::new(error.to_string()) }
72
74
 
73
75
  impl Matcher for RegexMatcher {
74
76
  type Captures = NoCaptures;
@@ -160,10 +162,10 @@ impl Visitor for ClassLiterals {
160
162
  type Err = std::convert::Infallible;
161
163
  fn finish(self) -> Result<Self, Self::Err> { Ok(self) }
162
164
  fn visit_pre(&mut self, ast: &Ast) -> Result<(), Self::Err> { if let Ast::Literal(lit) = ast { self.add(lit.c); } Ok(()) }
163
- fn visit_class_set_item_pre(&mut self, item: &ast::ClassSetItem) -> Result<(), Self::Err> {
165
+ fn visit_class_set_item_pre(&mut self, item: &ClassSetItem) -> Result<(), Self::Err> {
164
166
  match item {
165
- ast::ClassSetItem::Literal(lit) => self.add(lit.c),
166
- ast::ClassSetItem::Range(r) => {
167
+ ClassSetItem::Literal(lit) => self.add(lit.c),
168
+ ClassSetItem::Range(r) => {
167
169
  self.add(r.start.c);
168
170
  self.add(r.end.c);
169
171
  }
@@ -1,4 +1,6 @@
1
+ use serde_json::Value;
1
2
  use std::collections::{BTreeMap, HashMap};
3
+ use std::fs;
2
4
  use std::path::Path;
3
5
  use std::sync::atomic::Ordering;
4
6
  use std::sync::{Arc, LazyLock};
@@ -8,10 +10,9 @@ use fancy_regex::Regex;
8
10
  use ignore::{DirEntry, WalkState};
9
11
  use serde::Deserialize;
10
12
 
11
- use crate::RgApiError;
12
- use crate::compile_regex;
13
13
  use crate::search::{SearchLine, search_text};
14
- use crate::walk::{PathFilters, StreamIter, WalkOptions, entry_err, rel_path, resolve_roots, spawn_walk};
14
+ use crate::walk::{PathFilters, StreamIter, WalkOptions, entry_err, rel_path, resolve_roots, send_error, spawn_walk};
15
+ use crate::{RgApiError, compile_regex};
15
16
 
16
17
  static HEADING_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^#{1,6} \w").unwrap());
17
18
 
@@ -56,8 +57,8 @@ fn tool_names(text: &str) -> Vec<String> {
56
57
  }
57
58
 
58
59
  /// nbformat multiline text, a string or a list of strings, as one string.
59
- fn nb_text(v: &serde_json::Value) -> String {
60
- match v { serde_json::Value::String(s) => s.clone(), serde_json::Value::Array(a) => a.iter().filter_map(|o| o.as_str()).collect(), _ => String::new() }
60
+ fn nb_text(v: &Value) -> String {
61
+ match v { Value::String(s) => s.clone(), Value::Array(a) => a.iter().filter_map(|o| o.as_str()).collect(), _ => String::new() }
61
62
  }
62
63
 
63
64
  /// The sigil references in one notebook cell, in order of appearance.
@@ -75,7 +76,7 @@ pub struct CellRefs {
75
76
  /// `vars` and `cmds` come from a prompt cell's source. `tools` comes from the source of a prompt or Markdown cell.
76
77
  /// For every other cell, `tools` comes from the `text/markdown` data of its `display_data` and `execute_result` outputs.
77
78
  /// A prompt cell's outputs are never read.
78
- pub fn cell_refs(cell: &serde_json::Value) -> CellRefs {
79
+ pub fn cell_refs(cell: &Value) -> CellRefs {
79
80
  let src = nb_text(&cell["source"]);
80
81
  let prompt = cell["metadata"]["solveit_ai"] == true;
81
82
  let mut res = CellRefs::default();
@@ -109,12 +110,10 @@ pub struct NbCell {
109
110
  struct RawNb { #[serde(default)] cells: Vec<RawCell> }
110
111
 
111
112
  #[derive(Deserialize)]
112
- struct RawCell { #[serde(default)] id: Option<serde_json::Value>, #[serde(default)] cell_type: Option<String>, #[serde(default)] source: Source }
113
+ struct RawCell { #[serde(default)] id: Option<Value>, #[serde(default)] cell_type: Option<String>, #[serde(default)] source: Source }
113
114
 
114
115
  impl RawCell {
115
- fn id_string(&self, index: usize) -> String {
116
- match &self.id { Some(serde_json::Value::String(s)) => s.clone(), Some(v) => v.to_string(), None => index.to_string() }
117
- }
116
+ fn id_string(&self, index: usize) -> String { match &self.id { Some(Value::String(s)) => s.clone(), Some(v) => v.to_string(), None => index.to_string() } }
118
117
  }
119
118
 
120
119
  // nbformat `source` is a list of lines or a single string (or absent/null).
@@ -126,9 +125,13 @@ impl Source {
126
125
  fn text(&self) -> String { match self { Source::Lines(v) => v.concat(), Source::Text(s) => s.clone(), Source::Empty => String::new() } }
127
126
  }
128
127
 
129
- fn process_file(disp: String, bytes: &[u8], matcher: &RegexMatcher, cell_context: usize, multiline: bool) -> Result<Vec<NbCell>, RgApiError> {
130
- // Not a parseable notebook (bad JSON, or JSON that isn't a notebook): skip, like a binary file.
131
- let nb: RawNb = match serde_json::from_slice(bytes) { Ok(nb) => nb, Err(_) => return Ok(Vec::new()) };
128
+ /// The notebook at `path`. A file that can't be read, or parsed as a notebook, gives a skipped error naming `path`.
129
+ fn notebook(path: &Path) -> Result<RawNb, RgApiError> {
130
+ let bytes = fs::read(path).map_err(|err| RgApiError::unreadable(path, err))?;
131
+ serde_json::from_slice(&bytes).map_err(|err| RgApiError::unreadable(path, err))
132
+ }
133
+
134
+ fn process_file(disp: String, nb: RawNb, matcher: &RegexMatcher, cell_context: usize, multiline: bool) -> Result<Vec<NbCell>, RgApiError> {
132
135
  let n = nb.cells.len();
133
136
  let mut info = Vec::with_capacity(n);
134
137
  let mut matched: Vec<(usize, Vec<SearchLine>)> = Vec::new();
@@ -158,8 +161,7 @@ pub fn nb_search_file(
158
161
  path: &Path, display_path: String, pattern: &str, case_sensitive: Option<bool>, smart_case: bool, cell_context: usize, multiline: bool
159
162
  ) -> Result<Vec<NbCell>, RgApiError> {
160
163
  let matcher = compile_regex(pattern, case_sensitive, smart_case, multiline)?;
161
- let bytes = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(Vec::new()) };
162
- process_file(display_path, &bytes, &matcher, cell_context, multiline)
164
+ process_file(display_path, notebook(path)?, &matcher, cell_context, multiline)
163
165
  }
164
166
 
165
167
  fn nb_entry(
@@ -171,8 +173,7 @@ fn nb_entry(
171
173
  if !ft.is_file() { return Ok(Vec::new()); }
172
174
  let rel = rel_path(base, path);
173
175
  if !filters.path_allowed(Path::new(&rel))? { return Ok(Vec::new()); }
174
- let bytes = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(Vec::new()) };
175
- process_file(rel, &bytes, matcher, cell_context, multiline)
176
+ process_file(rel, notebook(path)?, matcher, cell_context, multiline)
176
177
  }
177
178
 
178
179
  pub type NbIter = StreamIter<NbCell>;
@@ -188,7 +189,7 @@ pub fn nb_iter(opts: &NbOptions) -> Result<NbIter, RgApiError> {
188
189
  for cell in cells { if cancel.load(Ordering::Relaxed) || tx.send(Ok(cell)).is_err() { return WalkState::Quit; } }
189
190
  WalkState::Continue
190
191
  }
191
- Err(err) => { let _ = tx.send(Err(err)); WalkState::Quit }
192
+ Err(err) => send_error(tx, err),
192
193
  }
193
194
  }))
194
195
  }
@@ -1,4 +1,7 @@
1
+ use grep_searcher::Searcher;
2
+ use std::fmt::Display;
1
3
  use std::path::Path;
4
+ use std::str;
2
5
  use std::sync::{Arc, atomic::{AtomicBool, Ordering}, mpsc::SyncSender};
3
6
 
4
7
  use crate::matcher::RegexMatcher;
@@ -6,7 +9,7 @@ use grep_matcher::LineTerminator;
6
9
  use grep_searcher::{BinaryDetection, SearcherBuilder, Sink, SinkContext, SinkContextKind, SinkError, SinkMatch};
7
10
  use ignore::{DirEntry, WalkState};
8
11
 
9
- use crate::walk::{PathFilters, StreamIter, WalkOptions, entry_err, rel_path, resolve_roots, spawn_walk};
12
+ use crate::walk::{PathFilters, StreamIter, WalkOptions, entry_err, rel_path, resolve_roots, send_error, spawn_walk};
10
13
  use crate::{RgApiError, compile_regex};
11
14
 
12
15
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -46,27 +49,25 @@ fn search_entry(
46
49
  max_depth: Option<usize>, tx: &SyncSender<Result<SearchLine, RgApiError>>, cancel: &Arc<AtomicBool>) -> WalkState {
47
50
  if is_cancelled(cancel) { return WalkState::Quit; }
48
51
  let dent = match entry {
49
- Ok(dent) => dent, Err(err) => { return match entry_err(err, max_depth) { Some(e) => send_search_error(tx, e), None => WalkState::Continue }; }
52
+ Ok(dent) => dent, Err(err) => { return match entry_err(err, max_depth) { Some(e) => send_error(tx, e), None => WalkState::Continue }; }
50
53
  };
51
54
  let path = dent.path();
52
55
  let Some(ft) = dent.file_type() else { return WalkState::Continue; };
53
56
  if !ft.is_file() { return WalkState::Continue; }
54
57
  let rel = rel_path(base, path);
55
- match filters.path_allowed(Path::new(&rel)) { Ok(true) => {} Ok(false) => return WalkState::Continue, Err(err) => return send_search_error(tx, err) }
58
+ match filters.path_allowed(Path::new(&rel)) { Ok(true) => {} Ok(false) => return WalkState::Continue, Err(err) => return send_error(tx, err) }
56
59
  match search_path_cancelable(path, rel, matcher.clone(), before_context, after_context, Some(cancel.clone())) {
57
60
  Ok(lines) => {
58
61
  if is_cancelled(cancel) { return WalkState::Quit; }
59
62
  for line in lines { if is_cancelled(cancel) || tx.send(Ok(line)).is_err() { return WalkState::Quit; } }
60
63
  WalkState::Continue
61
64
  }
62
- Err(err) => send_search_error(tx, err),
65
+ Err(err) => send_error(tx, err),
63
66
  }
64
67
  }
65
68
 
66
69
  fn is_cancelled(cancel: &Arc<AtomicBool>) -> bool { cancel.load(Ordering::Relaxed) }
67
70
 
68
- fn send_search_error(tx: &SyncSender<Result<SearchLine, RgApiError>>, err: RgApiError) -> WalkState { let _ = tx.send(Err(err)); WalkState::Quit }
69
-
70
71
  pub(crate) fn format_lnhash(lineno: u64, line: &str) -> String {
71
72
  const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
72
73
  let hash = crc32fast::hash(line.as_bytes()) as usize;
@@ -88,7 +89,8 @@ fn search_path_cancelable(
88
89
  let mut out = Vec::new();
89
90
  let search_matcher = matcher.clone();
90
91
  let sink = CollectSink { path: display_path, matcher, lines: &mut out, cancel };
91
- match searcher.search_path(search_matcher, path, sink) {
92
+ let file = std::fs::File::open(path).map_err(|err| RgApiError::unreadable(path, err))?;
93
+ match searcher.search_file(search_matcher, &file, sink) {
92
94
  Ok(()) => Ok(out), Err(SearchError::InvalidUtf8) => Ok(Vec::new()), Err(err) => Err(RgApiError::new(err.to_string()))
93
95
  }
94
96
  }
@@ -114,7 +116,7 @@ impl CollectSink<'_> { fn cancelled(&self) -> bool { match &self.cancel { Some(c
114
116
  impl Sink for CollectSink<'_> {
115
117
  type Error = SearchError;
116
118
 
117
- fn matched(&mut self, _searcher: &grep_searcher::Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
119
+ fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
118
120
  if self.cancelled() { return Ok(false); }
119
121
  let line = bytes_to_line(mat.bytes())?;
120
122
  let line_number = mat.line_number().unwrap_or(0);
@@ -124,7 +126,7 @@ impl Sink for CollectSink<'_> {
124
126
  Ok(!self.cancelled())
125
127
  }
126
128
 
127
- fn context(&mut self, _searcher: &grep_searcher::Searcher, ctx: &SinkContext<'_>) -> Result<bool, Self::Error> {
129
+ fn context(&mut self, _searcher: &Searcher, ctx: &SinkContext<'_>) -> Result<bool, Self::Error> {
128
130
  if self.cancelled() { return Ok(false); }
129
131
  let kind = match ctx.kind() {
130
132
  SinkContextKind::Before => SearchKind::Before, SinkContextKind::After => SearchKind::After, SinkContextKind::Other => SearchKind::Context
@@ -134,27 +136,27 @@ impl Sink for CollectSink<'_> {
134
136
  self.lines.push(SearchLine { kind, path: self.path.clone(), line_number, lnhash: format_lnhash(line_number, &line), line, matches: Vec::new() });
135
137
  Ok(!self.cancelled())
136
138
  }
137
- fn binary_data(&mut self, _searcher: &grep_searcher::Searcher, _binary_byte_offset: u64) -> Result<bool, Self::Error> { self.lines.clear(); Ok(false) }
139
+ fn binary_data(&mut self, _searcher: &Searcher, _binary_byte_offset: u64) -> Result<bool, Self::Error> { self.lines.clear(); Ok(false) }
138
140
  }
139
141
 
140
142
  #[derive(Debug)]
141
143
  /// A search failure, including invalid UTF-8 in matched text.
142
144
  pub enum SearchError { Message(String), InvalidUtf8 }
143
- impl std::fmt::Display for SearchError {
145
+ impl Display for SearchError {
144
146
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145
147
  match self { Self::Message(msg) => write!(f, "{msg}"), Self::InvalidUtf8 => write!(f, "invalid utf-8") }
146
148
  }
147
149
  }
148
150
  impl std::error::Error for SearchError {}
149
- impl SinkError for SearchError { fn error_message<T: std::fmt::Display>(message: T) -> Self { Self::Message(message.to_string()) } }
151
+ impl SinkError for SearchError { fn error_message<T: Display>(message: T) -> Self { Self::Message(message.to_string()) } }
150
152
  fn bytes_to_line(bytes: &[u8]) -> Result<String, SearchError> {
151
- let s = std::str::from_utf8(bytes).map_err(|_| SearchError::InvalidUtf8)?;
153
+ let s = str::from_utf8(bytes).map_err(|_| SearchError::InvalidUtf8)?;
152
154
  Ok(s.trim_end_matches(['\r', '\n']).to_string())
153
155
  }
154
156
  /// Return byte offsets for every match in a line.
155
157
  pub fn spans_for(matcher: &RegexMatcher, bytes: &[u8]) -> Result<Vec<MatchSpan>, SearchError> {
156
158
  let mut spans = Vec::new();
157
- let text = std::str::from_utf8(bytes).map_err(|_| SearchError::InvalidUtf8)?;
159
+ let text = str::from_utf8(bytes).map_err(|_| SearchError::InvalidUtf8)?;
158
160
  matcher.for_each_match(text, |start, end| spans.push(MatchSpan { start, end })).map_err(|e| SearchError::Message(e.to_string()))?;
159
161
  Ok(spans)
160
162
  }
@@ -1,8 +1,12 @@
1
+ use mpsc::{RecvTimeoutError, SyncSender};
1
2
  use std::collections::HashSet;
2
3
  use std::panic::{AssertUnwindSafe, catch_unwind};
3
4
  use std::path::{Path, PathBuf};
4
5
  use std::sync::atomic::{AtomicBool, Ordering};
5
6
  use std::sync::{Arc, Mutex, mpsc};
7
+ use std::thread;
8
+ use std::thread::JoinHandle;
9
+ use std::time::{Duration, Instant};
6
10
 
7
11
  use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
8
12
  use grep_matcher::Matcher;
@@ -85,12 +89,12 @@ pub fn find_iter_with(opts: &FindOptions, walk_root_links: bool) -> Result<FindI
85
89
  if panic_probe { panic!("rgapi: deliberate panic for tests (panic_probe)"); }
86
90
  match find_entry(dent, base, filters, pattern.as_ref(), files, dirs, special_files, max_depth) {
87
91
  Ok(Some(path)) => { if cancel.load(Ordering::Relaxed) || tx.send(Ok(path)).is_err() { return WalkState::Quit; } WalkState::Continue }
88
- Ok(None) => WalkState::Continue, Err(err) => { let _ = tx.send(Err(err)); WalkState::Quit }
92
+ Ok(None) => WalkState::Continue, Err(err) => send_error(tx, err)
89
93
  }
90
94
  }))
91
95
  }
92
96
 
93
- pub struct StreamIter<T> { rx: mpsc::Receiver<Result<T, RgApiError>>, cancel: Arc<AtomicBool>, worker: Option<std::thread::JoinHandle<()>> }
97
+ pub struct StreamIter<T> { rx: mpsc::Receiver<Result<T, RgApiError>>, cancel: Arc<AtomicBool>, worker: Option<JoinHandle<()>> }
94
98
 
95
99
  impl<T> StreamIter<T> {
96
100
  pub fn cancel(&self) { self.cancel.store(true, Ordering::Relaxed); }
@@ -108,19 +112,19 @@ impl<T> StreamIter<T> {
108
112
  Ok(())
109
113
  }
110
114
 
111
- pub fn next_timeout(&mut self, timeout: std::time::Duration) -> Result<Result<T, RgApiError>, mpsc::RecvTimeoutError> { self.rx.recv_timeout(timeout) }
115
+ pub fn next_timeout(&mut self, timeout: Duration) -> Result<Result<T, RgApiError>, RecvTimeoutError> { self.rx.recv_timeout(timeout) }
112
116
 
113
117
  /// Collect all items, stopping at `timeout_ms`; the bool is true when the deadline stopped it.
114
118
  pub fn collect_timeout(mut self, timeout_ms: Option<u64>) -> Result<(Vec<T>, bool), RgApiError> {
115
119
  let Some(ms) = timeout_ms else { return Ok((self.collect::<Result<Vec<_>, _>>()?, false)); };
116
- let deadline = std::time::Instant::now() + std::time::Duration::from_millis(ms);
120
+ let deadline = Instant::now() + Duration::from_millis(ms);
117
121
  let mut res = Vec::new();
118
122
  loop {
119
- let left = deadline.saturating_duration_since(std::time::Instant::now());
123
+ let left = deadline.saturating_duration_since(Instant::now());
120
124
  if left.is_zero() { return Ok((res, true)); }
121
125
  match self.next_timeout(left) {
122
- Ok(Ok(item)) => res.push(item), Ok(Err(err)) => return Err(err), Err(mpsc::RecvTimeoutError::Disconnected) => return Ok((res, false)),
123
- Err(mpsc::RecvTimeoutError::Timeout) => return Ok((res, true))
126
+ Ok(Ok(item)) => res.push(item), Ok(Err(err)) => return Err(err), Err(RecvTimeoutError::Disconnected) => return Ok((res, false)),
127
+ Err(RecvTimeoutError::Timeout) => return Ok((res, true))
124
128
  }
125
129
  }
126
130
  }
@@ -136,6 +140,7 @@ impl<T> Drop for StreamIter<T> { fn drop(&mut self) { self.cancel(); } }
136
140
  #[cfg(test)]
137
141
  mod close_tests {
138
142
  use super::*;
143
+ use std::thread;
139
144
 
140
145
  #[test]
141
146
  fn cancel_and_join_drains_full_channel_and_waits_for_worker() {
@@ -145,7 +150,7 @@ mod close_tests {
145
150
  let worker_cancel = cancel.clone();
146
151
  let done = Arc::new(AtomicBool::new(false));
147
152
  let worker_done = done.clone();
148
- let worker = std::thread::spawn(move || {
153
+ let worker = thread::spawn(move || {
149
154
  tx.send(Ok(1)).unwrap();
150
155
  started.send(()).unwrap();
151
156
  // This blocks while the channel is full; close must drain, not just join.
@@ -161,14 +166,14 @@ mod close_tests {
161
166
  }
162
167
 
163
168
  pub(crate) fn spawn_walk<T, F>(roots: Vec<PathBuf>, base: PathBuf, walk: &WalkOptions, filters: Arc<PathFilters>, ready: Vec<T>, entry: F) -> StreamIter<T>
164
- where T: Send + 'static, F: Fn(Result<DirEntry, ignore::Error>, &Path, &PathFilters, &mpsc::SyncSender<Result<T, RgApiError>>, &Arc<AtomicBool>) -> WalkState
165
- + Send + Sync + Clone + 'static {
169
+ where T: Send + 'static, F: Fn(Result<DirEntry, ignore::Error>, &Path, &PathFilters, &SyncSender<Result<T, RgApiError>>, &Arc<AtomicBool>) -> WalkState + Send
170
+ + Sync + Clone + 'static {
166
171
  let cancel = Arc::new(AtomicBool::new(false));
167
172
  let worker_cancel = cancel.clone();
168
173
  let (tx, rx) = mpsc::sync_channel(8192);
169
174
  let walk = walk.clone();
170
175
  let seen = roots.iter().any(|a| roots.iter().any(|b| a != b && a.starts_with(b))).then(|| Arc::new(Mutex::new(HashSet::new())));
171
- let worker = std::thread::spawn(move || {
176
+ let worker = thread::spawn(move || {
172
177
  for item in ready { if tx.send(Ok(item)).is_err() { return; } }
173
178
  for root in &roots {
174
179
  if worker_cancel.load(Ordering::Relaxed) { return; }
@@ -234,11 +239,17 @@ fn file_root_flags(root: &Path, ignore: bool, hidden: bool) -> (bool, bool) { if
234
239
 
235
240
  // At the max_depth cap the walker opens directories it will never descend into
236
241
  // (readdir precedes the depth check in `ignore`), so permission failures there are
237
- // harmless: skip them (None). Every other walk error is fatal (Some).
242
+ // harmless: skip them silently (None). Report every other walk error as a skipped path (Some).
238
243
  pub(crate) fn entry_err(err: ignore::Error, max_depth: Option<usize>) -> Option<RgApiError> {
239
244
  let at_cap = max_depth.is_some_and(|m| err.depth().is_some_and(|d| d >= m));
240
245
  let denied = err.io_error().is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied);
241
- if at_cap && denied { None } else { Some(RgApiError::new(err.to_string())) }
246
+ if at_cap && denied { None } else { Some(RgApiError::skipped(err)) }
247
+ }
248
+
249
+ /// Send `err`, then end the walk unless `err` only skipped a path.
250
+ pub(crate) fn send_error<T>(tx: &SyncSender<Result<T, RgApiError>>, err: RgApiError) -> WalkState {
251
+ let skipped = err.is_skipped();
252
+ if tx.send(Err(err)).is_ok() && skipped { WalkState::Continue } else { WalkState::Quit }
242
253
  }
243
254
 
244
255
  fn normalize_root(path: &Path) -> Result<PathBuf, RgApiError> {
@@ -287,7 +298,7 @@ fn configure_walker(walker: &mut WalkBuilder, root: &Path, walk: &WalkOptions) {
287
298
  }
288
299
 
289
300
  fn filter_dirs<T: Send + 'static>(
290
- walker: &mut WalkBuilder, base: &Path, filters: Arc<PathFilters>, tx: mpsc::SyncSender<Result<T, RgApiError>>, cancel: Arc<AtomicBool>) {
301
+ walker: &mut WalkBuilder, base: &Path, filters: Arc<PathFilters>, tx: SyncSender<Result<T, RgApiError>>, cancel: Arc<AtomicBool>) {
291
302
  let base = base.to_path_buf();
292
303
  walker.filter_entry(move |entry| {
293
304
  if cancel.load(Ordering::Relaxed) { false } else {
@@ -350,6 +361,8 @@ fn re_match(matcher: &RegexMatcher, rel: &str) -> Result<bool, RgApiError> { mat
350
361
  #[cfg(test)]
351
362
  mod tests {
352
363
  use super::*;
364
+ use std::thread;
365
+ use std::time::Duration;
353
366
 
354
367
  #[test]
355
368
  fn globs_match_names_or_root_relative_components() {
@@ -366,8 +379,8 @@ mod tests {
366
379
  fn iter_of(items: Vec<u32>, delay_ms: u64) -> StreamIter<u32> {
367
380
  let (tx, rx) = mpsc::channel();
368
381
  let cancel = Arc::new(AtomicBool::new(false));
369
- let worker = std::thread::spawn(move || {
370
- for i in items { std::thread::sleep(std::time::Duration::from_millis(delay_ms)); if tx.send(Ok(i)).is_err() { return; } }
382
+ let worker = thread::spawn(move || {
383
+ for i in items { thread::sleep(Duration::from_millis(delay_ms)); if tx.send(Ok(i)).is_err() { return; } }
371
384
  });
372
385
  StreamIter { rx, cancel, worker: Some(worker) }
373
386
  }
@@ -371,8 +371,7 @@ def test_backreferences_and_lookaround(tmp_path):
371
371
 
372
372
 
373
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") == []
374
+ for pattern in [r"foo\s+foo", r"foo[^x]+foo", r"(?s:foo.+foo)", r"foo(?=\s+foo)"]: assert rgstr(pattern, "foo\nfoo\n") == []
376
375
  assert [r.matches for r in rgstr(r"(?s:foo.*|bar)", "foo\nbar\n")] == [[(0, 3)], [(0, 3)]]
377
376
  assert compile(r"\Afoo\z").finditer("foo\nfoo\n") == [(0, 3), (4, 7)]
378
377
  empty = compile(r"\A\z")
@@ -395,7 +394,7 @@ def test_regex_runtime_errors(tmp_path):
395
394
  p.mkdir()
396
395
  (tmp_path/(text+".txt")).write_text(text)
397
396
  for search in [lambda: compile(pattern).is_match(text), lambda: rgstr(pattern, text),
398
- lambda: fd(pattern, tmp_path), lambda: fd(root=tmp_path, path_re=pattern), lambda: fd(root=tmp_path, skip_dir_re=pattern)]:
397
+ lambda: fd(pattern, tmp_path), lambda: fd(root=tmp_path, path_re=pattern), lambda: fd(root=tmp_path, skip_dir_re=pattern)]:
399
398
  with pytest.raises(ValueError, match="backtrack"): search()
400
399
 
401
400
 
@@ -498,12 +497,13 @@ def test_file_as_root_searches_just_that_file(tmp_path):
498
497
  with pytest.raises(Exception): rg("TODO", str(tmp_path / "nope.txt"))
499
498
 
500
499
 
501
- def test_nbrg_skips_bad_json(tmp_path):
502
- from rgapi import nbrg
500
+ def test_nbrg_warns_on_bad_json(tmp_path):
501
+ from rgapi import nbrg, search_nb
503
502
  write_nb(tmp_path / "good.ipynb", [_cell("code", ["read_csv(x)\n"], cid="g1")])
504
503
  (tmp_path / "bad.ipynb").write_text("{not valid json")
505
- a = nbrg("read_csv", str(tmp_path))
504
+ with pytest.warns(RuntimeWarning, match=r"bad\.ipynb: .* at line 1 column"): a = nbrg("read_csv", tmp_path)
506
505
  assert {c.cell_id for c in a} == {"g1"}
506
+ with pytest.raises(ValueError, match=r"bad\.ipynb: "): search_nb("read_csv", tmp_path / "bad.ipynb")
507
507
 
508
508
 
509
509
  def test_search_nb_single_and_asdict_str(tmp_path):
@@ -717,12 +717,41 @@ def test_file_root_ignores_siblings_and_depth_cap_permissions(tmp_path):
717
717
  got = sorted({r.path for r in rg("x", tmp_path, max_depth=1, ignore=False, hidden=True)})
718
718
  assert got == [".gitignore", ".hid.txt", "f.txt"] # depth-cap dir skipped silently
719
719
  assert list(fd(root=tmp_path, max_depth=1, ignore=False)) == [f]
720
- with pytest.raises(ValueError, match="ermission"):
721
- rg("x", tmp_path) # uncapped: unreadable dir in tree is fatal
722
- with pytest.raises(ValueError, match="ermission"):
723
- rg("x", tmp_path, max_depth=2) # cap above the dir: descent needed, fatal
720
+ with pytest.warns(RuntimeWarning, match="locked: Permission denied"):
721
+ assert not rg("x", tmp_path, max_depth=2) # cap above the dir: descent needed, so it's reported
724
722
  finally: locked.chmod(0o755)
725
723
 
724
+ def test_unreadable_paths_warn_and_walks_continue(tmp_path):
725
+ from fastcore.aio import run_sync
726
+ from rgapi import nbrg, rga, rga_iter
727
+ (tmp_path / "a.txt").write_text("x\n")
728
+ write_nb(tmp_path / "a.ipynb", [_cell("code", ["x\n"], cid="c1")])
729
+ secret = tmp_path / "secret.ipynb"
730
+ write_nb(secret, [_cell("code", ["x\n"], cid="c2")])
731
+ locked = tmp_path / "locked"
732
+ locked.mkdir()
733
+ (locked / "b.txt").write_text("x\n")
734
+ def walk(f):
735
+ "`f()`'s results, and the names of the paths it warned it skipped"
736
+ with pytest.warns(RuntimeWarning) as caught: res = f()
737
+ return res, sorted(Path(str(w.message).partition(": ")[0]).name for w in caught)
738
+ async def listed(it): return [r async for r in it]
739
+ searches = [lambda: rg("x", tmp_path), lambda: list(rg_iter("x", tmp_path)), lambda: run_sync(rga("x", tmp_path)),
740
+ lambda: run_sync(listed(rga_iter("x", tmp_path))), lambda: rg("x", tmp_path, summary=True)]
741
+ secret.chmod(0o000)
742
+ locked.chmod(0o000)
743
+ try:
744
+ for search in searches:
745
+ res, skipped = walk(search)
746
+ assert sorted({r.path for r in res}) == ["a.ipynb", "a.txt"] and skipped == ["locked", "secret.ipynb"]
747
+ res, skipped = walk(lambda: nbrg("x", tmp_path))
748
+ assert [c.cell_id for c in res] == ["c1"] and skipped == ["locked", "secret.ipynb"]
749
+ res, skipped = walk(lambda: fd(root=tmp_path)) # discovery reads no files, so only the dir is skipped
750
+ assert sorted(p.name for p in res) == ["a.ipynb", "a.txt", "secret.ipynb"] and skipped == ["locked"]
751
+ finally:
752
+ secret.chmod(0o644)
753
+ locked.chmod(0o755)
754
+
726
755
  def test_symlink_link_target_and_show_target(tmp_path):
727
756
  (tmp_path / "real").mkdir()
728
757
  (tmp_path / "f.txt").write_text("x\n")
rgapi-0.2.3/src/lib.rs DELETED
@@ -1,24 +0,0 @@
1
- //! rgapi - Python-friendly wrappers around ripgrep's walking and searching crates.
2
-
3
- mod block;
4
- mod matcher;
5
- mod nb;
6
- mod search;
7
- mod walk;
8
-
9
- pub use block::{BlockIter, SearchBlock, block_iter};
10
- pub use matcher::{RegexMatcher, compile_regex};
11
- pub use nb::{CellRefs, NbCell, NbIter, NbOptions, ancestor_indices, cell_refs, heading_level, nb_iter, nb_search, nb_search_file, section_range};
12
- pub use search::{MatchSpan, RgIter, RgOptions, SearchError, SearchKind, SearchLine, rg, rg_iter, search_path, search_text, spans_for};
13
- pub use walk::{FindIter, FindOptions, StreamIter, WalkOptions, find, find_iter, find_iter_with, resolve_roots};
14
-
15
- #[derive(Debug, Clone)]
16
- pub struct RgApiError { msg: String }
17
-
18
- impl RgApiError { pub(crate) fn new(msg: impl Into<String>) -> Self { Self { msg: msg.into() } } }
19
-
20
- impl std::fmt::Display for RgApiError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.msg) } }
21
-
22
- impl std::error::Error for RgApiError {}
23
-
24
- impl From<std::io::Error> for RgApiError { fn from(err: std::io::Error) -> Self { Self::new(err.to_string()) } }
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes