Compare commits

...

1 Commits

Author SHA1 Message Date
Dmitriy Kovalenko b2904f2425 fix(grep): file path single file constraint parsing
docs / docs (push) Has been cancelled
2026-03-12 16:51:01 -07:00
4 changed files with 53 additions and 43 deletions
+4 -1
View File
@@ -1509,7 +1509,7 @@ pub fn grep_search<'a>(
// "name = *.rs someth" -> grep "name = someth" with constraint Extension("rs")
let constraints_from_query: &[fff_query_parser::Constraint<'_>];
let grep_text = match query {
let mut grep_text = match query {
Some(p) => {
constraints_from_query = &p.constraints[..];
p.grep_text()
@@ -1557,12 +1557,15 @@ pub fn grep_search<'a>(
// If constraints yielded 0 files and we had a FilePath constraint,
// retry without it (the path token was likely part of the search text).
// Also restore the raw query as grep text so the filename token is
// searched as literal text rather than silently dropped.
if files_to_search.is_empty()
&& let Some(stripped) = strip_file_path_constraints(constraints_from_query)
{
let (retry_files, retry_count) = prepare_files_to_search(files, &stripped, options);
files_to_search = retry_files;
filtered_file_count = retry_count;
grep_text = raw_query.trim().to_string();
}
if files_to_search.is_empty() {
+8 -6
View File
@@ -71,13 +71,15 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
"\n",
"Constraints MUST match one of these formats:\n",
" Extension: '*.rs', '*.{ts,tsx}' (starts with *.)\n",
" Directory: 'src/', 'quotes/' (ends with /)\n",
" Exclude: '!test/', '!*.spec.ts' (starts with !)\n",
" Extension: '*.rs', '*.{ts,tsx}'\n",
" Directory: 'src/', 'quotes/'\n",
" Filename: 'schema.rs', 'src/main.rs'\n",
" Exclude: '!test/', '!*.spec.ts'\n",
"\n",
"! Bare words are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
"! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
" + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n",
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
"\n",
"Prefer broad constraints:\n",
" + '*.rs query' -> file type\n",
+5 -31
View File
@@ -161,10 +161,10 @@ impl ParserConfig for GrepConfig {
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
/// automatic file-path constraint detection.
///
/// When an AI agent sends `"libswscale/input.c rgba32ToY"`, the token
/// `libswscale/input.c` is detected as a `FilePath` constraint so the
/// search is scoped to that file. The caller validates the constraint
/// against the index and drops it if no files match (fallback).
/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
/// so the search is scoped to matching files. The caller validates the
/// constraint against the index and drops it if no files match (fallback).
#[derive(Debug, Clone, Copy, Default)]
pub struct AiGrepConfig;
@@ -202,36 +202,10 @@ impl ParserConfig for AiGrepConfig {
}
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_file_path_token(token) {
if is_filename_constraint_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}
#[inline]
fn is_file_path_token(token: &str) -> bool {
let bytes = token.as_bytes();
// Must contain at least one /
if !bytes.contains(&b'/') {
return false;
}
// Must NOT end with / (that's a PathSegment)
if bytes.last() == Some(&b'/') {
return false;
}
// Must NOT contain wildcards (those are globs)
if has_wildcards(token) {
return false;
}
// Last component must contain . (file extension)
match token.rsplit('/').next() {
Some(last) => last.contains('.'),
None => false,
}
}
+36 -5
View File
@@ -1,8 +1,8 @@
use crate::ConstraintVec;
use crate::config::ParserConfig;
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
use crate::glob_detect::has_wildcards;
use crate::location::{Location, parse_location};
use crate::location::{parse_location, Location};
use crate::ConstraintVec;
#[derive(Debug, Clone, PartialEq)]
#[allow(clippy::large_enum_variant)]
@@ -918,13 +918,44 @@ mod tests {
}
#[test]
fn test_ai_grep_no_false_positive_no_slash() {
fn test_ai_grep_bare_filename_is_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("main.rs pattern").expect("Should parse");
// No slash → not a file path, just text
// Bare filename with valid extension → FilePath constraint
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
"Expected FilePath, got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "pattern");
}
#[test]
fn test_ai_grep_bare_filename_schema_rs() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser
.parse("schema.rs part_revisions")
.expect("Should parse");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
"Expected FilePath(schema.rs), got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "part_revisions");
}
#[test]
fn test_ai_grep_bare_word_no_extension_not_constraint() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("schema pattern").expect("Should parse");
// No extension → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "main.rs pattern");
assert_eq!(result.grep_text(), "schema pattern");
}
#[test]