use crate::core::args_ref::read_text_slice;
use crate::core::batch::{SEARCH_PLAN, available_parallelism, create_batch_response, run_batch_parallel};
use crate::core::config::ensure_path_allowed;
use crate::core::response::RawResult;
use grep_matcher::{Match, Matcher, NoCaptures, NoError};
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
use ignore::WalkState;
use ignore::overrides::OverrideBuilder;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
const SEARCH_BACKEND: &str = "native-grep";
const SEARCH_TIMEOUT_MS: u64 = 25_000;
#[derive(Clone, Debug)]
struct SearchSession {
lines: Vec<String>,
backend: String,
}
pub fn handle_fs_search(args: &Value) -> RawResult {
let Some(items) = args.get("items").and_then(Value::as_array) else {
return RawResult::error(
"items must be an array; wrap a single operation as items:[{...}]",
);
};
let results = run_batch_parallel(items, SEARCH_PLAN, regex_item);
create_batch_response("fs-search", results, true)
}
fn regex_item(item: &Value) -> RawResult {
let search = match run_regex_search(item) {
Ok(search) => search,
Err(error) => return RawResult::error(error),
};
let text = search.lines.join("\n");
let backend = search.backend;
let total = search.lines.len();
let mut result = RawResult::structured(
text,
json!({
"backend": &backend,
"totalCount": total
}),
);
result.meta.insert("backend".to_string(), json!(backend));
result
}
struct SearchSpec {
root: PathBuf,
include_hidden: bool,
file_globs: Vec<String>,
default_excludes: bool,
context: usize,
max_results: usize,
timeout_ms: u64,
multiline: bool,
}
fn run_regex_search(item: &Value) -> Result<SearchSession, String> {
let Some(path) = item.get("path").and_then(Value::as_str) else {
return Err("path must be a string".to_string());
};
let root = ensure_path_allowed(path)?;
let pattern = read_pattern(item)?;
let opts = PatternOpts {
ignore_case: bool_field(item, "ignoreCase", true),
literal: bool_field(item, "literal", false),
multiline: bool_field(item, "multiline", false),
word_match: bool_field(item, "wordMatch", false),
};
let include_hidden = bool_field(item, "includeHidden", false);
let max_results = item
.get("maxResults")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(usize::MAX);
if max_results == 0 {
return Ok(SearchSession {
lines: Vec::new(),
backend: SEARCH_BACKEND.to_string(),
});
}
let spec = SearchSpec {
include_hidden,
file_globs: split_patterns(item.get("filePattern").and_then(Value::as_str)),
default_excludes: !bool_field(item, "noDefaultExcludes", false) && !path_in_heavy_dir(&root),
context: item
.get("contextLines")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(2),
max_results,
timeout_ms: item
.get("timeout_ms")
.and_then(Value::as_u64)
.unwrap_or(SEARCH_TIMEOUT_MS),
multiline: opts.multiline,
root,
};
let (matcher, backend) = if opts.literal {
let matcher = build_matcher(&pattern, &opts, true)
.map_err(|error| format!("Invalid literal pattern: {error}"))?;
(matcher, format!("{SEARCH_BACKEND} (literal)"))
}
else {
match build_matcher(&pattern, &opts, false) {
Ok(matcher) => (matcher, SEARCH_BACKEND.to_string()),
Err(error) => {
if let Some(hint) = reject_unsupported(&error) {
return Err(hint);
}
let matcher = build_matcher(&pattern, &opts, true)
.map_err(|inner| format!("Invalid search pattern: {inner}"))?;
(
matcher,
format!("{SEARCH_BACKEND} (literal fallback: {})", parse_error_gist(&error)),
)
}
}
};
let outcome = run_native_search(&spec, &matcher)?;
if outcome.timed_out {
return Err(format!(
"{SEARCH_BACKEND} timed out after {}ms; narrow the search path, add filePattern, or raise timeout_ms",
spec.timeout_ms
));
}
let backend = if outcome.partial {
format!("{backend} (partial: some files were unreadable)")
}
else {
backend
};
Ok(SearchSession {
lines: outcome.lines,
backend,
})
}
#[derive(Clone, Debug)]
pub(crate) struct RegexAdapter {
pub(crate) regex: regex::bytes::Regex,
}
impl Matcher for RegexAdapter {
type Captures = NoCaptures;
type Error = NoError;
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, NoError> {
Ok(self
.regex
.find_at(haystack, at)
.map(|found| Match::new(found.start(), found.end())))
}
fn new_captures(&self) -> Result<NoCaptures, NoError> {
Ok(NoCaptures::new())
}
}
struct PatternOpts {
ignore_case: bool,
literal: bool,
multiline: bool,
word_match: bool,
}
fn build_matcher(pattern: &str, opts: &PatternOpts, force_literal: bool) -> Result<RegexAdapter, regex::Error> {
let mut source = if opts.literal || force_literal {
regex::escape(pattern)
}
else {
pattern.to_string()
};
if opts.word_match {
source = format!(r"\b(?:{source})\b");
}
let regex = regex::bytes::RegexBuilder::new(&source)
.case_insensitive(opts.ignore_case)
.multi_line(true)
.dot_matches_new_line(opts.multiline)
.build()?;
Ok(RegexAdapter { regex })
}
fn reject_unsupported(error: ®ex::Error) -> Option<String> {
if let regex::Error::CompiledTooBig(limit) = error {
return Some(format!(
"pattern compiles past the {limit}-byte engine limit; shrink bounded repetitions or split the search"
));
}
let text = error.to_string();
if text.contains("look-around") {
return Some(
"pattern uses look-around, which this linear engine (Rust regex syntax) does not support; \
match the surrounding text with plain groups and filter afterwards, or set literal:true for exact text"
.to_string(),
);
}
if text.contains("backreference") {
return Some(
"pattern uses backreferences, which this linear engine (Rust regex syntax) does not support; \
repeat the subpattern explicitly or run a second confirming search"
.to_string(),
);
}
None
}
fn parse_error_gist(error: ®ex::Error) -> String {
let text = error.to_string();
text.lines()
.rev()
.find_map(|line| line.strip_prefix("error: "))
.unwrap_or("regex parse error")
.to_string()
}
struct NativeOutcome {
lines: Vec<String>,
timed_out: bool,
partial: bool,
}
struct Collected {
lines: Vec<String>,
hits: usize,
}
fn run_native_search(spec: &SearchSpec, matcher: &RegexAdapter) -> Result<NativeOutcome, String> {
let deadline = Instant::now() + Duration::from_millis(spec.timeout_ms);
let collected = Mutex::new(Collected {
lines: Vec::new(),
hits: 0,
});
let timed_out = AtomicBool::new(false);
let partial = AtomicBool::new(false);
let done = AtomicBool::new(false);
let mut overrides = OverrideBuilder::new(&spec.root);
for glob in &spec.file_globs {
overrides
.add(glob)
.map_err(|error| format!("Invalid filePattern glob '{glob}': {error}"))?;
}
if spec.default_excludes {
for glob in ["!**/node_modules/**", "!**/target/**"] {
overrides.add(glob).map_err(|error| error.to_string())?;
}
if spec.include_hidden {
overrides.add("!**/.git/**").map_err(|error| error.to_string())?;
}
}
let overrides = overrides.build().map_err(|error| error.to_string())?;
let mut builder = ignore::WalkBuilder::new(&spec.root);
builder
.hidden(!spec.include_hidden)
.ignore(false)
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.parents(false)
.follow_links(false)
.overrides(overrides)
.threads(available_parallelism().clamp(2, 12));
builder.build_parallel().run(|| {
let mut searcher = SearcherBuilder::new()
.line_number(true)
.before_context(spec.context)
.after_context(spec.context)
.multi_line(spec.multiline)
.binary_detection(BinaryDetection::quit(0))
.build();
let matcher = matcher.clone();
let collected = &collected;
let timed_out = &timed_out;
let partial = &partial;
let done = &done;
Box::new(move |entry| {
if done.load(Ordering::Relaxed) || timed_out.load(Ordering::Relaxed) {
return WalkState::Quit;
}
if Instant::now() >= deadline {
timed_out.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
let Ok(entry) = entry else {
partial.store(true, Ordering::Relaxed);
return WalkState::Continue;
};
if !entry.file_type().map(|kind| kind.is_file()).unwrap_or(false) {
return WalkState::Continue;
}
let budget = {
let collected = collected.lock().unwrap();
if collected.hits >= spec.max_results {
done.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
spec.max_results - collected.hits
};
let mut sink = FileSink {
lines: Vec::new(),
hits: 0,
budget,
deadline,
timed_out,
tick: 0,
};
if searcher.search_path(&matcher, entry.path(), &mut sink).is_err() {
partial.store(true, Ordering::Relaxed);
return WalkState::Continue;
}
if timed_out.load(Ordering::Relaxed) {
return WalkState::Quit;
}
if sink.lines.is_empty() {
return WalkState::Continue;
}
let mut collected = collected.lock().unwrap();
let remaining = spec.max_results.saturating_sub(collected.hits);
if remaining == 0 {
done.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
collected.lines.push(entry.path().display().to_string());
let take = sink.lines.len().min(remaining);
collected.lines.extend(sink.lines.drain(..take));
collected.hits += take;
if collected.hits >= spec.max_results {
done.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
WalkState::Continue
})
});
let collected = collected
.into_inner()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let hit_cap = done.into_inner();
Ok(NativeOutcome {
lines: collected.lines,
timed_out: timed_out.into_inner() && !hit_cap,
partial: partial.into_inner(),
})
}
struct FileSink<'a> {
lines: Vec<String>,
hits: usize,
budget: usize,
deadline: Instant,
timed_out: &'a AtomicBool,
tick: u32,
}
impl FileSink<'_> {
fn push_line(&mut self, number: Option<u64>, sep: char, bytes: &[u8]) -> Result<bool, std::io::Error> {
self.tick = self.tick.wrapping_add(1);
if self.tick & 63 == 0 && Instant::now() >= self.deadline {
self.timed_out.store(true, Ordering::Relaxed);
return Ok(false);
}
let text = String::from_utf8_lossy(bytes);
let text = text.trim_end_matches(['\r', '\n']);
for (number, line) in (number.unwrap_or(0)..).zip(text.split('\n')) {
let line = line.trim_end_matches('\r');
self.lines.push(format!("{number}{sep}{line}"));
self.hits += 1;
if self.hits >= self.budget {
return Ok(false);
}
}
Ok(true)
}
}
impl Sink for FileSink<'_> {
type Error = std::io::Error;
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, std::io::Error> {
self.push_line(mat.line_number(), ':', mat.bytes())
}
fn context(&mut self, _searcher: &Searcher, ctx: &SinkContext<'_>) -> Result<bool, std::io::Error> {
self.push_line(ctx.line_number(), '-', ctx.bytes())
}
}
pub(crate) fn path_in_heavy_dir(path: &std::path::Path) -> bool {
path.components().any(|component| {
let text = component.as_os_str().to_string_lossy().to_ascii_lowercase();
text == "node_modules" || text == "target" || text == ".git"
})
}
fn split_patterns(pattern: Option<&str>) -> Vec<String> {
pattern
.into_iter()
.flat_map(|value| value.split('|'))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn read_pattern(item: &Value) -> Result<String, String> {
if let Some(path) = item.get("pattern_path").and_then(Value::as_str) {
let path = ensure_path_allowed(path)?;
let offset = item
.get("pattern_offset")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let length = item
.get("pattern_length")
.and_then(Value::as_u64)
.map(|value| value as usize);
return read_text_slice(path, offset, length);
}
if item.get("start_line").is_some() || item.get("line_count").is_some() {
return Err(
"pattern is required; fs-search matches regex content — for start_line/line_count reads use file-read-line-range"
.to_string(),
);
}
item.get("pattern")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| "pattern or pattern_path is required".to_string())
}
fn bool_field(value: &Value, key: &str, default: bool) -> bool {
value.get(key).and_then(Value::as_bool).unwrap_or(default)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(prefix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"{prefix}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn first_backend(result: &RawResult) -> String {
result.structured.clone().unwrap()["results"][0]["data"]["backend"]
.as_str()
.unwrap_or_default()
.to_string()
}
#[test]
fn missing_pattern_is_error() {
let result = handle_fs_search(&json!({ "items": [{ "path": "." }] }));
assert!(result.is_error);
}
#[test]
fn line_range_args_get_targeted_hint() {
let result = handle_fs_search(
&json!({ "items": [{ "path": ".", "start_line": 3, "line_count": 2 }] }),
);
assert!(result.is_error);
let text = result.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("file-read-line-range"), "{text}");
}
#[test]
fn invalid_regex_falls_back_to_literal() {
let dir = temp_dir("rust-fs-mcp-litfb");
std::fs::write(dir.join("sample.txt"), "call sendCancel( now\n").unwrap();
let result = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "sendCancel(" }]
}));
assert!(!result.is_error, "{result:?}");
let text = result.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("sendCancel("), "{text}");
let backend = first_backend(&result);
assert!(backend.contains("literal fallback"), "{result:?}");
assert!(backend.contains("unclosed group"), "{backend}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn groups_lines_under_file_headings_with_context() {
let dir = temp_dir("rust-fs-mcp-heading");
std::fs::write(dir.join("a.txt"), "ctx before\nneedle hit\nctx after\n").unwrap();
let result = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "needle", "contextLines": 1 }]
}));
assert!(!result.is_error, "{result:?}");
let text = result.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("a.txt"), "{text}");
assert!(text.contains("1-ctx before"), "{text}");
assert!(text.contains("2:needle hit"), "{text}");
assert!(text.contains("3-ctx after"), "{text}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn default_excludes_skip_heavy_dirs() {
let dir = temp_dir("rust-fs-mcp-heavy");
std::fs::create_dir_all(dir.join("node_modules").join("pkg")).unwrap();
std::fs::create_dir_all(dir.join("target")).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("node_modules").join("pkg").join("dep.js"), "needle\n").unwrap();
std::fs::write(dir.join("target").join("out.txt"), "needle\n").unwrap();
std::fs::write(dir.join("src").join("app.js"), "needle\n").unwrap();
let result = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "needle" }]
}));
assert!(!result.is_error, "{result:?}");
let text = result.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("app.js"), "{text}");
assert!(!text.contains("dep.js"), "{text}");
assert!(!text.contains("out.txt"), "{text}");
let all = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "needle", "noDefaultExcludes": true }]
}));
let text = all.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("dep.js"), "{text}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn max_results_caps_match_and_context_lines() {
let dir = temp_dir("rust-fs-mcp-maxres");
let body: String = (1..=50).map(|index| format!("needle line {index}\n")).collect();
std::fs::write(dir.join("many.txt"), body).unwrap();
let result = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "needle", "maxResults": 5, "contextLines": 0 }]
}));
assert!(!result.is_error, "{result:?}");
let structured = result.structured.clone().unwrap();
assert_eq!(structured["results"][0]["data"]["totalCount"], 6, "{structured}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn single_file_root_searches_that_file() {
let dir = temp_dir("rust-fs-mcp-singlefile");
let file = dir.join("only.rs");
std::fs::write(&file, "fn needle_here() {}\n").unwrap();
let result = handle_fs_search(&json!({
"items": [{ "path": file.display().to_string(), "pattern": "needle_here" }]
}));
assert!(!result.is_error, "{result:?}");
let text = result.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("1:fn needle_here() {}"), "{text}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn file_pattern_narrows_targets() {
let dir = temp_dir("rust-fs-mcp-filepat");
std::fs::write(dir.join("a.rs"), "needle\n").unwrap();
std::fs::write(dir.join("b.js"), "needle\n").unwrap();
let result = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "needle", "filePattern": "*.rs" }]
}));
assert!(!result.is_error, "{result:?}");
let text = result.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("a.rs"), "{text}");
assert!(!text.contains("b.js"), "{text}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn unsupported_constructs_are_rejected_with_hints() {
let dir = temp_dir("rust-fs-mcp-unsupported");
std::fs::write(dir.join("a.txt"), "foo bar\n").unwrap();
let look = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "(?<=foo )bar" }]
}));
assert!(look.is_error, "{look:?}");
let text = look.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("look-around"), "{text}");
assert!(text.contains("literal:true"), "{text}");
let backref = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": r"(\w+) \1" }]
}));
assert!(backref.is_error, "{backref:?}");
let text = backref.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("backreference"), "{text}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn explicit_literal_matches_fixed_string_only() {
let dir = temp_dir("rust-fs-mcp-literal");
std::fs::write(dir.join("a.txt"), "foo.bar\nfooXbar\n").unwrap();
let result = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "foo.bar", "literal": true, "contextLines": 0 }]
}));
assert!(!result.is_error, "{result:?}");
let text = result.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("1:foo.bar"), "{text}");
assert!(!text.contains("fooXbar"), "{text}");
assert!(first_backend(&result).contains("(literal)"), "{result:?}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn word_match_bounds_ascii_and_hangul_words() {
let dir = temp_dir("rust-fs-mcp-word");
std::fs::write(dir.join("a.txt"), "cat\nconcatenate\n가나 이후\n가나다 이후\n").unwrap();
let ascii = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "cat", "wordMatch": true, "contextLines": 0 }]
}));
assert!(!ascii.is_error, "{ascii:?}");
let text = ascii.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("1:cat"), "{text}");
assert!(!text.contains("concatenate"), "{text}");
let hangul = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": "가나", "wordMatch": true, "contextLines": 0 }]
}));
assert!(!hangul.is_error, "{hangul:?}");
let text = hangul.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("3:가나 이후"), "{text}");
assert!(!text.contains("가나다"), "{text}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn multiline_lets_patterns_span_lines() {
let dir = temp_dir("rust-fs-mcp-multiline");
std::fs::write(dir.join("a.rs"), "alpha\nfn demo(\n arg: u32,\n) -> bool {\nomega\n").unwrap();
let single = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": r"fn demo\(.*?\) -> bool", "contextLines": 0 }]
}));
assert!(!single.is_error, "{single:?}");
let text = single.content[0]["text"].as_str().unwrap_or_default();
assert!(!text.contains("fn demo"), "{text}");
let multi = handle_fs_search(&json!({
"items": [{ "path": dir.display().to_string(), "pattern": r"fn demo\(.*?\) -> bool", "multiline": true, "contextLines": 0 }]
}));
assert!(!multi.is_error, "{multi:?}");
let text = multi.content[0]["text"].as_str().unwrap_or_default();
assert!(text.contains("2:fn demo("), "{text}");
assert!(text.contains("3: arg: u32,"), "{text}");
assert!(text.contains("4:) -> bool {"), "{text}");
std::fs::remove_dir_all(&dir).unwrap();
}
}