diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index e1713f31..00280ccb 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 b238b495..555566d4 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 8fd1589f..297fc132 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 9519ef83..bc614a15 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 c4affa33..350c6931 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 d2a7ad39..7d7857f1 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 4fd72116..6f129c11 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,