Skip to main content

escriba_runtime/
scan.rs

1//! The scan runner — the courier's first real carrier.
2//!
3//! Walks a tree on its own thread and posts matches back as findings. This is
4//! what removes the grep ceiling: the synchronous version stopped at 2,000
5//! files and 500 hits because it ran on the thread that draws the screen, and
6//! its own comment named the courier as the way out.
7//!
8//! # Batches, not one big answer
9//!
10//! Results are posted as they are found, in batches, and each batch is
11//! CUMULATIVE — it carries every match so far rather than only the new ones.
12//! That is deliberate: the reply lands as a `PublishFindings`, and publishing
13//! REPLACES a list rather than appending to it, so an incremental batch would
14//! make the list flicker down to the last few rows. Re-sending the whole set
15//! costs a clone per batch and keeps the surface honest at every moment.
16//!
17//! # What bounds it
18//!
19//! Nothing bounds the walk. That is the point — the ceiling was a symptom of
20//! running on the wrong thread. What DOES stop it is the cancel flag, checked
21//! once per directory and once per batch; and even that only stops the
22//! posting promptly, since the flag is observed between units of work rather
23//! than interrupting one.
24//!
25//! A superseded scan whose runner ignores the flag is still harmless: its
26//! replies are sealed against a scan generation the editor has moved past, so
27//! they are dropped on arrival. The flag makes it stop sooner, not safer.
28
29use 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
40/// The list name scan results publish under.
41///
42/// One name, referenced by the runner and by whatever projects it, so the
43/// producer and the consumer cannot drift onto two spellings.
44pub const LIST: &str = "grep";
45
46/// How many matches accumulate before a batch is posted.
47///
48/// Small enough that the first rows appear immediately on a big tree, large
49/// enough that a dense match does not post per line.
50const BATCH: usize = 64;
51
52/// Directory names never descended into.
53///
54/// Not a gitignore implementation and not pretending to be — it is the same
55/// list the synchronous walker used, kept identical so this change is about
56/// WHERE the walk runs, not what it finds.
57fn skip(name: &str) -> bool {
58    name.starts_with('.') || name == "target" || name == "node_modules"
59}
60
61/// Does `line` contain `needle`, under `case`?
62///
63/// **Substring, not regex.** The synchronous grep was `line.contains(pattern)`,
64/// and routing this through a regex engine would silently change what a
65/// pattern containing `.` or `*` means for every existing user. Case handling
66/// is new and is a strict widening: smartcase makes `foo` find `Foo`, which the
67/// old behaviour did not.
68fn matches(line: &str, needle: &str, case: CaseMode) -> bool {
69    let insensitive = match case {
70        CaseMode::Sensitive => false,
71        CaseMode::Ignore => true,
72        // vim's smartcase: an uppercase character in the pattern means the
73        // operator meant it.
74        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
93/// Walks the filesystem for matches, on a thread of its own.
94pub 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            // Unreachable: `Crew::get` routes by class. Say so rather than
100            // panicking on a thread nobody is waiting on.
101            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        // Kept out of the closure so the spawn-failure arm still has a way to
110        // speak. An errand that silently never starts is precisely the failure
111        // this seam exists to prevent.
112        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                        // Binary or unreadable is not an error worth
154                        // reporting — a tree has plenty of both.
155                        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                            // A closed channel means the editor is gone.
169                            if !post(&found) {
170                                return;
171                            }
172                        }
173                    }
174                }
175
176                // The final batch. Sent even when empty and even when nothing
177                // changed since the last one: it is what turns "still
178                // searching" into "that is all there is", and a scan that
179                // simply stops talking is indistinguishable from one that hung.
180                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                    // Detached on purpose. Joining here would block the editor
193                    // on the walk, which is the entire thing being fixed.
194                },
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    /// Runs a scan to completion and returns every finding from the LAST
224    /// batch, which is cumulative and therefore the full result.
225    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        // Bounded: a regression that hangs the walk must fail the suite rather
242        // than hang it.
243        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    /// **The ceiling this exists to remove.** The synchronous grep stopped at
266    /// 500 hits; a match past that was simply not found.
267    #[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    /// …and the file ceiling, which was 2,000.
279    #[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    /// Results must arrive progressively — a scan that only answers at the end
290    /// is off-thread but still feels frozen.
291    #[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    /// Each batch carries everything found so far, because publishing REPLACES
320    /// a list. An incremental batch would make the surface shrink.
321    #[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    /// An empty result must still be REPORTED. A scan that finds nothing and
358    /// says nothing is indistinguishable from one that hung.
359    #[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    /// Smartcase is a strict widening over the old `line.contains`: `foo`
381    /// finds `Foo`, `Foo` does not find `foo`.
382    #[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    /// **Substring, NOT regex.** Routing grep through a regex engine would
392    /// silently reinterpret every pattern containing a metacharacter.
393    #[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    /// A tree full of binaries must not stop the walk.
425    #[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)); // already cancelled
444        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        // Cancelled before the first directory is read, so nothing is posted.
459        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}