OXYGENHUB
◆ v0.9.8 oxygenhub / crates / hub / src / repos / search.rs
2.9 KiB · Rust · last changed Milestone 0.6: releases, Git mirror, code search, anchored API comments 1 month ago source blame history raw
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
38
pub fn run(loaded: &Loaded, q: &Query<'_>) -> Result<Results, String> {
39
    let pattern = if q.regex { q.text.to_string() } else { regex::escape(q.text) };
40
    let re = regex::RegexBuilder::new(&pattern)
41
        .case_insensitive(!q.case_sensitive)
42
        .size_limit(1 << 20)
43
        .build()
44
        .map_err(|e| format!("not a valid regular expression: {e}"))?;
45
    let started = Instant::now();
46
    let mut out = Results::default();
47
    for (path, f) in &loaded.snapshot.files {
48
        if !path.starts_with(q.path_prefix) {
49
            continue;
50
        }
51
        if started.elapsed() > BUDGET || out.hits.len() >= MAX_HITS {
52
            out.truncated = true;
53
            break;
54
        }
55
        let mut found = false;
56
        if re.is_match(path) {
57
            out.hits.push(Hit { path: path.clone(), line: 0, text: String::new() });
58
            found = true;
59
        }
60
        let FileContent::Text { bytes, .. } = &f.content else { continue };
61
        if bytes.len() > 1 << 20 || bytes.contains(&0) {
62
            continue;
63
        }
64
        let text = String::from_utf8_lossy(bytes);
65
        for (i, line) in text.lines().enumerate() {
66
            if re.is_match(line) {
67
                out.hits.push(Hit { path: path.clone(), line: i + 1, text: line.chars().take(300).collect() });
68
                found = true;
69
                if out.hits.len() >= MAX_HITS {
70
                    out.truncated = true;
71
                    break;
72
                }
73
            }
74
        }
75
        if found {
76
            out.files += 1;
77
        }
78
    }
79
    Ok(out)
80
}
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 {
89
    pub fn get(&self, key: &str) -> Option<Results> {
90
        self.inner.lock().unwrap().get(key).cloned()
91
    }
92
93
    pub fn put(&self, key: String, r: Results) {
94
        let mut m = self.inner.lock().unwrap();
95
        if m.len() > 256 {
96
            m.clear();
97
        }
98
        m.insert(key, r);
99
    }
100
}