Skip to content
Merged
5 changes: 4 additions & 1 deletion crates/fff-core/src/file_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1286,7 +1286,10 @@ impl FilePicker {
dir.update_frecency_if_larger(score);
}
} else {
error!(?path, "Couldn't update the git status for path");
// Expected on sparse checkouts: git reports a status for
// a path that isn't materialized on disk and therefore
// isn't in the file index. Don't spam the log (#404).
debug!(?path, "Git status for path not in index, skipping");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does having a sparse checkout repo hinder performance? the intention of my issue is to mitigate the slowness, I imagine we don't need to care about files not present on disk whatsoever. having an else branch here means we still have to do 'something' about it, which is why i think it is slower.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if git doesn't return those non checked out files, the code here wouldn't even run right? so i think the root cause is git somehow caring about files not checked out.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait are you saying that this issue is not fixed?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct, i think the root issue has to do with fff doing unnecessary work. when the repo is sparsely checked out, we are still attempting to update the git status of files that aren't checked out.

}
Ok(())
})?;
Expand Down
105 changes: 105 additions & 0 deletions crates/fff-core/src/score.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1462,3 +1462,108 @@ mod typo_resistance_tests {
);
}
}

#[cfg(test)]
mod constraint_only_query_tests {
use super::*;
use crate::types::PaginationArgs;
use fff_query_parser::QueryParser;

#[test]
fn constraint_only_git_status_query_returns_filtered_files() {
let paths = ["modified.rs", "clean.rs"];
let path_strings: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
let items: Vec<FileItem> = paths
.iter()
.map(|p| {
let fname = p.rfind(std::path::is_separator).map(|i| i + 1).unwrap_or(0) as u16;
FileItem::new_raw(fname, 0, 0, None, false)
})
.collect();
let (store, strings) =
crate::simd_path::build_chunked_path_store_from_strings(&path_strings, &items);
let arena = store.as_arena_ptr();
let mut files: Vec<FileItem> = items;
for (i, file) in files.iter_mut().enumerate() {
file.set_path(strings[i].clone());
}
files[0].git_status = Some(git2::Status::WT_MODIFIED);
files[1].git_status = Some(git2::Status::CURRENT);
std::mem::forget(store);

let parser = QueryParser::default();
let parsed = parser.parse("git:modified");

let ctx = ScoringContext {
query: &parsed,
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 50,
},
};

let (items, _scores, total_matched) =
fuzzy_match_and_score_files(&files, &ctx, files.len(), arena, arena);

assert_eq!(
total_matched, 1,
"git:modified constraint-only query should match exactly 1 modified file"
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].relative_path(arena), "modified.rs");
}

#[test]
fn constraint_only_status_modified_alias_returns_filtered_files() {
let paths = ["a.rs", "b.rs"];
let path_strings: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
let items: Vec<FileItem> = paths
.iter()
.map(|p| {
let fname = p.rfind(std::path::is_separator).map(|i| i + 1).unwrap_or(0) as u16;
FileItem::new_raw(fname, 0, 0, None, false)
})
.collect();
let (store, strings) =
crate::simd_path::build_chunked_path_store_from_strings(&path_strings, &items);
let arena = store.as_arena_ptr();
let mut files: Vec<FileItem> = items;
for (i, file) in files.iter_mut().enumerate() {
file.set_path(strings[i].clone());
}
files[0].git_status = Some(git2::Status::WT_MODIFIED);
files[1].git_status = Some(git2::Status::CURRENT);
std::mem::forget(store);

let parser = QueryParser::default();
let parsed = parser.parse("status:modified");

let ctx = ScoringContext {
query: &parsed,
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 50,
},
};

let (items, _scores, total_matched) =
fuzzy_match_and_score_files(&files, &ctx, files.len(), arena, arena);

