Skip to content

Commit 84ba575

Browse files
feat(pi-fff): Improve instructions and add more features (#414)
* feat(pi-fff): Improve instructions and add more features So what I've done is simple: I run autoresearch to fine tune the prompts for the best results, then thrown it all away cause autoresearch is a fucking trash and manually fine tuned by running "analyze X" in various repos and asking AI what went wrong * chore: Update docs for - feat(pi-fff): Improve instructions and add more features * chore: Update docs for - chore: Update docs for - feat(pi-fff): Improve instructions and add more features
1 parent bca71ef commit 84ba575

8 files changed

Lines changed: 726 additions & 224 deletions

File tree

crates/fff-mcp/server/.gitkeep

Whitespace-only changes.

crates/fff-query-parser/src/config.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ pub trait ParserConfig {
9191
has_wildcards(token)
9292
}
9393

94+
/// When `true`, a PathSegment constraint that is the ONLY token in the
95+
/// query is demoted to fuzzy text. Grep modes enable this because the
96+
/// user is typing a search term (e.g. `/api/tests`), not scoping to a
97+
/// directory. File-search modes keep the default (`false`) so that
98+
/// `/src/` still filters by directory.
99+
fn treat_lone_path_as_text(&self) -> bool {
100+
false
101+
}
102+
94103
/// Custom constraint parsers for picker-specific needs
95104
fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
96105
None
@@ -137,6 +146,10 @@ impl ParserConfig for GrepConfig {
137146
false
138147
}
139148

149+
fn treat_lone_path_as_text(&self) -> bool {
150+
true
151+
}
152+
140153
/// Only recognise globs that are clearly directory/path oriented.
141154
///
142155
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
@@ -209,6 +222,10 @@ impl ParserConfig for AiGrepConfig {
209222
false
210223
}
211224

225+
fn treat_lone_path_as_text(&self) -> bool {
226+
true
227+
}
228+
212229
fn is_glob_pattern(&self, token: &str) -> bool {
213230
// First check GrepConfig's strict rules (path globs, brace expansion)
214231
if GrepConfig.is_glob_pattern(token) {

crates/fff-query-parser/src/parser.rs

Lines changed: 194 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,15 @@ impl<C: ParserConfig> QueryParser<C> {
6161
.rev()
6262
.take_while(|&b| b != b':')
6363
.all(|b| b.is_ascii_digit());
64-
if !matches!(constraint, Constraint::FilePath(_)) && !has_location_suffix {
64+
65+
// for grep we don't want to treat a part of path like pathname
66+
let treat_as_text = matches!(constraint, Constraint::PathSegment(_))
67+
&& config.treat_lone_path_as_text();
68+
69+
if !matches!(constraint, Constraint::FilePath(_))
70+
&& !has_location_suffix
71+
&& !treat_as_text
72+
{
6573
constraints.push(constraint);
6674
return FFFQuery {
6775
raw_query,
@@ -102,6 +110,10 @@ impl<C: ParserConfig> QueryParser<C> {
102110
let tokens = query.split_whitespace();
103111

104112
let mut has_file_path = false;
113+
// Track the FilePath token position in constraints so we can promote
114+
// it back to text if the final query ends up with no fuzzy text.
115+
let mut file_path_constraint_idx: Option<usize> = None;
116+
let mut file_path_token: Option<&str> = None;
105117
for token in tokens {
106118
match parse_token(token, config) {
107119
Some(Constraint::FilePath(_)) => {
@@ -111,6 +123,8 @@ impl<C: ParserConfig> QueryParser<C> {
111123
// searching for).
112124
text_parts.push(token);
113125
} else {
126+
file_path_constraint_idx = Some(constraints.len());
127+
file_path_token = Some(token);
114128
constraints.push(Constraint::FilePath(token));
115129
has_file_path = true;
116130
}
@@ -124,6 +138,21 @@ impl<C: ParserConfig> QueryParser<C> {
124138
}
125139
}
126140

141+
// If the query produced a single FilePath and no fuzzy text parts, the
142+
// user isn't filtering by filename suffix — they're fuzzy-searching
143+
// for that name (the only other constraints are path-scoping like
144+
// PathSegment/Extension/Glob). Mirror the single-token rule at
145+
// parser.rs:48-64: promote FilePath → fuzzy text so e.g. `profile.h`
146+
// alongside `chrome/browser/profiles/` fuzzy-matches all `profile*.h`
147+
// files instead of only one file literally ending in `/profile.h`.
148+
if text_parts.is_empty()
149+
&& let Some(idx) = file_path_constraint_idx
150+
&& let Some(tok) = file_path_token
151+
{
152+
constraints.remove(idx);
153+
text_parts.push(tok);
154+
}
155+
127156
// Try to extract location from the last fuzzy token
128157
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
129158
let location = if config.enable_location() && !text_parts.is_empty() {
@@ -916,6 +945,164 @@ mod tests {
916945
assert_eq!(result.grep_text(), "pattern");
917946
}
918947

948+
#[test]
949+
fn test_ai_grep_filename_with_pathsegment_only_promotes_to_text() {
950+
// When the ONLY non-text constraints are path-scoping (PathSegment,
951+
// here), a bare filename token like `profile.h` should NOT be used as
952+
// a FilePath filter — the user is fuzzy-searching within that dir,
953+
// not asking for files named exactly `profile.h`.
954+
use crate::AiGrepConfig;
955+
let parser = QueryParser::new(AiGrepConfig);
956+
let result = parser.parse("chrome/browser/profiles/ profile.h");
957+
assert_eq!(result.constraints.len(), 1);
958+
assert!(
959+
matches!(
960+
result.constraints[0],
961+
Constraint::PathSegment("chrome/browser/profiles")
962+
),
963+
"Expected single PathSegment, got {:?}",
964+
result.constraints
965+
);
966+
assert_eq!(result.grep_text(), "profile.h");
967+
}
968+
969+
#[test]
970+
fn test_ai_grep_leading_slash_path_alone_is_text_not_path_segment() {
971+
// A leading-slash multi-segment path like `/api/tests/` or `/api/tests`
972+
// used as the sole query token should be treated as fuzzy text, NOT as
973+
// a PathSegment constraint. The user is searching for files matching
974+
// that path string, not trying to scope results to a directory.
975+
use crate::AiGrepConfig;
976+
let parser = QueryParser::new(AiGrepConfig);
977+
978+
// With trailing slash
979+
let result = parser.parse("/api/tests/");
980+
assert_eq!(
981+
result.constraints.len(),
982+
0,
983+
"Expected no constraints for '/api/tests/', got {:?}",
984+
result.constraints
985+
);
986+
assert!(
987+
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
988+
"Expected FuzzyQuery::Text, got {:?}",
989+
result.fuzzy_query
990+
);
991+
992+
// Without trailing slash
993+
let result = parser.parse("/api/tests");
994+
assert_eq!(
995+
result.constraints.len(),
996+
0,
997+
"Expected no constraints for '/api/tests', got {:?}",
998+
result.constraints
999+
);
1000+
assert!(
1001+
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
1002+
"Expected FuzzyQuery::Text, got {:?}",
1003+
result.fuzzy_query
1004+
);
1005+
}
1006+
1007+
#[test]
1008+
fn test_grep_leading_slash_path_alone_is_text_not_path_segment() {
1009+
// Same behavior for regular GrepConfig — single-token path-like
1010+
// queries are search terms, not directory filters.
1011+
let parser = QueryParser::new(GrepConfig);
1012+
1013+
let result = parser.parse("/api/tests/");
1014+
assert_eq!(
1015+
result.constraints.len(),
1016+
0,
1017+
"GrepConfig: expected no constraints for '/api/tests/', got {:?}",
1018+
result.constraints
1019+
);
1020+
assert!(
1021+
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
1022+
"GrepConfig: expected FuzzyQuery::Text, got {:?}",
1023+
result.fuzzy_query
1024+
);
1025+
1026+
let result = parser.parse("/api/tests");
1027+
assert_eq!(
1028+
result.constraints.len(),
1029+
0,
1030+
"GrepConfig: expected no constraints for '/api/tests', got {:?}",
1031+
result.constraints
1032+
);
1033+
assert!(
1034+
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
1035+
"GrepConfig: expected FuzzyQuery::Text, got {:?}",
1036+
result.fuzzy_query
1037+
);
1038+
}
1039+
1040+
#[test]
1041+
fn test_file_search_leading_slash_path_alone_stays_path_segment() {
1042+
// FileSearchConfig (fuzzy file finder) should still treat a lone
1043+
// `/api/tests/` as a PathSegment constraint — the user is scoping
1044+
// the file list to that directory.
1045+
let parser = QueryParser::new(FileSearchConfig);
1046+
1047+
let result = parser.parse("/api/tests/");
1048+
assert_eq!(
1049+
result.constraints.len(),
1050+
1,
1051+
"FileSearchConfig: expected PathSegment constraint, got {:?}",
1052+
result.constraints
1053+
);
1054+
assert!(
1055+
matches!(result.constraints[0], Constraint::PathSegment("api/tests")),
1056+
"FileSearchConfig: expected PathSegment(\"api/tests\"), got {:?}",
1057+
result.constraints[0]
1058+
);
1059+
1060+
let result = parser.parse("/api/tests");
1061+
assert_eq!(
1062+
result.constraints.len(),
1063+
1,
1064+
"FileSearchConfig: expected PathSegment constraint, got {:?}",
1065+
result.constraints
1066+
);
1067+
assert!(
1068+
matches!(result.constraints[0], Constraint::PathSegment("api/tests")),
1069+
"FileSearchConfig: expected PathSegment(\"api/tests\"), got {:?}",
1070+
result.constraints[0]
1071+
);
1072+
}
1073+
1074+
#[test]
1075+
fn test_ai_grep_filename_with_extension_only_promotes_to_text() {
1076+
// Same case with an Extension constraint — no fuzzy text means the
1077+
// filename is what the user is searching for.
1078+
use crate::AiGrepConfig;
1079+
let parser = QueryParser::new(AiGrepConfig);
1080+
let result = parser.parse("*.h profile.h");
1081+
assert_eq!(result.constraints.len(), 1);
1082+
assert!(
1083+
matches!(result.constraints[0], Constraint::Extension("h")),
1084+
"Expected Extension, got {:?}",
1085+
result.constraints
1086+
);
1087+
assert_eq!(result.grep_text(), "profile.h");
1088+
}
1089+
1090+
#[test]
1091+
fn test_ai_grep_filename_with_other_text_keeps_filepath() {
1092+
// Sanity: when there IS fuzzy text alongside the filename, the
1093+
// filename stays a FilePath filter (the documented multi-token case).
1094+
use crate::AiGrepConfig;
1095+
let parser = QueryParser::new(AiGrepConfig);
1096+
let result = parser.parse("main.rs pattern");
1097+
assert_eq!(result.constraints.len(), 1);
1098+
assert!(
1099+
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
1100+
"Expected FilePath, got {:?}",
1101+
result.constraints
1102+
);
1103+
assert_eq!(result.grep_text(), "pattern");
1104+
}
1105+
9191106
#[test]
9201107
fn test_ai_grep_bare_filename_schema_rs() {
9211108
use crate::AiGrepConfig;
@@ -1157,16 +1344,16 @@ mod tests {
11571344
fn test_file_picker_filename_with_extension_constraint() {
11581345
let parser = QueryParser::new(FileSearchConfig);
11591346
let result = parser.parse("main.rs *.lua");
1160-
// main.rs → FilePath, *.lua → Extension
1161-
assert_eq!(result.constraints.len(), 2);
1347+
// With only path-scoping constraints (Extension) and no fuzzy text,
1348+
// `main.rs` is promoted to fuzzy text — the user is fuzzy-searching
1349+
// for "main.rs" among `.lua` files, not filtering by literal filename
1350+
// suffix. Only the Extension constraint remains.
1351+
assert_eq!(result.constraints.len(), 1);
11621352
assert!(matches!(
11631353
result.constraints[0],
1164-
Constraint::FilePath("main.rs")
1165-
));
1166-
assert!(matches!(
1167-
result.constraints[1],
11681354
Constraint::Extension("lua")
11691355
));
1356+
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("main.rs"));
11701357
}
11711358

11721359
#[test]

doc/fff.nvim.txt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
*fff.nvim.txt*
2-
For Neovim >= 0.10.0 Last change: 2026 April 23
2+
For Neovim >= 0.10.0 Last change: 2026 April 26
33

44
==============================================================================
55
Table of Contents *fff.nvim-table-of-contents*
@@ -11,14 +11,12 @@ Table of Contents *fff.nvim-table-of-contents*
1111
1. Links |fff.nvim-links|
1212

1313

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

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

@@ -496,7 +494,7 @@ MINIMAL EXAMPLE ~
496494

497495
NOTES ~
498496

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

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

573571
FFF also keeps a content index, around 360 bytes per indexed file, so roughly
574-
36 MB for a 100k-file repo. Not every file is indexed binaries, oversized
572+
36 MB for a 100k-file repo. Not every file is indexed - binaries, oversized
575573
files, and anything not eligible for grep are skipped. If even that footprint
576574
is too much, the index can be backed by a memory-mapped file instead of
577575
anonymous RAM.

packages/fff-node/src/ffi.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,9 @@ function readGrepMatchFromRaw(raw: FffGrepMatchRaw): GrepMatch {
821821
if (raw.context_after_count > 0) {
822822
match.contextAfter = readCStringArray(raw.context_after, raw.context_after_count);
823823
}
824+
if (raw.is_definition !== 0) {
825+
match.isDefinition = true;
826+
}
824827

825828
return match;
826829
}

packages/fff-node/src/finder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ export class FileFinder {
325325
options?.timeBudgetMs ?? 0,
326326
options?.beforeContext ?? 0,
327327
options?.afterContext ?? 0,
328-
false,
328+
options?.classifyDefinitions ?? false,
329329
);
330330
}
331331

@@ -374,7 +374,7 @@ export class FileFinder {
374374
options.timeBudgetMs ?? 0,
375375
options.beforeContext ?? 0,
376376
options.afterContext ?? 0,
377-
false,
377+
options.classifyDefinitions ?? false,
378378
);
379379
}
380380

packages/fff-node/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,12 @@ export interface GrepOptions {
358358
beforeContext?: number;
359359
/** Number of context lines to include after each match (default: 0) */
360360
afterContext?: number;
361+
/**
362+
* When true, classify each match line as a code definition (struct/fn/class/...)
363+
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
364+
* without a TS-side regex port. (default: false)
365+
*/
366+
classifyDefinitions?: boolean;
361367
}
362368

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

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

0 commit comments

Comments
 (0)