Skip to content
55 changes: 35 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ Drop this into your project's `CLAUDE.md` or equivalent:
For any file search or grep in the current git-indexed directory, use fff tools.
```

### Definition search

MCP and Pi expose only the optional `definitions_only` boolean to the model. MCP accepts `{ "query": "AudioMap", "definitions_only": true }`; Pi's `ffgrep` uses `pattern` instead of `query`. Single-query grep tries these stages in order:

1. Matching declarations.
2. Ordinary exact matches, with a notice that no matching declarations were found.
3. Fuzzy declarations, labeled “Maybe you meant these definitions”.
4. Fuzzy ordinary matches, labeled “Maybe you meant these other matches”.

A stage advances only after its complete search returns no matches. A partial page keeps its current stage, and a stage that returned matches never broadens when pagination ends. Cursors preserve the stage, and all stages keep the original file constraints. The multi-pattern tools accept the same flag but use only the first two stages; their patterns remain literal.

### What changes

- Frecency memory. Files you actually open rank higher next time. Warm-up from git touch history runs automatically.
Expand Down Expand Up @@ -153,6 +164,8 @@ Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode
- `ffgrep`. Content search. Accepts `path`, `exclude` (comma, space, or array; leading `!` optional), `caseSensitive`, `context`, and cursor pagination. Auto-detects regex, falls back to fuzzy on zero exact matches, rejects `.*`-style wildcard-only patterns up front.
- `fffind`. Path and filename search. Matches the whole repo-relative path, not just the filename. Frecency-aware. The weak-match detector flags scattered fuzzy noise before it floods the agent's context.

Set `definitions_only: true` on `ffgrep` to try declarations first, then exact references, fuzzy declarations, and fuzzy references. Each fallback includes a notice. See the [extension documentation](packages/pi-fff/README.md) for pagination and multi-pattern behavior.

### Commands

- `/fff-mode [tools-and-ui | tools-only | override]`. Show or switch the mode.
Expand Down Expand Up @@ -491,27 +504,9 @@ require('fff').setup({

`<S-Tab>` cycles between `plain`, `regex`, `fuzzy`, and `definition_only`. The list is configurable via `grep.modes`, and single-mode setups hide the indicator entirely. `definition_only` uses plain substring matching and returns only matched declarations, regardless of `grep.classify_definitions`. Its status label uses `hl.grep_definition_active` (default: `DiagnosticOk`).

Set `grep.classify_definitions = true` to show matching definition lines first within each page, grouped under their file names. Each page uses one ordinary grep search; classification does not scan ahead for definitions or search the files again for usages. It defaults to `false`, which skips definition classification and grouping. A **Definitions** divider separates the sections when both are on the same page. With a bottom prompt, the order reverses so definitions stay nearest the prompt. Files with both kinds of matches appear in both sections, with each match shown once.

Each core grep call uses one `classify_definitions` mode. Rust stores it in `GrepSearchOptions` as `DefinitionMode`, defaulting to `Off`. The JavaScript SDKs expose the same setting as `classifyDefinitions`.

| Mode | Rust variant | C integer | Result |
| --- | --- | --- | --- |
| `off` | `Off` | `0` | All matches, without classification |
| `classify` | `Classify` | `1` | All matches, with definition labels |
| `definition_only` | `DefinitionOnly` | `2` | Only matches on declared names |
| `usage_only` | `UsageOnly` | `3` | Only other matches |

JavaScript and Neovim also accept the existing booleans: `false` means `off`, and `true` means `classify`. Neovim groups each page of `classify` results into definitions first, then usages. The SDK default is `off`. Use the same mode when continuing with a cursor. The C argument is a `uint8_t`, preserving the byte representation of existing boolean calls (`0` and `1`). Modes `2` and `3` require the updated native library; never send them to a library that still expects `bool`.

```typescript
finder.grep('AudioMap', { classifyDefinitions: 'definition_only' });
finder.multiGrep({ patterns: ['AudioMap', 'audio_map'], classifyDefinitions: 'definition_only' });
```

MCP and Pi expose only the optional `definitions_only` boolean to the model. MCP accepts `{ "query": "AudioMap", "definitions_only": true }`; Pi's `ffgrep` uses `pattern` instead of `query`. If the completed search finds no matching declarations, the tool reports that and returns ordinary matches. Cursors preserve the definition search or fallback state, so the model does not need to repeat a mode. The multi-pattern tools accept the same flag.
Set `grep.classify_definitions = true` to show matching definition lines first within each page, grouped under their file names. Each page uses one ordinary grep search; classification does not scan ahead for definitions or search the files again for usages. It defaults to `false`, which skips definition classification and grouping. It also accepts the strings `'classify'`, `'definition_only'`, and `'usage_only'`. A **Definitions** divider separates the sections when both are on the same page. With a bottom prompt, the order reverses so definitions stay nearest the prompt. Files with both kinds of matches appear in both sections, with each match shown once.

SDK definition filtering occurs before page and per-file result limits. Cursors resume at the next file; page size is a soft limit because the current file is returned together. Repeat the same query, mode, and constraints for each page. A timed-out page can be empty with a continuation cursor. In Neovim, move down (top prompt) or up (bottom prompt) to continue an empty page. Context lines can still contain ordinary code.
Definition filtering occurs before page and per-file result limits. A timed-out page can be empty; move down (top prompt) or up (bottom prompt) to continue searching. Context lines can still contain ordinary code.

Definition classification accepts source lines up to 256 bytes, including indentation and excluding line endings. Longer lines stay ordinary matches and are excluded from `definition_only`. The limit uses the original source bytes, regardless of display truncation or whitespace trimming. For multiline regex matches, it applies to the first physical line.

Expand Down Expand Up @@ -672,6 +667,26 @@ finder.value.destroy();

Every method returns a `Result<T>` (`{ ok: true, value } | { ok: false, error }`). Full type reference: [`packages/fff-node/src/fff-api.ts`](./packages/fff-node/src/fff-api.ts).

### Definition modes

Each core grep call uses one `classify_definitions` mode. Rust stores it in `GrepSearchOptions` as `DefinitionMode`, defaulting to `Off`. The JavaScript SDKs expose the same setting as `classifyDefinitions`.

| Mode | Rust variant | C integer | Result |
| --- | --- | --- | --- |
| `off` | `Off` | `0` | All matches, without classification |
| `classify` | `Classify` | `1` | All matches, with definition labels |
| `definition_only` | `DefinitionOnly` | `2` | Only matches on declared names |
| `usage_only` | `UsageOnly` | `3` | Only other matches |

JavaScript also accepts the existing booleans: `false` means `off`, and `true` means `classify`. The SDK default is `off`. Use the same mode when continuing with a cursor. The C argument is a `uint8_t`, preserving the byte representation of existing boolean calls (`0` and `1`). Modes `2` and `3` require the updated native library; never send them to a library that still expects `bool`.

```typescript
finder.grep('AudioMap', { classifyDefinitions: 'definition_only' });
finder.multiGrep({ patterns: ['AudioMap', 'audio_map'], classifyDefinitions: 'definition_only' });
```

SDK definition filtering occurs before page and per-file result limits. `definition_only` retains file constraints when no matches exist. `usage_only` keeps the file-path and literal retries of ordinary grep. Cursors resume at the next file; page size is a soft limit because the current file is returned together. Repeat the same query, mode, and constraints for each page. A timed-out page can be empty with a continuation cursor. Context lines can still contain ordinary code.

</details>

TypeScript wrapper over the C library for nodejs and bun. Build custom agent tools, CLIs, or IDE integrations on top of FFF.
Expand Down
50 changes: 50 additions & 0 deletions crates/fff-core/src/grep/grep_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,56 @@ fn definition_only_keeps_unknown_language_files_out_of_fallbacks() {
}
}

#[test]
fn definition_only_keeps_empty_scopes_while_usage_only_retries() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("a.rs"),
"fn Needle() {}\nNeedle();\n*.txt Needle\n",
)
.unwrap();
std::fs::write(dir.path().join("b.txt"), "unrelated\n").unwrap();
let mut picker = FilePicker::new(FilePickerOptions {
base_path: dir.path().to_string_lossy().into_owned(),
enable_content_indexing: false,
enable_mmap_cache: false,
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
for mode in [GrepMode::PlainText, GrepMode::Regex, GrepMode::Fuzzy] {
let options = GrepSearchOptions {
mode,
classify_definitions: DefinitionMode::DefinitionOnly,
time_budget_ms: 0,
..Default::default()
};
for raw in ["missing.rs Needle", "*.txt Needle", "!*.rs Needle"] {
let result = picker.grep(&parser.parse(raw), &options);
assert!(result.matches.is_empty(), "{mode:?}: {raw}");
assert_eq!(result.next_file_offset, 0);
assert!(!result.literal_fallback);
}
}

// usage_only is public API: it keeps the path and literal retries of unfiltered grep.
let options = GrepSearchOptions {
classify_definitions: DefinitionMode::UsageOnly,
time_budget_ms: 0,
..Default::default()
};
let lines = |raw: &str| {
let result = picker.grep(&parser.parse(raw), &options);
let lines: Vec<u64> = result.matches.iter().map(|m| m.line_number).collect();
(lines, result.literal_fallback)
};
assert_eq!(lines("missing.rs Needle"), (vec![2, 3], false));
assert_eq!(lines("*.txt Needle"), (vec![3], true));
assert_eq!(lines("!*.rs Needle"), (vec![], false));
}

#[test]
fn pagination_keeps_unreturned_files_before_abort() {
let file = crate::types::FileItem::new_raw(0, 1, 0, None, false);
Expand Down
54 changes: 52 additions & 2 deletions crates/fff-mcp/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,58 @@ const MAX_CURSORS: usize = 20;
#[derive(Clone, Copy, Default)]
pub(crate) struct GrepCursor {
pub file_offset: usize,
pub definitions_only: bool,
pub found_definitions: bool,
pub stage: GrepStage,
pub found_matches: bool,
pub missing_path: bool,
}

#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
pub(crate) enum GrepStage {
#[default]
Normal,
Broadened,
Definitions,
References,
FuzzyDefinitions,
FuzzyMatches,
}

impl GrepStage {
pub fn next(self) -> Option<Self> {
match self {
Self::Definitions => Some(Self::References),
Self::References => Some(Self::FuzzyDefinitions),
Self::FuzzyDefinitions => Some(Self::FuzzyMatches),
_ => None,
}
}

pub fn is_fuzzy(self) -> bool {
matches!(self, Self::FuzzyDefinitions | Self::FuzzyMatches)
}

pub fn definition_mode(self) -> fff::DefinitionMode {
match self {
Self::Definitions | Self::FuzzyDefinitions => fff::DefinitionMode::DefinitionOnly,
// A file-path retry can widen these stages, so keep definitions found elsewhere.
Self::References | Self::FuzzyMatches | Self::Normal | Self::Broadened => {
fff::DefinitionMode::Classify
}
}
}

pub fn notice(self) -> Option<&'static str> {
match self {
Self::References => Some("No matching definitions found. Showing other matches:"),
Self::FuzzyDefinitions => {
Some("No exact matches found. Maybe you meant these definitions:")
}
Self::FuzzyMatches => Some(
"No exact matches or approximate definitions found. Maybe you meant these other matches:",
),
_ => None,
}
}
}

/// Stores cursor state for paginated grep results.
Expand Down
2 changes: 1 addition & 1 deletion crates/fff-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
"Grep matches single lines. Search for ONE identifier per query:\n",
" + 'InProgressQuote' -> finds definition + all usages\n",
" + 'ActorAuth' -> finds enum, struct, all call sites\n",
"Set definitions_only to true to find declarations. If none are found, the tool reports that and returns other matches. Cursors preserve this behavior.\n",
"Set definitions_only to true to find declarations. Grep tries declarations, exact matches, fuzzy declarations, then fuzzy matches; it reports fallbacks. Multi-grep stays literal. Cursors keep the current stage; fallback starts only after a completed search with no matches.\n",
" x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n",
" x 'ctx.data::<ActorAuth>' -> code syntax, too specific, 0 results\n",
" x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n",
Expand Down
Loading
Loading