Skip to main content

xei_core/
workspace_search.rs

1//! Project-wide text search (ripgrep preferred, walk fallback).
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Debug, Clone)]
7pub struct SearchHit {
8    pub path: PathBuf,
9    pub row: usize, // 0-based
10    pub col: usize, // 0-based char (best-effort)
11    pub line: String,
12}
13
14#[derive(Debug, Clone)]
15pub struct WorkspaceSearch {
16    pub open: bool,
17    pub query: String,
18    pub replace: String,
19    /// When true, focus is on the replace field.
20    pub replace_focus: bool,
21    pub hits: Vec<SearchHit>,
22    pub selected: usize,
23    pub scroll: usize,
24    pub status: String,
25    pub root: PathBuf,
26    /// Dirty flag: re-run search on next idle when query changes.
27    pub needs_search: bool,
28}
29
30impl Default for WorkspaceSearch {
31    fn default() -> Self {
32        Self {
33            open: false,
34            query: String::new(),
35            replace: String::new(),
36            replace_focus: false,
37            hits: Vec::new(),
38            selected: 0,
39            scroll: 0,
40            status: String::new(),
41            root: PathBuf::from("."),
42            needs_search: false,
43        }
44    }
45}
46
47impl WorkspaceSearch {
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    pub fn open_at(&mut self, root: PathBuf) {
53        self.open = true;
54        self.root = root;
55        self.query.clear();
56        self.replace.clear();
57        self.replace_focus = false;
58        self.hits.clear();
59        self.selected = 0;
60        self.scroll = 0;
61        self.status = "Type to search · Tab replace · Enter open · r replace one · R all".into();
62        self.needs_search = false;
63    }
64
65    pub fn close(&mut self) {
66        self.open = false;
67        self.hits.clear();
68        self.query.clear();
69        self.replace.clear();
70        self.needs_search = false;
71    }
72
73    pub fn push_char(&mut self, c: char) {
74        if self.replace_focus {
75            self.replace.push(c);
76        } else {
77            self.query.push(c);
78            self.needs_search = true;
79        }
80    }
81
82    pub fn pop_char(&mut self) {
83        if self.replace_focus {
84            self.replace.pop();
85        } else {
86            self.query.pop();
87            self.needs_search = true;
88        }
89    }
90
91    pub fn toggle_replace_focus(&mut self) {
92        self.replace_focus = !self.replace_focus;
93    }
94
95    pub fn move_sel(&mut self, delta: isize) {
96        if self.hits.is_empty() {
97            self.selected = 0;
98            return;
99        }
100        let n = self.hits.len() as isize;
101        let cur = self.selected as isize + delta;
102        self.selected = (((cur % n) + n) % n) as usize;
103        // keep in view roughly
104        if self.selected < self.scroll {
105            self.scroll = self.selected;
106        }
107    }
108
109    pub fn selected_hit(&self) -> Option<&SearchHit> {
110        self.hits.get(self.selected)
111    }
112
113    /// Run search if query non-empty. Prefer `rg`, fall back to walk.
114    pub fn run_search(&mut self) {
115        self.needs_search = false;
116        self.hits.clear();
117        self.selected = 0;
118        self.scroll = 0;
119        let q = self.query.trim();
120        if q.is_empty() {
121            self.status = "Type a pattern…".into();
122            return;
123        }
124        self.hits = search_project(&self.root, q, 500);
125        self.status = if self.hits.is_empty() {
126            format!("No matches for `{q}`")
127        } else {
128            format!("{} match(es) in {}", self.hits.len(), self.root.display())
129        };
130    }
131}
132
133/// Search with ripgrep when available; otherwise recursive walk + line scan.
134pub fn search_project(root: &Path, pattern: &str, max: usize) -> Vec<SearchHit> {
135    if let Some(hits) = search_with_rg(root, pattern, max) {
136        return hits;
137    }
138    search_walk(root, pattern, max)
139}
140
141fn search_with_rg(root: &Path, pattern: &str, max: usize) -> Option<Vec<SearchHit>> {
142    let output = Command::new("rg")
143        .args([
144            "--json",
145            "--max-count",
146            "50",
147            "-m",
148            &max.to_string(),
149            "--hidden",
150            "--glob",
151            "!.git",
152            "--glob",
153            "!target",
154            "--glob",
155            "!node_modules",
156            "--glob",
157            "!dist",
158            "--glob",
159            "!build",
160            "-n",
161            "--",
162            pattern,
163        ])
164        .current_dir(root)
165        .output()
166        .ok()?;
167    // rg returns 1 when no matches
168    if !output.status.success() && output.stdout.is_empty() {
169        if output.status.code() == Some(1) {
170            return Some(Vec::new());
171        }
172        return None;
173    }
174    let text = String::from_utf8_lossy(&output.stdout);
175    let mut hits = Vec::new();
176    for line in text.lines() {
177        if !line.contains("\"type\":\"match\"") {
178            continue;
179        }
180        // crude JSON field extract
181        let path = extract_json_str(line, "\"path\":{\"text\":\"")
182            .or_else(|| extract_json_str(line, "\"path\":\""));
183        let row = extract_json_num(line, "\"line_number\":");
184        let text_line = extract_json_str(line, "\"lines\":{\"text\":\"")
185            .or_else(|| extract_json_str(line, "\"text\":\""));
186        let col = extract_json_num(line, "\"start\":").unwrap_or(0);
187        if let (Some(p), Some(r), Some(tl)) = (path, row, text_line) {
188            let line_clean = tl.trim_end_matches(['\r', '\n']).to_string();
189            hits.push(SearchHit {
190                path: root.join(p),
191                row: r.saturating_sub(1),
192                col,
193                line: line_clean,
194            });
195            if hits.len() >= max {
196                break;
197            }
198        }
199    }
200    Some(hits)
201}
202
203fn extract_json_str(s: &str, key: &str) -> Option<String> {
204    let i = s.find(key)? + key.len();
205    let rest = &s[i..];
206    let mut out = String::new();
207    let mut chars = rest.chars();
208    while let Some(c) = chars.next() {
209        if c == '\\' {
210            if let Some(n) = chars.next() {
211                match n {
212                    'n' => out.push('\n'),
213                    't' => out.push('\t'),
214                    'r' => out.push('\r'),
215                    '"' => out.push('"'),
216                    '\\' => out.push('\\'),
217                    'u' => {
218                        // skip \uXXXX
219                        let hex: String = chars.by_ref().take(4).collect();
220                        if let Ok(cp) = u32::from_str_radix(&hex, 16) {
221                            if let Some(ch) = char::from_u32(cp) {
222                                out.push(ch);
223                            }
224                        }
225                    }
226                    other => out.push(other),
227                }
228            }
229            continue;
230        }
231        if c == '"' {
232            break;
233        }
234        out.push(c);
235    }
236    if out.is_empty() && !rest.starts_with('"') {
237        // already consumed start
238    }
239    Some(out)
240}
241
242fn extract_json_num(s: &str, key: &str) -> Option<usize> {
243    let i = s.find(key)? + key.len();
244    let rest = &s[i..];
245    let num: String = rest
246        .chars()
247        .take_while(|c| c.is_ascii_digit())
248        .collect();
249    num.parse().ok()
250}
251
252fn search_walk(root: &Path, pattern: &str, max: usize) -> Vec<SearchHit> {
253    let mut hits = Vec::new();
254    let mut stack = vec![root.to_path_buf()];
255    let skip = ["target", "node_modules", ".git", "dist", "build", ".xei"];
256    let pat_lower = pattern.to_lowercase();
257    let case_sensitive = pattern.chars().any(|c| c.is_uppercase());
258
259    while let Some(dir) = stack.pop() {
260        if hits.len() >= max {
261            break;
262        }
263        let Ok(rd) = std::fs::read_dir(&dir) else {
264            continue;
265        };
266        for entry in rd.flatten() {
267            if hits.len() >= max {
268                break;
269            }
270            let path = entry.path();
271            let name = entry.file_name().to_string_lossy().to_string();
272            if name.starts_with('.') && name != ".env" {
273                continue;
274            }
275            if path.is_dir() {
276                if skip.iter().any(|s| *s == name) {
277                    continue;
278                }
279                stack.push(path);
280                continue;
281            }
282            // skip binary-ish
283            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
284                let e = ext.to_lowercase();
285                if matches!(
286                    e.as_str(),
287                    "png" | "jpg" | "jpeg" | "gif" | "webp" | "pdf" | "zip" | "o" | "a" | "so"
288                        | "dylib" | "exe" | "wasm" | "bin"
289                ) {
290                    continue;
291                }
292            }
293            let Ok(content) = std::fs::read_to_string(&path) else {
294                continue;
295            };
296            for (i, line) in content.lines().enumerate() {
297                let found = if case_sensitive {
298                    line.find(pattern)
299                } else {
300                    line.to_lowercase().find(&pat_lower)
301                };
302                if let Some(col) = found {
303                    hits.push(SearchHit {
304                        path: path.clone(),
305                        row: i,
306                        col,
307                        line: line.to_string(),
308                    });
309                    if hits.len() >= max {
310                        break;
311                    }
312                }
313            }
314        }
315    }
316    hits
317}
318
319/// Replace first occurrence of `query` on a specific line of a file. Returns true if changed.
320pub fn replace_in_file(path: &Path, row: usize, query: &str, replace: &str) -> Result<bool, String> {
321    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
322    let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
323    // preserve trailing newline presence
324    let trailing = content.ends_with('\n');
325    if row >= lines.len() {
326        return Err("line out of range".into());
327    }
328    if !lines[row].contains(query) {
329        return Ok(false);
330    }
331    lines[row] = lines[row].replacen(query, replace, 1);
332    let mut out = lines.join("\n");
333    if trailing {
334        out.push('\n');
335    }
336    std::fs::write(path, out).map_err(|e| e.to_string())?;
337    Ok(true)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use std::io::Write;
344
345    #[test]
346    fn walk_finds_pattern() {
347        let dir = std::env::temp_dir().join(format!("xei_ws_search_{}", std::process::id()));
348        let _ = std::fs::remove_dir_all(&dir);
349        std::fs::create_dir_all(&dir).unwrap();
350        let f = dir.join("hello.txt");
351        let mut file = std::fs::File::create(&f).unwrap();
352        writeln!(file, "alpha").unwrap();
353        writeln!(file, "findme now").unwrap();
354        writeln!(file, "beta").unwrap();
355        let hits = search_walk(&dir, "findme", 10);
356        assert_eq!(hits.len(), 1);
357        assert_eq!(hits[0].row, 1);
358        let _ = std::fs::remove_dir_all(&dir);
359    }
360
361    #[test]
362    fn replace_one_line() {
363        let dir = std::env::temp_dir().join(format!("xei_ws_repl_{}", std::process::id()));
364        let _ = std::fs::remove_dir_all(&dir);
365        std::fs::create_dir_all(&dir).unwrap();
366        let f = dir.join("a.txt");
367        std::fs::write(&f, "one two one\n").unwrap();
368        assert!(replace_in_file(&f, 0, "one", "ONE").unwrap());
369        let s = std::fs::read_to_string(&f).unwrap();
370        assert_eq!(s, "ONE two one\n");
371        let _ = std::fs::remove_dir_all(&dir);
372    }
373}