Skip to main content

reovim_picker_grep/
lib.rs

1#![cfg_attr(coverage_nightly, allow(unused_features))]
2#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
3//! Grep picker module for reovim.
4//!
5//! Provides content search using ripgrep subprocess.
6//! Registers `GrepPicker` in the `PickerRegistry` during module init.
7
8use std::{
9    fs,
10    io::{BufRead, BufReader},
11    path::Path,
12    process::Command,
13    sync::Arc,
14};
15
16use {
17    reovim_driver_picker::{
18        Picker, PickerAction, PickerContext, PickerData, PickerItem, PickerRegistry,
19        PreviewContent, SessionRuntime,
20    },
21    reovim_driver_session::{BufferApi, ChangeTracker, WindowApi},
22    reovim_driver_vfs::VfsInstance,
23    reovim_kernel::api::v1::{Module, ModuleContext, ModuleError, ModuleId, ProbeResult, Version},
24};
25
26/// Number of context lines to show around a grep match in preview.
27const PREVIEW_CONTEXT_LINES: usize = 5;
28
29/// Maximum number of grep results to return.
30const MAX_RESULTS: usize = 1000;
31
32// ============================================================================
33// GrepPicker
34// ============================================================================
35
36/// Picker that searches file contents using ripgrep.
37///
38/// This is a dynamic picker (`is_static() = false`) that re-fetches
39/// results whenever the query changes.
40pub struct GrepPicker;
41
42impl GrepPicker {
43    /// Create a new instance.
44    #[must_use]
45    pub const fn new() -> Self {
46        Self
47    }
48}
49
50impl Default for GrepPicker {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl Picker for GrepPicker {
57    fn name(&self) -> &'static str {
58        "grep"
59    }
60
61    fn title(&self) -> &'static str {
62        "Grep"
63    }
64
65    fn prompt(&self) -> &'static str {
66        "rg> "
67    }
68
69    fn items(
70        &self,
71        ctx: &PickerContext,
72        _services: &reovim_kernel::api::v1::ServiceRegistry,
73    ) -> Vec<PickerItem> {
74        if ctx.query.is_empty() {
75            return Vec::new();
76        }
77
78        run_ripgrep(&ctx.query, &ctx.cwd)
79    }
80
81    fn on_select(&self, item: &PickerItem) -> PickerAction {
82        match &item.data {
83            PickerData::GotoLocation { path, line, col } => PickerAction::GotoLocation {
84                path: path.clone(),
85                line: *line,
86                col: *col,
87            },
88            _ => PickerAction::Close,
89        }
90    }
91
92    fn preview(
93        &self,
94        item: &PickerItem,
95        _services: &reovim_kernel::api::v1::ServiceRegistry,
96    ) -> Option<PreviewContent> {
97        let PickerData::GotoLocation { path, line, .. } = &item.data else {
98            return None;
99        };
100
101        let file = fs::File::open(path).ok()?;
102        let reader = BufReader::new(file);
103
104        let start = line.saturating_sub(PREVIEW_CONTEXT_LINES);
105        let end = line + PREVIEW_CONTEXT_LINES;
106
107        let lines: Vec<String> = reader
108            .lines()
109            .skip(start)
110            .take(end - start + 1)
111            .filter_map(Result::ok)
112            .collect();
113
114        if lines.is_empty() {
115            return None;
116        }
117
118        // Highlight line is relative to the start of preview.
119        let highlight = line.saturating_sub(start);
120
121        Some(PreviewContent {
122            lines,
123            highlight_line: Some(highlight),
124            file_path: Some(path.clone()),
125            ..Default::default()
126        })
127    }
128
129    fn is_static(&self) -> bool {
130        false
131    }
132
133    #[cfg_attr(coverage_nightly, coverage(off))]
134    fn execute(&self, action: PickerAction, runtime: &mut SessionRuntime<'_>) {
135        if let PickerAction::GotoLocation { path, line, col } = action {
136            open_file(runtime, &path);
137            // Set cursor position (1-based from rg -> 0-based).
138            if let Some(window) = runtime.windows_mut().active_mut() {
139                window.cursor.line = line.saturating_sub(1);
140                window.cursor.column = col.saturating_sub(1);
141            }
142            if let Some(buf_id) = runtime.active_buffer() {
143                runtime.record_cursor_move(buf_id);
144            }
145        }
146    }
147}
148
149/// Run ripgrep and parse results.
150///
151/// Uses `--` to separate the pattern from arguments (prevents shell injection).
152/// Returns empty vec if rg is not found.
153fn run_ripgrep(query: &str, cwd: &std::path::Path) -> Vec<PickerItem> {
154    let output = Command::new("rg")
155        .args(["--line-number", "--column", "--no-heading", "--", query])
156        .current_dir(cwd)
157        .output();
158
159    let Ok(output) = output else {
160        // rg not found or failed to execute.
161        return Vec::new();
162    };
163
164    let stdout = String::from_utf8_lossy(&output.stdout);
165    stdout
166        .lines()
167        .take(MAX_RESULTS)
168        .filter_map(|line| parse_rg_line(line, cwd))
169        .collect()
170}
171
172/// Parse a single ripgrep output line.
173///
174/// Format: `path:line:col:text`
175fn parse_rg_line(line: &str, cwd: &std::path::Path) -> Option<PickerItem> {
176    // Split on first three colons: path:line:col:text
177    let mut parts = line.splitn(4, ':');
178    let path_str = parts.next()?;
179    let line_str = parts.next()?;
180    let col_str = parts.next()?;
181    let text = parts.next().unwrap_or("");
182
183    let line_num: usize = line_str.parse().ok()?;
184    let col_num: usize = col_str.parse().ok()?;
185    let path = cwd.join(path_str);
186
187    Some(PickerItem {
188        display: format!("{path_str}:{line_num}:{text}"),
189        detail: None,
190        data: PickerData::GotoLocation {
191            path,
192            line: line_num,
193            col: col_num,
194        },
195        icon: None,
196    })
197}
198
199// ============================================================================
200// open_file utility (duplicated from picker-files for independence)
201// ============================================================================
202
203/// Open a file by path, reusing existing buffers when possible.
204#[cfg_attr(coverage_nightly, coverage(off))]
205fn open_file(runtime: &mut SessionRuntime<'_>, path: &Path) {
206    use reovim_kernel::api::v1::events::kernel::FileOpened;
207
208    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
209    let path_str = canonical.to_string_lossy();
210
211    let existing = runtime.kernel().buffers.list().into_iter().find(|&id| {
212        runtime
213            .buffer_file_path(id)
214            .is_some_and(|p| Path::new(&p) == canonical)
215    });
216
217    let is_new = existing.is_none();
218    let buf_id = existing.unwrap_or_else(|| {
219        let content = runtime
220            .kernel()
221            .services
222            .get::<VfsInstance>()
223            .and_then(|vfs| vfs.driver().read_to_string(&canonical).ok())
224            .unwrap_or_default();
225        let id = runtime.create_buffer(Some(&path_str), &content);
226        runtime.set_buffer_modified(id, false);
227        id
228    });
229
230    if let Some(win) = runtime.active_window() {
231        let _ = runtime.set_window_buffer(win, buf_id);
232    }
233    runtime.set_active_buffer(Some(buf_id));
234
235    if is_new {
236        runtime.record_buffer_modified(buf_id);
237        #[allow(clippy::cast_possible_truncation)]
238        let buf_id_raw = buf_id.as_usize() as u64;
239        runtime.kernel().event_bus.emit(FileOpened {
240            buffer_id: buf_id_raw,
241            path: path_str.to_string(),
242        });
243    }
244}
245
246// ============================================================================
247// Module implementation
248// ============================================================================
249
250/// Grep picker module.
251///
252/// Registers `GrepPicker` in `PickerRegistry` during init.
253pub struct PickerGrepModule;
254
255impl PickerGrepModule {
256    /// Create a new instance.
257    #[must_use]
258    pub const fn new() -> Self {
259        Self
260    }
261}
262
263impl Default for PickerGrepModule {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269impl Module for PickerGrepModule {
270    fn id(&self) -> ModuleId {
271        ModuleId::new("picker-grep")
272    }
273
274    fn name(&self) -> &'static str {
275        "Grep Picker"
276    }
277
278    fn version(&self) -> Version {
279        Version::new(0, 1, 0)
280    }
281
282    fn init(&mut self, ctx: &ModuleContext) -> ProbeResult {
283        let registry = ctx.services.get_or_create::<PickerRegistry>();
284        registry.register(Arc::new(GrepPicker));
285        ProbeResult::Success
286    }
287
288    fn exit(&mut self) -> Result<(), ModuleError> {
289        Ok(())
290    }
291}
292
293#[cfg(feature = "dynamic")]
294reovim_module_macros::declare_module!(PickerGrepModule);
295
296#[cfg(test)]
297#[path = "lib_tests.rs"]
298mod tests;