Mountain/Environment/
SearchProvider.rs

1// File: Mountain/Source/Environment/SearchProvider.rs
2// Role: Implements the `SearchProvider` trait for `MountainEnvironment`.
3// Responsibilities:
4//   - Perform workspace-wide text searches using `grep-searcher` (the `ripgrep`
5//     library).
6//   - Respect workspace folders and standard ignore files (`.gitignore`).
7//   - Collect and format search results into a DTO suitable for the frontend.
8
9//! # SearchProvider Implementation
10//!
11//! Implements the `SearchProvider` trait using the `grep-searcher` crate, which
12//! is a library for the `ripgrep` search tool.
13
14#![allow(non_snake_case, non_camel_case_types)]
15
16use std::{
17	io,
18	path::PathBuf,
19	sync::{Arc, Mutex},
20};
21
22use Common::{Error::CommonError::CommonError, Search::SearchProvider::SearchProvider};
23use async_trait::async_trait;
24use grep_regex::RegexMatcherBuilder;
25use grep_searcher::{Searcher, Sink, SinkMatch};
26use ignore::WalkBuilder;
27use log::{info, warn};
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30
31use super::{MountainEnvironment::MountainEnvironment, Utility};
32
33#[derive(Deserialize, Debug)]
34#[serde(rename_all = "camelCase")]
35struct TextSearchQuery {
36	pattern:String,
37
38	is_case_sensitive:Option<bool>,
39
40	is_word_match:Option<bool>,
41}
42
43#[derive(Serialize, Clone, Debug)]
44#[serde(rename_all = "camelCase")]
45struct TextMatch {
46	preview:String,
47
48	line_number:u64,
49}
50
51#[derive(Serialize, Clone, Debug)]
52#[serde(rename_all = "camelCase")]
53struct FileMatch {
54	// URI
55	resource:String,
56
57	matches:Vec<TextMatch>,
58}
59
60// This Sink is designed to be created for each file. It holds a reference to
61// the central results vector and the path of the file it's searching.
62struct PerFileSink {
63	path:PathBuf,
64
65	results:Arc<Mutex<Vec<FileMatch>>>,
66}
67
68impl Sink for PerFileSink {
69	type Error = io::Error;
70
71	fn matched(&mut self, _Searcher:&Searcher, Mat:&SinkMatch<'_>) -> Result<bool, Self::Error> {
72		let mut ResultsGuard = self
73			.results
74			.lock()
75			.map_err(|Error| io::Error::new(io::ErrorKind::Other, Error.to_string()))?;
76
77		let Preview = String::from_utf8_lossy(Mat.bytes()).to_string();
78
79		let LineNumber = Mat.line_number().unwrap_or(0);
80
81		// Since this sink is per-file, we know `self.path` is correct.
82		let FileURI = url::Url::from_file_path(&self.path)
83			.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Could not convert path to URL"))?
84			.to_string();
85
86		// Find the entry for our file, or create it if it's the first match.
87		if let Some(FileMatch) = ResultsGuard.iter_mut().find(|fm| fm.resource == FileURI) {
88			FileMatch.matches.push(TextMatch { preview:Preview, line_number:LineNumber });
89		} else {
90			ResultsGuard.push(FileMatch {
91				resource:FileURI,
92
93				matches:vec![TextMatch { preview:Preview, line_number:LineNumber }],
94			});
95		}
96
97		// Continue searching
98		Ok(true)
99	}
100}
101
102#[async_trait]
103impl SearchProvider for MountainEnvironment {
104	async fn TextSearch(&self, QueryValue:Value, _OptionsValue:Value) -> Result<Value, CommonError> {
105		let Query:TextSearchQuery = serde_json::from_value(QueryValue)?;
106
107		info!("[SearchProvider] Performing text search for: {:?}", Query);
108
109		let mut Builder = RegexMatcherBuilder::new();
110
111		Builder
112			.case_insensitive(!Query.is_case_sensitive.unwrap_or(false))
113			.word(Query.is_word_match.unwrap_or(false));
114
115		let Matcher = Builder.build(&Query.pattern).map_err(|Error| {
116			CommonError::InvalidArgument { ArgumentName:"pattern".into(), Reason:Error.to_string() }
117		})?;
118
119		let AllMatches = Arc::new(Mutex::new(Vec::<FileMatch>::new()));
120
121		let Folders = self
122			.ApplicationState
123			.WorkSpaceFolders
124			.lock()
125			.map_err(Utility::MapApplicationStateLockErrorToCommonError)?
126			.clone();
127
128		if Folders.is_empty() {
129			warn!("[SearchProvider] No workspace folders to search in.");
130
131			return Ok(json!([]));
132		}
133
134		for Folder in Folders {
135			if let Ok(FolderPath) = Folder.URI.to_file_path() {
136				// Use a parallel walker for better performance.
137				let Walker = WalkBuilder::new(FolderPath).build_parallel();
138
139				// The `search_parallel` method is not available on `Searcher`. We must process
140				// entries from the walker and call `search_path` individually.
141				Walker.run(|| {
142					let mut Searcher = Searcher::new();
143
144					let Matcher = Matcher.clone();
145
146					let AllMatches = AllMatches.clone();
147
148					Box::new(move |EntryResult| {
149						if let Ok(Entry) = EntryResult {
150							if Entry.file_type().map_or(false, |ft| ft.is_file()) {
151								// For each file, create a new sink that knows its path.
152								let Sink = PerFileSink { path:Entry.path().to_path_buf(), results:AllMatches.clone() };
153
154								if let Err(Error) = Searcher.search_path(&Matcher, Entry.path(), Sink) {
155									warn!(
156										"[SearchProvider] Error searching path {}: {}",
157										Entry.path().display(),
158										Error
159									);
160								}
161							}
162						}
163
164						ignore::WalkState::Continue
165					})
166				});
167			}
168		}
169
170		let FinalMatches = AllMatches
171			.lock()
172			.map_err(|Error| CommonError::StateLockPoisoned { Context:Error.to_string() })?
173			.clone();
174
175		Ok(json!(FinalMatches))
176	}
177}