Skip to content
Merged
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
Empty file added crates/fff-mcp/server/.gitkeep
Empty file.
17 changes: 17 additions & 0 deletions crates/fff-query-parser/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ pub trait ParserConfig {
has_wildcards(token)
}

/// When `true`, a PathSegment constraint that is the ONLY token in the
/// query is demoted to fuzzy text. Grep modes enable this because the
/// user is typing a search term (e.g. `/api/tests`), not scoping to a
/// directory. File-search modes keep the default (`false`) so that
/// `/src/` still filters by directory.
fn treat_lone_path_as_text(&self) -> bool {
false
}

/// Custom constraint parsers for picker-specific needs
fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
None
Expand Down Expand Up @@ -137,6 +146,10 @@ impl ParserConfig for GrepConfig {
false
}

fn treat_lone_path_as_text(&self) -> bool {
true
}

/// Only recognise globs that are clearly directory/path oriented.
///
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
Expand Down Expand Up @@ -209,6 +222,10 @@ impl ParserConfig for AiGrepConfig {
false
}

fn treat_lone_path_as_text(&self) -> bool {
true
}

fn is_glob_pattern(&self, token: &str) -> bool {
// First check GrepConfig's strict rules (path globs, brace expansion)
if GrepConfig.is_glob_pattern(token) {
Expand Down
201 changes: 194 additions & 7 deletions crates/fff-query-parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,15 @@ impl<C: ParserConfig> QueryParser<C> {
.rev()
.take_while(|&b| b != b':')
.all(|b| b.is_ascii_digit());
if !matches!(constraint, Constraint::FilePath(_)) && !has_location_suffix {

// for grep we don't want to treat a part of path like pathname
let treat_as_text = matches!(constraint, Constraint::PathSegment(_))
&& config.treat_lone_path_as_text();

if !matches!(constraint, Constraint::FilePath(_))
&& !has_location_suffix
&& !treat_as_text
{
constraints.push(constraint);
return FFFQuery {
raw_query,
Expand Down Expand Up @@ -102,6 +110,10 @@ impl<C: ParserConfig> QueryParser<C> {
let tokens = query.split_whitespace();

let mut has_file_path = false;
// Track the FilePath token position in constraints so we can promote
// it back to text if the final query ends up with no fuzzy text.
let mut file_path_constraint_idx: Option<usize> = None;
let mut file_path_token: Option<&str> = None;
for token in tokens {
match parse_token(token, config) {
Some(Constraint::FilePath(_)) => {
Expand All @@ -111,6 +123,8 @@ impl<C: ParserConfig> QueryParser<C> {
// searching for).
text_parts.push(token);
} else {
file_path_constraint_idx = Some(constraints.len());
file_path_token = Some(token);
constraints.push(Constraint::FilePath(token));
has_file_path = true;
}
Expand All @@ -124,6 +138,21 @@ impl<C: ParserConfig> QueryParser<C> {
}
}

// If the query produced a single FilePath and no fuzzy text parts, the
// user isn't filtering by filename suffix — they're fuzzy-searching
// for that name (the only other constraints are path-scoping like
// PathSegment/Extension/Glob). Mirror the single-token rule at
// parser.rs:48-64: promote FilePath → fuzzy text so e.g. `profile.h`
// alongside `chrome/browser/profiles/` fuzzy-matches all `profile*.h`
// files instead of only one file literally ending in `/profile.h`.
if text_parts.is_empty()
&& let Some(idx) = file_path_constraint_idx
&& let Some(tok) = file_path_token
{
constraints.remove(idx);
text_parts.push(tok);
}

// Try to extract location from the last fuzzy token
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
let location = if config.enable_location() && !text_parts.is_empty() {
Expand Down Expand Up @@ -916,6 +945,164 @@ mod tests {
assert_eq!(result.grep_text(), "pattern");
}

#[test]
fn test_ai_grep_filename_with_pathsegment_only_promotes_to_text() {
// When the ONLY non-text constraints are path-scoping (PathSegment,
// here), a bare filename token like `profile.h` should NOT be used as
// a FilePath filter — the user is fuzzy-searching within that dir,
// not asking for files named exactly `profile.h`.
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("chrome/browser/profiles/ profile.h");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(
result.constraints[0],
Constraint::PathSegment("chrome/browser/profiles")
),
"Expected single PathSegment, got {:?}",
result.constraints
);
assert_eq!(result.grep_text(), "profile.h");
}

#[test]
fn test_ai_grep_leading_slash_path_alone_is_text_not_path_segment() {
// A leading-slash multi-segment path like `/api/tests/` or `/api/tests`
// used as the sole query token should be treated as fuzzy text, NOT as
// a PathSegment constraint. The user is searching for files matching
// that path string, not trying to scope results to a directory.
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);

// With trailing slash
let result = parser.parse("/api/tests/");
assert_eq!(
result.constraints.len(),
0,
"Expected no constraints for '/api/tests/', got {:?}",
result.constraints
);
assert!(
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
"Expected FuzzyQuery::Text, got {:?}",
result.fuzzy_query
);

// Without trailing slash
let result = parser.parse("/api/tests");
assert_eq!(
result.constraints.len(),
0,
"Expected no constraints for '/api/tests', got {:?}",
result.constraints
);
assert!(
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
"Expected FuzzyQuery::Text, got {:?}",
result.fuzzy_query
);
}

#[test]
fn test_grep_leading_slash_path_alone_is_text_not_path_segment() {
// Same behavior for regular GrepConfig — single-token path-like
// queries are search terms, not directory filters.
let parser = QueryParser::new(GrepConfig);

let result = parser.parse("/api/tests/");
assert_eq!(
result.constraints.len(),
0,
"GrepConfig: expected no constraints for '/api/tests/', got {:?}",
result.constraints
);
assert!(
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
"GrepConfig: expected FuzzyQuery::Text, got {:?}",
result.fuzzy_query
);

let result = parser.parse("/api/tests");
assert_eq!(
result.constraints.len(),
0,
"GrepConfig: expected no constraints for '/api/tests', got {:?}",
result.constraints
);
assert!(
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
"GrepConfig: expected FuzzyQuery::Text, got {:?}",
result.fuzzy_query
);
}

#[test]
fn test_file_search_leading_slash_path_alone_stays_path_segment() {
// FileSearchConfig (fuzzy file finder) should still treat a lone
// `/api/tests/` as a PathSegment constraint — the user is scoping
// the file list to that directory.
let parser = QueryParser::new(FileSearchConfig);

let result = parser.parse("/api/tests/");
assert_eq!(
result.constraints.len(),
1,
"FileSearchConfig: expected PathSegment constraint, got {:?}",
result.constraints
);
assert!(
matches!(result.constraints[0], Constraint::PathSegment("api/tests")),
"FileSearchConfig: expected PathSegment(\"api/tests\"), got {:?}",
result.constraints[0]
);

let result = parser.parse("/api/tests");
assert_eq!(
result.constraints.len(),
1,
"FileSearchConfig: expected PathSegment constraint, got {:?}",
result.constraints
);
assert!(
matches!(result.constraints[0], Constraint::PathSegment("api/tests")),
"FileSearchConfig: expected PathSegment(\"api/tests\"), got {:?}",
result.constraints[0]
);
}

#[test]
fn test_ai_grep_filename_with_extension_only_promotes_to_text() {
// Same case with an Extension constraint — no fuzzy text means the
// filename is what the user is searching for.
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("*.h profile.h");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::Extension("h")),
"Expected Extension, got {:?}",
result.constraints
);
assert_eq!(result.grep_text(), "profile.h");
}

#[test]
fn test_ai_grep_filename_with_other_text_keeps_filepath() {
// Sanity: when there IS fuzzy text alongside the filename, the
// filename stays a FilePath filter (the documented multi-token case).
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("main.rs pattern");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
"Expected FilePath, got {:?}",
result.constraints
);
assert_eq!(result.grep_text(), "pattern");
}

#[test]
fn test_ai_grep_bare_filename_schema_rs() {
use crate::AiGrepConfig;
Expand Down Expand Up @@ -1157,16 +1344,16 @@ mod tests {
fn test_file_picker_filename_with_extension_constraint() {
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("main.rs *.lua");
// main.rs → FilePath, *.lua → Extension
assert_eq!(result.constraints.len(), 2);
// With only path-scoping constraints (Extension) and no fuzzy text,
// `main.rs` is promoted to fuzzy text — the user is fuzzy-searching
// for "main.rs" among `.lua` files, not filtering by literal filename
// suffix. Only the Extension constraint remains.
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::FilePath("main.rs")
));
assert!(matches!(
result.constraints[1],
Constraint::Extension("lua")
));
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("main.rs"));
}

#[test]
Expand Down
10 changes: 4 additions & 6 deletions doc/fff.nvim.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
*fff.nvim.txt*
For Neovim >= 0.10.0 Last change: 2026 April 23
For Neovim >= 0.10.0 Last change: 2026 April 26

==============================================================================
Table of Contents *fff.nvim-table-of-contents*
Expand All @@ -11,14 +11,12 @@ Table of Contents *fff.nvim-table-of-contents*
1. Links |fff.nvim-links|




A file search toolkit for humans and AI agents. Really fast.Typo-resistant path and content search, frecency-ranked file access, a
background watcher, and a lightweight in-memory content index. Way faster than
CLIs like ripgrep and fzf in any long-running process that searches more than
once.

It started life as a |fff.nvim-neovim-plugin| people loved, but it turned out
Originally started as |fff.nvim-neovim-plugin| people loved, but it turned out
that plenty of AI harnesses and code editors need the same thing: accurate,
fast file search as a library. That is what fff is.

Expand Down Expand Up @@ -496,7 +494,7 @@ MINIMAL EXAMPLE ~

NOTES ~

- Every function returning `FffResult*` allocates with Rust’s `Box`. Free with `fff_free_result` not `libc::free`.
- Every function returning `FffResult*` allocates with Rust’s `Box`. Free with `fff_free_result`, do not use malloc’s free
- Payloads (search results, grep results, scan progress) have their own dedicated free functions listed in the header.
- C strings returned in the `handle` field (e.g. from `fff_get_base_path`) are freed with `fff_free_string`.

Expand Down Expand Up @@ -571,7 +569,7 @@ than a burst of ripgrep invocations
<https://x.com/neogoose_btw/status/2041606853155811442>.

FFF also keeps a content index, around 360 bytes per indexed file, so roughly
36 MB for a 100k-file repo. Not every file is indexed binaries, oversized
36 MB for a 100k-file repo. Not every file is indexed - binaries, oversized
files, and anything not eligible for grep are skipped. If even that footprint
is too much, the index can be backed by a memory-mapped file instead of
anonymous RAM.
Expand Down
3 changes: 3 additions & 0 deletions packages/fff-node/src/ffi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,9 @@ function readGrepMatchFromRaw(raw: FffGrepMatchRaw): GrepMatch {
if (raw.context_after_count > 0) {
match.contextAfter = readCStringArray(raw.context_after, raw.context_after_count);
}
if (raw.is_definition !== 0) {
match.isDefinition = true;
}

return match;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/fff-node/src/finder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ export class FileFinder {
options?.timeBudgetMs ?? 0,
options?.beforeContext ?? 0,
options?.afterContext ?? 0,
false,
options?.classifyDefinitions ?? false,
);
}

Expand Down Expand Up @@ -374,7 +374,7 @@ export class FileFinder {
options.timeBudgetMs ?? 0,
options.beforeContext ?? 0,
options.afterContext ?? 0,
false,
options.classifyDefinitions ?? false,
);
}

Expand Down
13 changes: 13 additions & 0 deletions packages/fff-node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,12 @@ export interface GrepOptions {
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
* without a TS-side regex port. (default: false)
*/
classifyDefinitions?: boolean;
}

/**
Expand Down Expand Up @@ -398,6 +404,8 @@ export interface GrepMatch {
contextBefore?: string[];
/** Lines after the match (context). Empty array when context is 0. */
contextAfter?: string[];
/** Whether this line is a code definition (only populated when `classifyDefinitions: true`). */
isDefinition?: boolean;
}

/**
Expand Down Expand Up @@ -454,4 +462,9 @@ export interface MultiGrepOptions {
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. (default: false)
*/
classifyDefinitions?: boolean;
}
Loading