assert_eq!(total_matched, 1);
assert_eq!(items[0].relative_path(arena), "a.rs");
}
}
6 changes: 0 additions & 6 deletions crates/fff-mcp/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ pub struct GrepFormatter<'a> {
pub files: &'a [&'a FileItem],
pub total_matched: usize,
pub next_file_offset: usize,
pub regex_fallback_error: Option<&'a str>,
pub output_mode: OutputMode,
pub max_results: usize,
pub show_context: bool,
Expand All @@ -169,7 +168,6 @@ impl GrepFormatter<'_> {
files,
total_matched,
next_file_offset,
regex_fallback_error,
output_mode,
max_results,
show_context,
Expand Down Expand Up @@ -216,10 +214,6 @@ impl GrepFormatter<'_> {
2500
};

if let Some(err) = regex_fallback_error {
lines.push(format!("! regex failed: {}, using literal match", err));
}

// File overview: collect first match per file
let file_preview = collect_file_preview(items, files, picker);
let mut content_def_file = String::new();
Expand Down
90 changes: 83 additions & 7 deletions crates/fff-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};

/// Normalize the caller-supplied `maxResults`.
///
/// `None`, `Some(0)`, and non-positive / non-finite values fall back to
/// `default`. Issue #400 reported that grep returned 0 items for
/// `maxResults: 0` while `find_files` returned the entire dataset; treating
/// 0 as "use the default" makes both tools behave consistently.
fn normalize_max_results(raw: Option<f64>, default: usize) -> usize {
match raw {
None => default,
Some(v) if v <= 0.0 || !v.is_finite() => default,
Some(v) => (v.round() as usize).max(1),
}
}

