OXYGENHUB

Coverage / crates/hub/src/repos/search.rs 86.5%

45 of 52 lines run, 4 of 8 functions · run #525 on main · run missed

1
//! Code search (hub spec §11): substring or regular expression over the
2
//! text files of one state, with a time budget and a result cap. Results
3
//! are cached by state hash and query, since a state never changes.
4
5
use super::cache::Loaded;
6
use oxy_core::state::FileContent;
7
use std::collections::HashMap;
8
use std::sync::Mutex;
9
use std::time::{Duration, Instant};
10
11
#[derive(Clone, Debug, PartialEq, Eq)]
12
pub struct Hit {
13
    pub path: String,
14
    /// 1-based; 0 for a match on the path itself.
15
    pub line: usize,
16
    pub text: String,
17
}
18
19
#[derive(Clone, Debug, Default)]
20
pub struct Results {
21
    pub hits: Vec<Hit>,
22
    pub files: usize,
23
    /// The budget or the cap stopped the search early.
24
    pub truncated: bool,
25
}
26
27
pub struct Query<'a> {
28
    pub text: &'a str,
29
    pub regex: bool,
30
    pub case_sensitive: bool,
31
    /// Only paths starting with this.
32
    pub path_prefix: &'a str,
33
}
34
35
pub const MAX_HITS: usize = 300;
36
pub const BUDGET: Duration = Duration::from_secs(2);
37
384×
pub fn run(loaded: &Loaded, q: &Query<'_>) -> Result<Results, String> {
394×
    let pattern = if q.regex { q.text.to_string() } else { regex::escape(q.text) };
404×
    let re = regex::RegexBuilder::new(&pattern)
414×
        .case_insensitive(!q.case_sensitive)
424×
        .size_limit(1 << 20)
434×
        .build()
444×
        .map_err(|e| format!("not a valid regular expression: {e}"))?;
453×
    let started = Instant::now();
463×
    let mut out = Results::default();
4712×
    for (path, f) in &loaded.snapshot.files {
4812×
        if !path.starts_with(q.path_prefix) {
490×
            continue;
5012×
        }
5112×
        if started.elapsed() > BUDGET || out.hits.len() >= MAX_HITS {
520×
            out.truncated = true;
530×
            break;
5412×
        }
5512×
        let mut found = false;
5612×
        if re.is_match(path) {
571×
            out.hits.push(Hit { path: path.clone(), line: 0, text: String::new() });
581×
            found = true;
5911×
        }
6012×
        let FileContent::Text { bytes, .. } = &f.content else { continue };
619×
        if bytes.len() > 1 << 20 || bytes.contains(&0) {
620×
            continue;
639×
        }
649×
        let text = String::from_utf8_lossy(bytes);
6518×
        for (i, line) in text.lines().enumerate() {
6618×
            if re.is_match(line) {
672×
                out.hits.push(Hit { path: path.clone(), line: i + 1, text: line.chars().take(300).collect() });
682×
                found = true;
692×
                if out.hits.len() >= MAX_HITS {
700×
                    out.truncated = true;
710×
                    break;
722×
                }
7316×
            }
74
        }
759×
        if found {
763×
            out.files += 1;
776×
        }
78
    }
793×
    Ok(out)
804×
}
81
82
/// A small cache of results by (state, query).
83
#[derive(Default)]
84
pub struct SearchCache {
85
    inner: Mutex<HashMap<String, Results>>,
86
}
87
88
impl SearchCache {
894×
    pub fn get(&self, key: &str) -> Option<Results> {
904×
        self.inner.lock().unwrap().get(key).cloned()
914×
    }
92
933×
    pub fn put(&self, key: String, r: Results) {
943×
        let mut m = self.inner.lock().unwrap();
953×
        if m.len() > 256 {
960×
            m.clear();
973×
        }
983×
        m.insert(key, r);
993×
    }
100
}