Skip to main content

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(&regex::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}