From cd90762b8069ab5fcbc55e5c8a548155a3dcf8cd Mon Sep 17 00:00:00 2001 From: Kh05ifr4nD Date: Sat, 8 Aug 2026 10:33:28 +0800 Subject: [PATCH 1/2] fix: apply standalone constraints in multi_grep --- crates/fff-c/src/lib.rs | 13 +---- crates/fff-core/src/grep/multi_pattern.rs | 5 +- crates/fff-core/src/grep/prefilter.rs | 2 +- .../tests/path_separator_constraint_test.rs | 11 ++++ crates/fff-mcp/src/server.rs | 56 +------------------ crates/fff-python/src/finder.rs | 12 ++-- crates/fff-query-parser/src/parser.rs | 46 ++++++++++++++- 7 files changed, 70 insertions(+), 75 deletions(-) diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index e1713f31b..00280ccb2 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -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 => &[], }; diff --git a/crates/fff-core/src/grep/multi_pattern.rs b/crates/fff-core/src/grep/multi_pattern.rs index b238b495f..555566d47 100644 --- a/crates/fff-core/src/grep/multi_pattern.rs +++ b/crates/fff-core/src/grep/multi_pattern.rs @@ -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}; @@ -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(), diff --git a/crates/fff-core/src/grep/prefilter.rs b/crates/fff-core/src/grep/prefilter.rs index 8fd1589fb..297fc132c 100644 --- a/crates/fff-core/src/grep/prefilter.rs +++ b/crates/fff-core/src/grep/prefilter.rs @@ -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 * memory if no prefilter needed -fn prefilter_files<'a>( +pub(super) fn prefilter_files<'a>( files: &'a [FileItem], constraints: &[Constraint<'_>], bigram_candidates: Option<&[u64]>, diff --git a/crates/fff-core/tests/path_separator_constraint_test.rs b/crates/fff-core/tests/path_separator_constraint_test.rs index 9519ef833..bc614a158 100644 --- a/crates/fff-core/tests/path_separator_constraint_test.rs +++ b/crates/fff-core/tests/path_separator_constraint_test.rs @@ -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] diff --git a/crates/fff-mcp/src/server.rs b/crates/fff-mcp/src/server.rs index c4affa334..350c69318 100644 --- a/crates/fff-mcp/src/server.rs +++ b/crates/fff-mcp/src/server.rs @@ -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}; @@ -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 ¶ms.patterns { - let full_query: Cow = 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(), diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index d2a7ad399..7d7857f19 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -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( diff --git a/crates/fff-query-parser/src/parser.rs b/crates/fff-query-parser/src/parser.rs index 4fd72116d..6f129c116 100644 --- a/crates/fff-query-parser/src/parser.rs +++ b/crates/fff-query-parser/src/parser.rs @@ -49,6 +49,14 @@ impl QueryParser { 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; @@ -498,7 +506,7 @@ fn parse_git_status(value: &str) -> Option> { #[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. @@ -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, From 1fbb431e9eb7b6f4de44a0118c32d33fc18110f7 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Thu, 13 Aug 2026 07:44:24 -0700 Subject: [PATCH 2/2] chore: use pub(crate) --- crates/fff-core/src/grep/prefilter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fff-core/src/grep/prefilter.rs b/crates/fff-core/src/grep/prefilter.rs index 297fc132c..548fba552 100644 --- a/crates/fff-core/src/grep/prefilter.rs +++ b/crates/fff-core/src/grep/prefilter.rs @@ -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 * memory if no prefilter needed -pub(super) fn prefilter_files<'a>( +pub(crate) fn prefilter_files<'a>( files: &'a [FileItem], constraints: &[Constraint<'_>], bigram_candidates: Option<&[u64]>,