1use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Debug, Clone)]
7pub struct SearchHit {
8 pub path: PathBuf,
9 pub row: usize, pub col: usize, 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 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 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 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 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
133pub 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 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 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 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 }
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 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
319pub 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 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}