Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions crates/fff-c/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,19 +729,12 @@ pub unsafe extern "C" fn fff_multi_grep(
}
};

let is_ai = picker.mode().is_ai();

// Parse constraints from the optional string (e.g. "*.rs /src/")
let parsed_constraints = constraints_str.map(|c| {
if is_ai {
fff::QueryParser::new(fff_query_parser::AiGrepConfig).parse(c)
} else {
fff::grep::parse_grep_query(c)
}
});
let parsed_constraints = constraints_str
.map(|c| fff::QueryParser::new(fff_query_parser::AiGrepConfig).parse_constraints(c));

let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints {
Some(q) => &q.constraints,
Some(constraints) => constraints,
None => &[],
};

Expand Down
5 changes: 3 additions & 2 deletions crates/fff-core/src/grep/multi_pattern.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::grep::{GrepContext, perform_grep};
use super::prefilter::prefilter_with_filepath_retry;
use super::prefilter::prefilter_files;
use super::sink::{SinkState, debug_assert_newline_terminator};
use super::types::{GrepResult, GrepSearchOptions};
use crate::index::{BigramFilter, BigramOverlay, bigram_boundary, literal_candidates};
Expand Down Expand Up @@ -121,7 +121,8 @@ pub(crate) fn multi_grep_search<'a>(
let bigram_candidates = literal_candidates(bigram_index, bigram_overlay, patterns);
let base_file_count = bigram_boundary(bigram_overlay, files.len());

let (files_to_search, filtered_file_count) = prefilter_with_filepath_retry(
// Constraints are separate from patterns, so a miss must not broaden the search.
let (files_to_search, filtered_file_count) = prefilter_files(
files,
constraints,
bigram_candidates.as_deref(),
Expand Down
2 changes: 1 addition & 1 deletion crates/fff-core/src/grep/prefilter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub(super) fn prefilter_with_filepath_retry<'a>(
/// Single pass prefilter that doesn't involve file reading
/// allocates only amount of memory required for storing references of the FileItems have to be
/// opened for grepping unaviodably, in the worst case allocates N * <word> memory if no prefilter needed
fn prefilter_files<'a>(
pub(super) fn prefilter_files<'a>(
files: &'a [FileItem],
constraints: &[Constraint<'_>],
bigram_candidates: Option<&[u64]>,
Expand Down
11 changes: 11 additions & 0 deletions crates/fff-core/tests/path_separator_constraint_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ fn multi_grep_with_file_path_suffix_constraint() {
}
}

#[test]
fn multi_grep_with_missing_file_path_constraint_returns_no_matches() {
let tmp = TempDir::new().unwrap();
let picker = create_picker(tmp.path(), &[("other.lua", "handleRequest\n")]);

let constraints = [Constraint::FilePath("missing.lua")];
let result = picker.multi_grep(&["handleRequest"], &constraints, &plain_opts());

assert!(result.matches.is_empty());
}

/// Glob constraints must match native Windows paths — the picker normalises
/// separators when handing paths to the glob matcher.
#[test]
Expand Down
56 changes: 3 additions & 53 deletions crates/fff-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use fff_query_parser::AiGrepConfig;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
Expand Down Expand Up @@ -613,61 +612,12 @@ impl FffServer {
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();

let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
let parsed_constraints = parser.parse(constraint_query);
let constraints = parsed_constraints.constraints.as_slice();
let parser = QueryParser::new(AiGrepConfig);
let constraints = parser.parse_constraints(constraint_query);

let result = picker.multi_grep(&patterns_refs, constraints, &options);
let result = picker.multi_grep(&patterns_refs, &constraints, &options);
let file_refs: Vec<&FileItem> = result.files.to_vec();

if result.matches.is_empty() && file_offset == 0 {
// Fallback: try individual patterns with plain grep
let (fallback_options, _) =
make_grep_options(output_mode, GrepMode::PlainText, 0, context);

let fallback_options = GrepSearchOptions {
time_budget_ms: 3000,
before_context: 0,
..fallback_options
};

for pat in &params.patterns {
let full_query: Cow<str> = if !constraint_query.is_empty() {
Cow::Owned(format!("{} {}", constraint_query, pat))
} else {
Cow::Borrowed(pat)
};

let parsed = parser.parse(&full_query);
let fb_result = picker.grep(&parsed, &fallback_options);

if !fb_result.matches.is_empty() {
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &fb_result.matches,
files: &fb_file_refs,
total_matched: fb_result.matches.len(),
next_file_offset: fb_result.next_file_offset,
output_mode,
max_results,
show_context: false,
auto_expand_defs: auto_expand,
picker,
}
.format(&mut cs);
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 multi-pattern matches. Plain grep fallback for \"{}\":\n{}",
pat, text
))]));
}
}

return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}

if result.matches.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
Expand Down
12 changes: 4 additions & 8 deletions crates/fff-python/src/finder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,15 +693,11 @@ impl FileFinder {
}
let pattern_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect();

let parsed_constraints = constraints.as_ref().map(|c| {
if picker.mode().is_ai() {
QueryParser::new(fff_query_parser::AiGrepConfig).parse(c)
} else {
fff::grep::parse_grep_query(c)
}
});
let parsed_constraints = constraints
.as_ref()
.map(|c| QueryParser::new(fff_query_parser::AiGrepConfig).parse_constraints(c));
let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints {
Some(q) => &q.constraints,
Some(constraints) => constraints,
None => &[],
};
let options = grep_options(
Expand Down
46 changes: 45 additions & 1 deletion crates/fff-query-parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ impl<C: ParserConfig> QueryParser<C> {
Self { config }
}

/// Parse a field containing only constraints.
pub fn parse_constraints<'a>(&self, query: &'a str) -> ConstraintVec<'a> {
query
.split_whitespace()
.filter_map(|token| parse_token(token, &self.config))
.collect()
}

pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
let raw_query = query;
let config: &C = &self.config;
Expand Down Expand Up @@ -498,7 +506,7 @@ fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{FileSearchConfig, GrepConfig};
use crate::{AiGrepConfig, FileSearchConfig, GrepConfig};

/// File-picker-like config with filename-constraint detection enabled,
/// mirroring the Neovim layer's opt-in behavior.
Expand Down Expand Up @@ -1019,6 +1027,42 @@ mod tests {
assert_eq!(result.grep_text(), "pattern");
}

#[test]
fn test_standalone_constraints_preserve_directory() {
let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/");
assert_eq!(result.as_slice(), &[Constraint::PathSegment("scope-a")]);
}

#[test]
fn test_plain_constraints_preserve_directory_only() {
let directory = QueryParser::new(GrepConfig).parse_constraints("scope-a/");
assert_eq!(directory.as_slice(), &[Constraint::PathSegment("scope-a")]);

let file = QueryParser::new(GrepConfig).parse_constraints("scope-a/one.txt");
assert!(file.is_empty());
}

#[test]
fn test_standalone_constraints_preserve_file() {
let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/one.txt");
assert_eq!(
result.as_slice(),
&[Constraint::FilePath("scope-a/one.txt")]
);
}

#[test]
fn test_standalone_constraints_preserve_file_without_search_text() {
let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/ scope-a/one.txt");
assert_eq!(
result.as_slice(),
&[
Constraint::PathSegment("scope-a"),
Constraint::FilePath("scope-a/one.txt")
]
);
}

#[test]
fn test_ai_grep_filename_with_pathsegment_only_promotes_to_text() {
// When the ONLY non-text constraints are path-scoping (PathSegment,
Expand Down