1use std::path::Path;
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::mpsc::Sender;
33
34use escriba_core::{Position, Range};
35use escriba_madoguchi::Negai;
36use escriba_madoguchi::errand::{Errand, Freight, Parcel, Runner};
37use escriba_search::CaseMode;
38use escriba_shirube::{Finding, Origin, Severity, Site};
39
40pub const LIST: &str = "grep";
45
46const BATCH: usize = 64;
51
52fn skip(name: &str) -> bool {
58 name.starts_with('.') || name == "target" || name == "node_modules"
59}
60
61fn matches(line: &str, needle: &str, case: CaseMode) -> bool {
69 let insensitive = match case {
70 CaseMode::Sensitive => false,
71 CaseMode::Ignore => true,
72 CaseMode::Smart => !needle.chars().any(char::is_uppercase),
75 };
76 if insensitive {
77 line.to_lowercase().contains(&needle.to_lowercase())
78 } else {
79 line.contains(needle)
80 }
81}
82
83fn finding_at(path: &Path, line: u32, text: &str) -> Finding {
84 let at = Position::new(line, 0);
85 Finding::new(
86 Site::in_file(path, Range { start: at, end: at }),
87 Severity::Info,
88 text.trim().to_string(),
89 Origin::Search,
90 )
91}
92
93pub struct ScanRunner;
95
96impl Runner for ScanRunner {
97 fn start(&self, errand: Errand, cancel: Arc<AtomicBool>, reply: Sender<Parcel>) {
98 let Freight::Scan { raw, case, root } = errand.freight else {
99 let _ = reply.send(Parcel {
102 id: errand.id,
103 slip: Negai::Message("scan runner received the wrong freight".into()),
104 });
105 return;
106 };
107 let id = errand.id;
108 let anchor = errand.anchor.into_anchor();
109 let on_fail = reply.clone();
113
114 std::thread::Builder::new()
115 .name("escriba-scan".into())
116 .spawn(move || {
117 let post = |found: &[Finding]| {
118 reply
119 .send(Parcel {
120 id,
121 slip: Negai::ErrandReply {
122 anchor: anchor.clone(),
123 then: Box::new(Negai::PublishFindings {
124 list: LIST.to_string(),
125 findings: found.to_vec(),
126 }),
127 },
128 })
129 .is_ok()
130 };
131
132 let mut found: Vec<Finding> = Vec::new();
133 let mut stack = vec![root];
134 let mut since_post = 0usize;
135
136 while let Some(dir) = stack.pop() {
137 if cancel.load(Ordering::Relaxed) {
138 return;
139 }
140 let Ok(entries) = std::fs::read_dir(&dir) else {
141 continue;
142 };
143 for entry in entries.flatten() {
144 let name = entry.file_name();
145 if skip(&name.to_string_lossy()) {
146 continue;
147 }
148 let path = entry.path();
149 if entry.file_type().is_ok_and(|t| t.is_dir()) {
150 stack.push(path);
151 continue;
152 }
153 let Ok(text) = std::fs::read_to_string(&path) else {
156 continue;
157 };
158 for (n, line) in text.lines().enumerate() {
159 if !matches(line, &raw, case) {
160 continue;
161 }
162 let Ok(n) = u32::try_from(n) else { break };
163 found.push(finding_at(&path, n, line));
164 since_post += 1;
165 }
166 if since_post >= BATCH {
167 since_post = 0;
168 if !post(&found) {
170 return;
171 }
172 }
173 }
174 }
175
176 post(&found);
181 })
182 .map_or_else(
183 |e| {
184 let mut m = String::from("could not start the scan thread: ");
185 m.push_str(&e.to_string());
186 let _ = on_fail.send(Parcel {
187 id,
188 slip: Negai::Message(m),
189 });
190 },
191 |_handle| {
192 },
195 );
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::{BATCH, LIST, ScanRunner, finding_at, matches, skip};
202 use escriba_madoguchi::errand::{Errand, Freight, Runner};
203 use escriba_madoguchi::{ErrandId, Negai};
204 use escriba_search::CaseMode;
205 use escriba_shirube::{Axis, NonEmptyAnchor, Origin, SessionGen, SessionKind};
206 use std::sync::Arc;
207 use std::sync::atomic::AtomicBool;
208 use std::sync::mpsc::channel;
209 use std::time::Duration;
210
211 fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
212 let d = tempfile::tempdir().unwrap();
213 for (name, body) in files {
214 let p = d.path().join(name);
215 if let Some(parent) = p.parent() {
216 std::fs::create_dir_all(parent).unwrap();
217 }
218 std::fs::write(p, body).unwrap();
219 }
220 d
221 }
222
223 fn scan(root: &std::path::Path, pattern: &str) -> Vec<escriba_shirube::Finding> {
226 let (tx, rx) = channel();
227 ScanRunner.start(
228 Errand {
229 id: ErrandId(1),
230 freight: Freight::Scan {
231 raw: pattern.into(),
232 case: CaseMode::Smart,
233 root: root.to_path_buf(),
234 },
235 anchor: NonEmptyAnchor::on(Axis::Session(SessionKind::Scan, SessionGen(1))),
236 },
237 Arc::new(AtomicBool::new(false)),
238 tx,
239 );
240 let mut last = Vec::new();
241 while let Ok(p) = rx.recv_timeout(Duration::from_secs(20)) {
244 if let Negai::ErrandReply { then, .. } = p.slip {
245 if let Negai::PublishFindings { list, findings } = *then {
246 assert_eq!(list, LIST);
247 last = findings;
248 }
249 }
250 }
251 last
252 }
253
254 #[test]
255 fn a_match_is_found_and_located() {
256 let d = tree(&[("a.txt", "one\nneedle here\nthree\n")]);
257 let got = scan(d.path(), "needle");
258 assert_eq!(got.len(), 1, "{got:?}");
259 assert_eq!(got[0].site.range.start.line, 1, "zero-based line");
260 assert_eq!(got[0].origin, Origin::Search);
261 assert!(got[0].message.contains("needle"));
262 assert!(got[0].site.path.as_ref().unwrap().ends_with("a.txt"));
263 }
264
265 #[test]
268 fn a_match_past_the_old_five_hundred_hit_ceiling_is_found() {
269 let mut body = String::new();
270 for _ in 0..600 {
271 body.push_str("needle\n");
272 }
273 let d = tree(&[("big.txt", &body)]);
274 let got = scan(d.path(), "needle");
275 assert_eq!(got.len(), 600, "no hit ceiling");
276 }
277
278 #[test]
280 fn more_files_than_the_old_two_thousand_file_ceiling_are_walked() {
281 let d = tempfile::tempdir().unwrap();
282 for i in 0..2_100 {
283 std::fs::write(d.path().join(format!("f{i}.txt")), "needle\n").unwrap();
284 }
285 let got = scan(d.path(), "needle");
286 assert_eq!(got.len(), 2_100, "no file ceiling");
287 }
288
289 #[test]
292 fn results_arrive_in_batches_rather_than_only_at_the_end() {
293 let mut body = String::new();
294 for _ in 0..(BATCH * 4) {
295 body.push_str("needle\n");
296 }
297 let d = tree(&[("big.txt", &body)]);
298 let (tx, rx) = channel();
299 ScanRunner.start(
300 Errand {
301 id: ErrandId(1),
302 freight: Freight::Scan {
303 raw: "needle".into(),
304 case: CaseMode::Smart,
305 root: d.path().to_path_buf(),
306 },
307 anchor: NonEmptyAnchor::on(Axis::Session(SessionKind::Scan, SessionGen(1))),
308 },
309 Arc::new(AtomicBool::new(false)),
310 tx,
311 );
312 let mut batches = 0;
313 while rx.recv_timeout(Duration::from_secs(20)).is_ok() {
314 batches += 1;
315 }
316 assert!(batches >= 2, "expected progressive batches, got {batches}");
317 }
318
319 #[test]
322 fn batches_are_cumulative_so_the_list_never_shrinks() {
323 let mut body = String::new();
324 for _ in 0..(BATCH * 3) {
325 body.push_str("needle\n");
326 }
327 let d = tree(&[("big.txt", &body)]);
328 let (tx, rx) = channel();
329 ScanRunner.start(
330 Errand {
331 id: ErrandId(1),
332 freight: Freight::Scan {
333 raw: "needle".into(),
334 case: CaseMode::Smart,
335 root: d.path().to_path_buf(),
336 },
337 anchor: NonEmptyAnchor::on(Axis::Session(SessionKind::Scan, SessionGen(1))),
338 },
339 Arc::new(AtomicBool::new(false)),
340 tx,
341 );
342 let mut sizes = Vec::new();
343 while let Ok(p) = rx.recv_timeout(Duration::from_secs(20)) {
344 if let Negai::ErrandReply { then, .. } = p.slip {
345 if let Negai::PublishFindings { findings, .. } = *then {
346 sizes.push(findings.len());
347 }
348 }
349 }
350 assert!(sizes.len() >= 2, "need several batches: {sizes:?}");
351 assert!(
352 sizes.windows(2).all(|w| w[1] >= w[0]),
353 "a batch must never be smaller than the one before: {sizes:?}"
354 );
355 }
356
357 #[test]
360 fn a_scan_with_no_matches_still_reports_completion() {
361 let d = tree(&[("a.txt", "nothing to see\n")]);
362 let (tx, rx) = channel();
363 ScanRunner.start(
364 Errand {
365 id: ErrandId(1),
366 freight: Freight::Scan {
367 raw: "absent".into(),
368 case: CaseMode::Smart,
369 root: d.path().to_path_buf(),
370 },
371 anchor: NonEmptyAnchor::on(Axis::Session(SessionKind::Scan, SessionGen(1))),
372 },
373 Arc::new(AtomicBool::new(false)),
374 tx,
375 );
376 let got = rx.recv_timeout(Duration::from_secs(20));
377 assert!(got.is_ok(), "an empty scan must still post a final batch");
378 }
379
380 #[test]
383 fn smartcase_widens_a_lowercase_pattern_and_respects_an_uppercase_one() {
384 assert!(matches("Foo bar", "foo", CaseMode::Smart));
385 assert!(!matches("foo bar", "Foo", CaseMode::Smart));
386 assert!(matches("Foo", "Foo", CaseMode::Smart));
387 assert!(!matches("Foo", "foo", CaseMode::Sensitive));
388 assert!(matches("Foo", "foo", CaseMode::Ignore));
389 }
390
391 #[test]
394 fn a_metacharacter_is_a_literal_not_a_pattern() {
395 assert!(matches("a.c", "a.c", CaseMode::Sensitive));
396 assert!(
397 !matches("abc", "a.c", CaseMode::Sensitive),
398 "`.` must not match any character"
399 );
400 assert!(!matches("aaa", "a*", CaseMode::Sensitive));
401 }
402
403 #[test]
404 fn the_skip_list_is_unchanged_from_the_synchronous_walker() {
405 for name in [".git", ".direnv", "target", "node_modules"] {
406 assert!(skip(name), "{name} must be skipped");
407 }
408 for name in ["src", "Cargo.toml", "a.rs"] {
409 assert!(!skip(name), "{name} must be walked");
410 }
411 }
412
413 #[test]
414 fn a_skipped_directory_is_not_searched() {
415 let d = tree(&[
416 ("src/a.txt", "needle\n"),
417 ("target/b.txt", "needle\n"),
418 (".git/c.txt", "needle\n"),
419 ]);
420 let got = scan(d.path(), "needle");
421 assert_eq!(got.len(), 1, "only src/ is walked: {got:?}");
422 }
423
424 #[test]
426 fn an_unreadable_file_is_skipped_rather_than_fatal() {
427 let d = tree(&[("good.txt", "needle\n")]);
428 std::fs::write(d.path().join("bin.dat"), [0xff, 0xfe, 0x00, 0x01]).unwrap();
429 let got = scan(d.path(), "needle");
430 assert_eq!(got.len(), 1);
431 }
432
433 #[test]
434 fn a_cancelled_scan_stops_posting() {
435 let mut body = String::new();
436 for _ in 0..(BATCH * 20) {
437 body.push_str("needle\n");
438 }
439 let d = tempfile::tempdir().unwrap();
440 for i in 0..50 {
441 std::fs::write(d.path().join(format!("f{i}.txt")), &body).unwrap();
442 }
443 let cancel = Arc::new(AtomicBool::new(true)); let (tx, rx) = channel();
445 ScanRunner.start(
446 Errand {
447 id: ErrandId(1),
448 freight: Freight::Scan {
449 raw: "needle".into(),
450 case: CaseMode::Smart,
451 root: d.path().to_path_buf(),
452 },
453 anchor: NonEmptyAnchor::on(Axis::Session(SessionKind::Scan, SessionGen(1))),
454 },
455 cancel,
456 tx,
457 );
458 assert!(
460 rx.recv_timeout(Duration::from_secs(5)).is_err(),
461 "a pre-cancelled scan must post nothing"
462 );
463 }
464
465 #[test]
466 fn a_finding_records_the_line_it_was_found_on() {
467 let f = finding_at(std::path::Path::new("x.rs"), 41, " hit ");
468 assert_eq!(f.site.range.start.line, 41);
469 assert_eq!(f.message, "hit", "the label is trimmed");
470 }
471}