hjkl_picker/source/rg.rs
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::thread::{self, JoinHandle};
6
7use hjkl_buffer::Buffer;
8
9use crate::logic::{PickerAction, PickerLogic, RequeryMode};
10use crate::preview::load_preview;
11
12/// One ripgrep match result.
13pub struct RgMatch {
14 pub path: PathBuf,
15 pub line: u32, // 1-based
16 pub _col: u32, // 1-based, byte column (reserved for future use)
17 pub text: String,
18}
19
20/// Which search backend is available on this system.
21pub enum GrepBackend {
22 /// ripgrep (`rg`) — preferred; produces rich JSON output.
23 Rg,
24 /// POSIX `grep` — fallback when ripgrep is not installed.
25 Grep,
26 /// Windows-native `findstr` — fallback on vanilla Windows.
27 Findstr,
28 /// No supported search tool found on PATH.
29 Neither,
30}
31
32/// Probe PATH once per requery to decide which backend to use.
33/// The probes are cheap (`--version` exits immediately).
34pub fn detect_grep_backend() -> GrepBackend {
35 if std::process::Command::new("rg")
36 .arg("--version")
37 .stdout(std::process::Stdio::null())
38 .stderr(std::process::Stdio::null())
39 .status()
40 .map(|s| s.success())
41 .unwrap_or(false)
42 {
43 return GrepBackend::Rg;
44 }
45 if std::process::Command::new("grep")
46 .arg("--version")
47 .stdout(std::process::Stdio::null())
48 .stderr(std::process::Stdio::null())
49 .status()
50 .map(|s| s.success())
51 .unwrap_or(false)
52 {
53 return GrepBackend::Grep;
54 }
55 if std::process::Command::new("findstr")
56 .arg("/?")
57 .stdout(std::process::Stdio::null())
58 .stderr(std::process::Stdio::null())
59 .status()
60 .map(|s| s.success())
61 .unwrap_or(false)
62 {
63 return GrepBackend::Findstr;
64 }
65 GrepBackend::Neither
66}
67
68/// Parse one JSON line from `rg --json` output. Returns `Some(RgMatch)` for
69/// lines of `"type":"match"`, `None` for everything else.
70pub fn parse_rg_json_line(line: &str, root: &Path) -> Option<RgMatch> {
71 if !line.contains("\"type\":\"match\"") {
72 return None;
73 }
74
75 let path_text = extract_json_string(line, "\"path\":{\"text\":")?;
76 let line_number: u32 = extract_json_u32(line, "\"line_number\":")?;
77 let col: u32 = extract_json_u32(line, "\"start\":").unwrap_or(0) + 1;
78 let match_text = extract_json_string(line, "\"lines\":{\"text\":").unwrap_or_default();
79 let match_text = match_text.trim_end_matches('\n').to_owned();
80
81 let abs_path = PathBuf::from(&path_text);
82 let rel_path = abs_path
83 .strip_prefix(root)
84 .map(|p| p.to_path_buf())
85 .unwrap_or(abs_path);
86
87 Some(RgMatch {
88 path: rel_path,
89 line: line_number,
90 _col: col,
91 text: match_text,
92 })
93}
94
95/// Extract a JSON string value that immediately follows the given key pattern.
96pub fn extract_json_string(json: &str, key: &str) -> Option<String> {
97 let start = json.find(key)? + key.len();
98 let rest = &json[start..];
99 let rest = rest.trim_start();
100 if !rest.starts_with('"') {
101 return None;
102 }
103 let inner = &rest[1..];
104 let mut result = String::new();
105 let mut chars = inner.chars();
106 loop {
107 match chars.next()? {
108 '"' => break,
109 '\\' => match chars.next()? {
110 '"' => result.push('"'),
111 '\\' => result.push('\\'),
112 'n' => result.push('\n'),
113 't' => result.push('\t'),
114 c => {
115 result.push('\\');
116 result.push(c);
117 }
118 },
119 c => result.push(c),
120 }
121 }
122 Some(result)
123}
124
125/// Extract a u32 JSON number value that immediately follows the given key pattern.
126pub fn extract_json_u32(json: &str, key: &str) -> Option<u32> {
127 let start = json.find(key)? + key.len();
128 let rest = json[start..].trim_start();
129 let end = rest
130 .find(|c: char| !c.is_ascii_digit())
131 .unwrap_or(rest.len());
132 rest[..end].parse().ok()
133}
134
135/// Parse one line of `grep -rn` output (`path:line:text`).
136///
137/// Splits on `:` from the left: first segment is path, second is the 1-based
138/// line number, everything after is the matched text (which may itself contain
139/// `:`). Returns `None` for lines that don't conform (binary-file warnings,
140/// etc.).
141pub fn parse_grep_line(raw: &str, root: &Path) -> Option<RgMatch> {
142 let mut parts = raw.splitn(3, ':');
143 let path_str = parts.next()?;
144 let line_str = parts.next()?;
145 let text = parts.next().unwrap_or("").trim_end_matches('\n').to_owned();
146
147 let line: u32 = line_str.parse().ok()?;
148
149 let abs_path = PathBuf::from(path_str);
150 let rel_path = abs_path
151 .strip_prefix(root)
152 .map(|p| p.to_path_buf())
153 .unwrap_or_else(|_| abs_path);
154
155 Some(RgMatch {
156 path: rel_path,
157 line,
158 _col: 1,
159 text,
160 })
161}
162
163/// Source for the ripgrep content-search picker.
164///
165/// Bonsai-agnostic — preview returns the file contents only. The host
166/// (e.g. apps/hjkl) layers syntax spans via `preview_path`.
167pub struct RgSource {
168 root: PathBuf,
169 pub items: Arc<Mutex<Vec<RgMatch>>>,
170}
171
172impl RgSource {
173 pub fn new(root: PathBuf) -> Self {
174 Self {
175 root,
176 items: Arc::new(Mutex::new(Vec::new())),
177 }
178 }
179}
180
181impl PickerLogic for RgSource {
182 fn title(&self) -> &str {
183 "grep"
184 }
185
186 fn requery_mode(&self) -> RequeryMode {
187 RequeryMode::Spawn
188 }
189
190 fn item_count(&self) -> usize {
191 self.items.lock().map(|g| g.len()).unwrap_or(0)
192 }
193
194 fn label(&self, idx: usize) -> String {
195 self.items
196 .lock()
197 .ok()
198 .and_then(|g| {
199 g.get(idx).map(|m| {
200 let path = m.path.display().to_string();
201 let text = if m.text.chars().count() > 80 {
202 let cut: String = m.text.chars().take(79).collect();
203 format!("{cut}…")
204 } else {
205 m.text.clone()
206 };
207 // Two-cell prefix matches BufferSource's marker column
208 // so labels stay vertically aligned across pickers.
209 format!(" {}:{}: {}", path, m.line, text)
210 })
211 })
212 .unwrap_or_default()
213 }
214
215 fn match_text(&self, idx: usize) -> String {
216 self.label(idx)
217 }
218
219 fn has_preview(&self) -> bool {
220 true
221 }
222
223 fn preview(&self, idx: usize) -> (Buffer, String) {
224 let (path, line) = match self
225 .items
226 .lock()
227 .ok()
228 .and_then(|g| g.get(idx).map(|m| (m.path.clone(), m.line)))
229 {
230 Some(v) => v,
231 None => return (Buffer::new(), String::new()),
232 };
233 // Sentinel: no path means rg wasn't found.
234 if path.as_os_str().is_empty() {
235 return (Buffer::new(), String::new());
236 }
237 let abs = self.root.join(&path);
238 let (content, status) = load_preview(&abs);
239 if !status.is_empty() {
240 return (Buffer::from_str(&content), status);
241 }
242
243 // Render the full file; the picker's `preview_top_row` puts the
244 // match line near the top of the visible window. Keeping the buffer
245 // intact preserves correct gutter line numbers.
246 let mut buf = Buffer::from_str(&content);
247 let match_row = (line as usize).saturating_sub(1);
248 buf.set_cursor(hjkl_buffer::Position {
249 row: match_row,
250 col: 0,
251 });
252 (buf, String::new())
253 }
254
255 fn preview_path(&self, idx: usize) -> Option<PathBuf> {
256 let path = self
257 .items
258 .lock()
259 .ok()
260 .and_then(|g| g.get(idx).map(|m| m.path.clone()))?;
261 if path.as_os_str().is_empty() {
262 return None;
263 }
264 Some(self.root.join(path))
265 }
266
267 fn preview_top_row(&self, idx: usize) -> usize {
268 self.items
269 .lock()
270 .ok()
271 .and_then(|g| {
272 g.get(idx)
273 .map(|m| (m.line as usize).saturating_sub(1).saturating_sub(2))
274 })
275 .unwrap_or(0)
276 }
277
278 fn preview_match_row(&self, idx: usize) -> Option<usize> {
279 self.items
280 .lock()
281 .ok()
282 .and_then(|g| g.get(idx).map(|m| (m.line as usize).saturating_sub(1)))
283 }
284
285 fn select(&self, _idx: usize) -> PickerAction {
286 // RgSource is always wrapped by an app-side source (e.g.
287 // HighlightedRgSource) that overrides `select` and boxes an
288 // app-specific `AppAction`. This base impl is never called directly.
289 PickerAction::None
290 }
291
292 fn label_match_positions(&self, idx: usize, query: &str, label: &str) -> Option<Vec<usize>> {
293 if query.is_empty() {
294 return Some(Vec::new());
295 }
296 // Retrieve the text portion of the match so we can compute the prefix
297 // length and restrict highlighting to content only.
298 let text = self.items.lock().ok().and_then(|g| {
299 g.get(idx).map(|m| {
300 // Mirror the truncation applied in `label()`.
301 if m.text.chars().count() > 80 {
302 let cut: String = m.text.chars().take(79).collect();
303 format!("{cut}\u{2026}") // U+2026 HORIZONTAL ELLIPSIS
304 } else {
305 m.text.clone()
306 }
307 })
308 })?;
309
310 // The label is "path:line: text". Prefix char count = label char
311 // count minus text char count.
312 let label_chars = label.chars().count();
313 let text_chars = text.chars().count();
314 let prefix_len = label_chars.saturating_sub(text_chars);
315
316 // Build regex from query: try literal compile first, fall back to
317 // regex::escape for literal matching.
318 let re = regex::Regex::new(query)
319 .or_else(|_| regex::Regex::new(®ex::escape(query)))
320 .ok()?;
321
322 // Collect byte-offset → char-index mapping for `text` so we can
323 // convert regex byte ranges to char indices.
324 let char_byte_offsets: Vec<usize> =
325 text.char_indices().map(|(byte_off, _)| byte_off).collect();
326
327 let mut positions: Vec<usize> = Vec::new();
328 for m in re.find_iter(&text) {
329 let byte_start = m.start();
330 let byte_end = m.end();
331 // Find which char indices in `text` fall within [byte_start, byte_end).
332 for (char_i, &byte_off) in char_byte_offsets.iter().enumerate() {
333 if byte_off >= byte_start && byte_off < byte_end {
334 // Offset by prefix_len to get the char index in the label.
335 positions.push(prefix_len + char_i);
336 }
337 }
338 }
339 positions.sort_unstable();
340 positions.dedup();
341 Some(positions)
342 }
343
344 fn enumerate(
345 &mut self,
346 query: Option<&str>,
347 cancel: Arc<AtomicBool>,
348 ) -> Option<JoinHandle<()>> {
349 // NOTE: Do NOT clear items here. The clear is deferred into the spawn
350 // closure so that the previous results stay visible until the first
351 // new batch arrives, preventing a flash-on-each-keystroke.
352 // If the query is empty, clear synchronously (nothing to show).
353 let q = match query {
354 Some(q) if !q.trim().is_empty() => q.to_owned(),
355 // Empty query → clear and show nothing.
356 _ => {
357 if let Ok(mut g) = self.items.lock() {
358 g.clear();
359 }
360 return None;
361 }
362 };
363
364 let items = Arc::clone(&self.items);
365 let root = self.root.clone();
366
367 thread::Builder::new()
368 .name("hjkl-rg-scan".into())
369 .spawn(move || {
370 use std::io::{BufRead, BufReader};
371 use std::process::Stdio;
372
373 let backend = detect_grep_backend();
374
375 match backend {
376 GrepBackend::Rg => {
377 let child = std::process::Command::new("rg")
378 .args([
379 "--json",
380 "--no-config",
381 "--smart-case",
382 "--max-count",
383 "200",
384 &q,
385 root.to_str().unwrap_or("."),
386 ])
387 .stdout(Stdio::piped())
388 .stderr(Stdio::null())
389 .spawn();
390
391 let mut child = match child {
392 Ok(c) => c,
393 Err(_) => {
394 // Spawn failed — clear stale results.
395 if let Ok(mut g) = items.lock() {
396 g.clear();
397 }
398 return;
399 }
400 };
401
402 let stdout = match child.stdout.take() {
403 Some(s) => s,
404 None => {
405 if let Ok(mut g) = items.lock() {
406 g.clear();
407 }
408 return;
409 }
410 };
411
412 let reader = BufReader::new(stdout);
413 let mut batch: Vec<RgMatch> = Vec::with_capacity(32);
414 // Cleared atomically on first push so old results
415 // remain visible during rg startup latency.
416 let mut first_push_done = false;
417
418 for line_result in reader.lines() {
419 if cancel.load(Ordering::Acquire) {
420 let _ = child.kill();
421 return;
422 }
423 let line = match line_result {
424 Ok(l) => l,
425 Err(_) => continue,
426 };
427 if let Some(rg_match) = parse_rg_json_line(&line, &root) {
428 batch.push(rg_match);
429 if batch.len() >= 32
430 && let Ok(mut g) = items.lock()
431 {
432 if !first_push_done {
433 g.clear();
434 first_push_done = true;
435 }
436 g.extend(batch.drain(..));
437 }
438 }
439 if cancel.load(Ordering::Acquire) {
440 let _ = child.kill();
441 return;
442 }
443 }
444 // Flush remaining batch.
445 if !batch.is_empty()
446 && let Ok(mut g) = items.lock()
447 {
448 if !first_push_done {
449 g.clear();
450 first_push_done = true;
451 }
452 g.extend(batch.drain(..));
453 }
454 // If rg exited with zero matches, clear stale results.
455 if !first_push_done
456 && let Ok(mut g) = items.lock()
457 {
458 g.clear();
459 }
460 let _ = child.wait();
461 }
462
463 GrepBackend::Grep => {
464 let child = std::process::Command::new("grep")
465 .args([
466 "-rn",
467 "-E",
468 "--color=never",
469 &q,
470 root.to_str().unwrap_or("."),
471 ])
472 .stdout(Stdio::piped())
473 .stderr(Stdio::null())
474 .spawn();
475
476 let mut child = match child {
477 Ok(c) => c,
478 Err(_) => {
479 if let Ok(mut g) = items.lock() {
480 g.clear();
481 }
482 return;
483 }
484 };
485
486 let stdout = match child.stdout.take() {
487 Some(s) => s,
488 None => {
489 if let Ok(mut g) = items.lock() {
490 g.clear();
491 }
492 return;
493 }
494 };
495
496 let reader = BufReader::new(stdout);
497 let mut batch: Vec<RgMatch> = Vec::with_capacity(32);
498 let mut total = 0usize;
499 let mut first_push_done = false;
500 const GREP_CAP: usize = 1000;
501
502 for line_result in reader.lines() {
503 if cancel.load(Ordering::Acquire) {
504 let _ = child.kill();
505 return;
506 }
507 let raw = match line_result {
508 Ok(l) => l,
509 Err(_) => continue,
510 };
511 if raw.is_empty() {
512 continue;
513 }
514 // Format: path:line_number:text
515 // Split on ':' from the left, first two segments
516 // are path and line number; rest is text (may
517 // contain ':'). Skip lines that don't conform
518 // (binary file warnings, etc.).
519 if let Some(m) = parse_grep_line(&raw, &root) {
520 batch.push(m);
521 total += 1;
522 if batch.len() >= 32
523 && let Ok(mut g) = items.lock()
524 {
525 if !first_push_done {
526 g.clear();
527 first_push_done = true;
528 }
529 g.extend(batch.drain(..));
530 }
531 if total >= GREP_CAP {
532 let _ = child.kill();
533 break;
534 }
535 }
536 if cancel.load(Ordering::Acquire) {
537 let _ = child.kill();
538 return;
539 }
540 }
541 // Flush remaining batch.
542 if !batch.is_empty()
543 && let Ok(mut g) = items.lock()
544 {
545 if !first_push_done {
546 g.clear();
547 first_push_done = true;
548 }
549 g.extend(batch.drain(..));
550 }
551 if !first_push_done
552 && let Ok(mut g) = items.lock()
553 {
554 g.clear();
555 }
556 let _ = child.wait();
557 }
558
559 GrepBackend::Findstr => {
560 // Windows-native findstr: findstr /S /N /R <pattern> <root>\*
561 // Output format: path:line:text — same as grep -n, reuse parse_grep_line.
562 let search_glob = root.join("*");
563 let child = std::process::Command::new("findstr")
564 .args([
565 "/S",
566 "/N",
567 "/R",
568 &q,
569 search_glob.to_str().unwrap_or("*"),
570 ])
571 .stdout(Stdio::piped())
572 .stderr(Stdio::null())
573 .spawn();
574
575 let mut child = match child {
576 Ok(c) => c,
577 Err(_) => {
578 if let Ok(mut g) = items.lock() {
579 g.clear();
580 }
581 return;
582 }
583 };
584
585 let stdout = match child.stdout.take() {
586 Some(s) => s,
587 None => {
588 if let Ok(mut g) = items.lock() {
589 g.clear();
590 }
591 return;
592 }
593 };
594
595 let reader = BufReader::new(stdout);
596 let mut batch: Vec<RgMatch> = Vec::with_capacity(32);
597 let mut total = 0usize;
598 let mut first_push_done = false;
599 const FINDSTR_CAP: usize = 1000;
600
601 for line_result in reader.lines() {
602 if cancel.load(Ordering::Acquire) {
603 let _ = child.kill();
604 return;
605 }
606 let raw = match line_result {
607 Ok(l) => l,
608 Err(_) => continue,
609 };
610 if raw.is_empty() {
611 continue;
612 }
613 if let Some(m) = parse_grep_line(&raw, &root) {
614 batch.push(m);
615 total += 1;
616 if batch.len() >= 32
617 && let Ok(mut g) = items.lock()
618 {
619 if !first_push_done {
620 g.clear();
621 first_push_done = true;
622 }
623 g.extend(batch.drain(..));
624 }
625 if total >= FINDSTR_CAP {
626 let _ = child.kill();
627 break;
628 }
629 }
630 if cancel.load(Ordering::Acquire) {
631 let _ = child.kill();
632 return;
633 }
634 }
635 // Flush remaining batch.
636 if !batch.is_empty()
637 && let Ok(mut g) = items.lock()
638 {
639 if !first_push_done {
640 g.clear();
641 first_push_done = true;
642 }
643 g.extend(batch.drain(..));
644 }
645 if !first_push_done
646 && let Ok(mut g) = items.lock()
647 {
648 g.clear();
649 }
650 let _ = child.wait();
651 }
652
653 GrepBackend::Neither => {
654 // No search tool found — push sentinel item.
655 // Clear first so the sentinel replaces stale results.
656 if let Ok(mut g) = items.lock() {
657 g.clear();
658 g.push(RgMatch {
659 path: PathBuf::new(),
660 line: 0,
661 _col: 0,
662 text: "no grep tool found — install ripgrep, grep, or findstr to use :rg"
663 .into(),
664 });
665 }
666 }
667 }
668 })
669 .ok()
670 }
671}