fn cleanup_fuzzy_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
Expand Down Expand Up @@ -71,6 +85,9 @@ fn make_grep_options(
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct FindFilesParams {
/// Fuzzy search query. Supports path prefixes and glob constraints.
// `pattern` alias for consistency with grep's alias and the common
// file-search parameter name (#311).
#[serde(alias = "pattern")]
pub query: String,
/// Max results (default 20).
#[serde(rename = "maxResults")]
Expand All @@ -84,6 +101,10 @@ pub struct FindFilesParams {
pub struct GrepParams {
/// Search text or regex query with optional constraint prefixes.
/// Matches within single lines only — use ONE specific term, not multiple words.
// `pattern` alias: LLMs that have seen multi_grep (which uses `patterns`)
// routinely call grep with `pattern`; accept it instead of erroring out
// with an unhelpful "missing field `query`" (#311).
#[serde(alias = "pattern")]
pub query: String,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
Expand Down Expand Up @@ -273,7 +294,6 @@ impl FffServer {
files: &retry_result.files,
total_matched: retry_result.matches.len(),
next_file_offset: retry_result.next_file_offset,
regex_fallback_error: retry_result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
Expand Down Expand Up @@ -363,7 +383,6 @@ impl FffServer {
files: &result.files,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
Expand Down Expand Up @@ -391,7 +410,7 @@ impl FffServer {
&self,
Parameters(params): Parameters<FindFilesParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20.0).round() as usize; // safe
let max_results = normalize_max_results(params.max_results, 20);
let query = &params.query;

let page_offset = params
Expand Down Expand Up @@ -503,7 +522,7 @@ impl FffServer {
&self,
Parameters(params): Parameters<GrepParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20.0) as usize;
let max_results = normalize_max_results(params.max_results, 20);
let output_mode = OutputMode::new(params.output_mode.as_deref());

let parsed = QueryParser::new(AiGrepConfig).parse(&params.query);
Expand Down Expand Up @@ -545,7 +564,7 @@ impl FffServer {

impl FffServer {
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20.0).round() as usize;
let max_results = normalize_max_results(params.max_results, 20);
let context = params.context.map(|v| v.round() as usize);
let output_mode = OutputMode::new(params.output_mode.as_deref());

Expand Down Expand Up @@ -604,7 +623,6 @@ impl FffServer {
files: &fb_file_refs,
total_matched: fb_result.matches.len(),
next_file_offset: fb_result.next_file_offset,
regex_fallback_error: None,
output_mode,
max_results,
show_context: false,
Expand Down Expand Up @@ -636,7 +654,6 @@ impl FffServer {
files: &file_refs,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: None,
output_mode,
max_results,
show_context: ctx_lines > 0,
Expand Down Expand Up @@ -664,3 +681,62 @@ impl ServerHandler for FffServer {
.with_instructions(instructions)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn normalize_max_results_none_uses_default() {
assert_eq!(normalize_max_results(None, 20), 20);
}

#[test]
fn normalize_max_results_zero_uses_default() {
// Issue #400: `maxResults: 0` must not return zero items for grep
// while `find_files` returns the full set. Both tools now map 0 to
// the default limit.
assert_eq!(normalize_max_results(Some(0.0), 20), 20);
}

#[test]
fn normalize_max_results_negative_uses_default() {
assert_eq!(normalize_max_results(Some(-5.0), 20), 20);
}

#[test]
fn normalize_max_results_non_finite_uses_default() {
assert_eq!(normalize_max_results(Some(f64::NAN), 20), 20);
assert_eq!(normalize_max_results(Some(f64::INFINITY), 20), 20);
}

#[test]
fn normalize_max_results_rounds_and_clamps() {
assert_eq!(normalize_max_results(Some(0.4), 20), 1);
assert_eq!(normalize_max_results(Some(10.0), 20), 10);
assert_eq!(normalize_max_results(Some(10.7), 20), 11);
}

#[test]
fn grep_params_accepts_pattern_alias() {
// Issue #311: LLMs flip between `query` and `pattern`; accept both.
let via_query: GrepParams =
serde_json::from_str(r#"{"query":"foo"}"#).expect("query field");
assert_eq!(via_query.query, "foo");

let via_pattern: GrepParams =
serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias");
assert_eq!(via_pattern.query, "foo");
}

#[test]
fn find_files_params_accepts_pattern_alias() {
let via_query: FindFilesParams =
serde_json::from_str(r#"{"query":"foo"}"#).expect("query field");
assert_eq!(via_query.query, "foo");

let via_pattern: FindFilesParams =
serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias");
assert_eq!(via_pattern.query, "foo");
}
}
52 changes: 33 additions & 19 deletions packages/pi-fff/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,11 @@ function createFffMentionProvider(
export default function fffExtension(pi: ExtensionAPI) {
let finder: FileFinder | null = null;
let finderCwd: string | null = null;
// Concurrent ensureFinder() callers share the same in-flight promise so
// FileFinder.create() (which takes native DB locks) runs at most once per
// base path at a time — otherwise parallel tool calls would race and
// deadlock at the native layer (issue #403).
let finderPromise: Promise<FileFinder> | null = null;
let activeCwd = process.cwd();

// Mode resolution: flag > env > default
Expand Down Expand Up @@ -324,28 +329,37 @@ export default function fffExtension(pi: ExtensionAPI) {
return currentMode !== "tools-only";
}

async function ensureFinder(cwd: string): Promise<FileFinder> {
if (finder && !finder.isDestroyed && finderCwd === cwd) return finder;
if (finder && !finder.isDestroyed) {
finder.destroy();
finder = null;
finderCwd = null;
}
function ensureFinder(cwd: string): Promise<FileFinder> {
if (finder && !finder.isDestroyed && finderCwd === cwd)
return Promise.resolve(finder);
if (finderPromise) return finderPromise;

const result = FileFinder.create({
basePath: cwd,
frecencyDbPath,
historyDbPath,
aiMode: true,
});
finderPromise = (async () => {
if (finder && !finder.isDestroyed) {
finder.destroy();
finder = null;
finderCwd = null;
}

const result = FileFinder.create({
basePath: cwd,
frecencyDbPath,
historyDbPath,
aiMode: true,
});

if (!result.ok)
throw new Error(`Failed to create FFF file finder: ${result.error}`);
if (!result.ok)
throw new Error(`Failed to create FFF file finder: ${result.error}`);

finder = result.value;
finderCwd = cwd;
await finder.waitForScan(15000);
return finder;
})().finally(() => {
finderPromise = null;
});

finder = result.value;
finderCwd = cwd;
await finder.waitForScan(15000);
return finder;
return finderPromise;
}

function destroyFinder() {
Expand Down
Loading
Loading