vtcode_core/tools/
grep_search.rs

1//! Helper that owns the debounce/cancellation logic for `rp_search` operations.
2//!
3//! This module manages the orchestration of ripgrep searches, implementing
4//! debounce and cancellation logic to ensure responsive and efficient searches.
5//!
6//! It works as follows:
7//! 1. First query starts a debounce timer.
8//! 2. While the timer is pending, the latest query from the user is stored.
9//! 3. When the timer fires, it is cleared, and a search is done for the most
10//!    recent query.
11//! 4. If there is an in-flight search that is not a prefix of the latest thing
12//!    the user typed, it is cancelled.
13
14use anyhow::Result;
15use serde_json;
16use std::num::NonZeroUsize;
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::sync::Mutex;
20use std::sync::atomic::AtomicBool;
21use std::sync::atomic::Ordering;
22use std::thread;
23use std::time::Duration;
24
25/// Maximum number of search results to return
26const MAX_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(100).unwrap();
27
28/// Number of threads to use for searching
29const NUM_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap();
30
31/// How long to wait after a keystroke before firing the first search when none
32/// is currently running. Keeps early queries more meaningful.
33const SEARCH_DEBOUNCE: Duration = Duration::from_millis(150);
34
35/// Poll interval when waiting for an active search to complete
36const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20);
37
38/// Input parameters for ripgrep search
39#[derive(Debug, Clone)]
40pub struct GrepSearchInput {
41    pub pattern: String,
42    pub path: String,
43    pub case_sensitive: Option<bool>,
44    pub literal: Option<bool>,
45    pub glob_pattern: Option<String>,
46    pub context_lines: Option<usize>,
47    pub include_hidden: Option<bool>,
48    pub max_results: Option<usize>,
49}
50
51/// Result of a ripgrep search
52#[derive(Debug, Clone)]
53pub struct GrepSearchResult {
54    pub query: String,
55    pub matches: Vec<serde_json::Value>,
56}
57
58/// State machine for grep_search orchestration.
59pub struct GrepSearchManager {
60    /// Unified state guarded by one mutex.
61    state: Arc<Mutex<SearchState>>,
62
63    search_dir: PathBuf,
64}
65
66struct SearchState {
67    /// Latest query typed by user (updated every keystroke).
68    latest_query: String,
69
70    /// true if a search is currently scheduled.
71    is_search_scheduled: bool,
72
73    /// If there is an active search, this will be the query being searched.
74    active_search: Option<ActiveSearch>,
75    last_result: Option<GrepSearchResult>,
76}
77
78struct ActiveSearch {
79    query: String,
80    cancellation_token: Arc<AtomicBool>,
81}
82
83impl GrepSearchManager {
84    pub fn new(search_dir: PathBuf) -> Self {
85        Self {
86            state: Arc::new(Mutex::new(SearchState {
87                latest_query: String::new(),
88                is_search_scheduled: false,
89                active_search: None,
90                last_result: None,
91            })),
92            search_dir,
93        }
94    }
95
96    /// Call whenever the user edits the search query.
97    pub fn on_user_query(&self, query: String) {
98        {
99            #[expect(clippy::unwrap_used)]
100            let mut st = self.state.lock().unwrap();
101            if query == st.latest_query {
102                // No change, nothing to do.
103                return;
104            }
105
106            // Update latest query.
107            st.latest_query.clear();
108            st.latest_query.push_str(&query);
109
110            // If there is an in-flight search that is definitely obsolete,
111            // cancel it now.
112            if let Some(active_search) = &st.active_search
113                && !query.starts_with(&active_search.query)
114            {
115                active_search
116                    .cancellation_token
117                    .store(true, Ordering::Relaxed);
118                st.active_search = None;
119            }
120
121            // Schedule a search to run after debounce.
122            if !st.is_search_scheduled {
123                st.is_search_scheduled = true;
124            } else {
125                return;
126            }
127        }
128
129        // If we are here, we set `st.is_search_scheduled = true` before
130        // dropping the lock. This means we are the only thread that can spawn a
131        // debounce timer.
132        let state = self.state.clone();
133        let search_dir = self.search_dir.clone();
134        thread::spawn(move || {
135            // Always do a minimum debounce, but then poll until the
136            // `active_search` is cleared.
137            thread::sleep(SEARCH_DEBOUNCE);
138            loop {
139                #[expect(clippy::unwrap_used)]
140                if state.lock().unwrap().active_search.is_none() {
141                    break;
142                }
143                thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL);
144            }
145
146            // The debounce timer has expired, so start a search using the
147            // latest query.
148            let cancellation_token = Arc::new(AtomicBool::new(false));
149            let token = cancellation_token.clone();
150            let query = {
151                #[expect(clippy::unwrap_used)]
152                let mut st = state.lock().unwrap();
153                let query = st.latest_query.clone();
154                st.is_search_scheduled = false;
155                st.active_search = Some(ActiveSearch {
156                    query: query.clone(),
157                    cancellation_token: token,
158                });
159                query
160            };
161
162            GrepSearchManager::spawn_rp_search(query, search_dir, cancellation_token, state);
163        });
164    }
165
166    /// Retrieve the last successful search result
167    pub fn last_result(&self) -> Option<GrepSearchResult> {
168        #[expect(clippy::unwrap_used)]
169        let st = self.state.lock().unwrap();
170        st.last_result.clone()
171    }
172
173    fn spawn_rp_search(
174        query: String,
175        search_dir: PathBuf,
176        cancellation_token: Arc<AtomicBool>,
177        search_state: Arc<Mutex<SearchState>>,
178    ) {
179        use std::process::Command;
180
181        thread::spawn(move || {
182            // Check if cancelled before starting
183            if cancellation_token.load(Ordering::Relaxed) {
184                // Reset the active search state
185                {
186                    #[expect(clippy::unwrap_used)]
187                    let mut st = search_state.lock().unwrap();
188                    if let Some(active_search) = &st.active_search
189                        && Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token)
190                    {
191                        st.active_search = None;
192                    }
193                }
194                return;
195            }
196
197            // Build the ripgrep command
198            let mut cmd = Command::new("rg");
199            cmd.arg("-j").arg(NUM_SEARCH_THREADS.get().to_string());
200
201            // Add the search pattern
202            cmd.arg(&query);
203
204            // Add the search path
205            cmd.arg(search_dir.to_string_lossy().as_ref());
206
207            // Output as JSON for easier parsing
208            cmd.arg("--json");
209
210            // Set result limits
211            cmd.arg("--max-count")
212                .arg(MAX_SEARCH_RESULTS.get().to_string());
213
214            // Execute the command
215            let output = cmd.output();
216
217            let is_cancelled = cancellation_token.load(Ordering::Relaxed);
218            if !is_cancelled
219                && let Ok(output) = output
220                && output.status.success()
221            {
222                let output_str = String::from_utf8_lossy(&output.stdout);
223                let mut matches = Vec::new();
224                for line in output_str.lines() {
225                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
226                        matches.push(val);
227                    }
228                }
229                let result = GrepSearchResult {
230                    query: query.clone(),
231                    matches,
232                };
233                #[expect(clippy::unwrap_used)]
234                let mut st = search_state.lock().unwrap();
235                st.last_result = Some(result);
236            }
237
238            // Reset the active search state
239            {
240                #[expect(clippy::unwrap_used)]
241                let mut st = search_state.lock().unwrap();
242                if let Some(active_search) = &st.active_search
243                    && Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token)
244                {
245                    st.active_search = None;
246                }
247            }
248        });
249    }
250
251    /// Perform an actual ripgrep search with the given input parameters
252    pub async fn perform_search(&self, input: GrepSearchInput) -> Result<GrepSearchResult> {
253        // std::path::Path import removed as it's not directly used
254        use std::process::Command;
255
256        // Build the ripgrep command
257        let mut cmd = Command::new("rg");
258
259        // Add the search pattern
260        cmd.arg(&input.pattern);
261
262        // Add the search path
263        cmd.arg(&input.path);
264
265        // Add optional flags
266        if let Some(case_sensitive) = input.case_sensitive {
267            if case_sensitive {
268                cmd.arg("--case-sensitive");
269            } else {
270                cmd.arg("--ignore-case");
271            }
272        }
273
274        if let Some(literal) = input.literal
275            && literal
276        {
277            cmd.arg("--fixed-strings");
278        }
279
280        if let Some(glob_pattern) = &input.glob_pattern {
281            cmd.arg("--glob").arg(glob_pattern);
282        }
283
284        if let Some(context_lines) = input.context_lines {
285            cmd.arg("--context").arg(context_lines.to_string());
286        }
287
288        if let Some(include_hidden) = input.include_hidden
289            && include_hidden
290        {
291            cmd.arg("--hidden");
292        }
293
294        // Set result limits
295        let max_results = input.max_results.unwrap_or(MAX_SEARCH_RESULTS.get());
296        cmd.arg("--max-count").arg(max_results.to_string());
297
298        // Output as JSON for easier parsing
299        cmd.arg("--json");
300
301        // Execute the command
302        let output = cmd.output()?;
303
304        if !output.status.success() {
305            // If ripgrep is not found, return an error
306            if String::from_utf8_lossy(&output.stderr).contains("not found") {
307                return Err(anyhow::anyhow!(
308                    "ripgrep (rg) command not found. Please install ripgrep to use search functionality."
309                ));
310            }
311
312            // For other errors, still return results but with a warning
313        }
314
315        // Parse the JSON output
316        let output_str = String::from_utf8_lossy(&output.stdout);
317        let mut matches = Vec::new();
318
319        for line in output_str.lines() {
320            if !line.trim().is_empty()
321                && let Ok(json_value) = serde_json::from_str::<serde_json::Value>(line)
322            {
323                matches.push(json_value);
324            }
325        }
326
327        Ok(GrepSearchResult {
328            query: input.pattern,
329            matches,
330        })
331    }
332}