fff — the grep engine (Rust)
The content-search organ of a resident file-picker daemon: three modes behind one dispatch point, a soft page limit over a wall-clock budget, and a resumable file-offset cursor. It is hue's stated design source for grep's budget, cursor and mode-fallback behaviour, which is why it is surveyed here rather than in the fuzzy-matching tree that owns fff's path matcher.
| Field | Value |
|---|---|
| Language | Rust |
| Repository | dmtrKovalenko/fff |
| Surveyed revision | 3a0ce85c54875563bc55888b6b9829b44aeca911 |
| Size | 2,684 lines (fff-core/src/grep/) + 1,024 (fff-grep/, a vendored fork) |
| Category | Unindexed scanner, resident |
| Engine class | Three: memmem literal · Rust regex (bytes, Unicode off) · Smith-Waterman |
| Index | Optional bigram content index (grep-only; see trigram-indexes (Phase 4)) |
| Interactive | In-process, resident; budget + abort + file cursor |
Last reviewed: August 28, 2026.
NOTE
This page covers two organs in one repository. fff-core/src/grep/ is the search engine; fff-grep/ is a vendored fork of ripgrep's grep-searcher, stripped to a fraction of its size. The fork is the more useful artifact for Sparkles, because it is a worked answer to "what is the minimum a line-oriented searcher must own".
Overview
What it solves
fff is a file picker that keeps its index and a content cache warm in one long-lived process. Its grep path exists to answer a content query inside a frame, over a corpus it has already walked, without the process-spawn tax that every editor-side live-grep integration pays per keystroke.
The module's own header states the architecture:
"Live grep.
grep.rsimplements the main plain-text path, the parallel scan engine, and thegrep_searchentry point that picks the matcher/sink for every mode in one place;regex,multi_pattern, andfuzzy_grephold the mode-specific machinery on top of the sharedprefilter/sink." —grep/mod.rs[source-verified]
Design philosophy
Three commitments run through the code, and each is stated in a comment beside the thing it justifies.
Degrade, and say so. No input is a hard error. An uncompilable regex falls back to literal search and returns the compiler's message in a result field for the UI to show. A constrained query that scanned everything and found nothing is retried raw. A path constraint that filtered every file is dropped and retried. Each rung sets a flag the caller can render.
Bound the wrong answer, not the right one. The page limit is soft — the file that tips the count over is finished, not truncated — and the budget is checked between files rather than between lines. Cheap, coarse bounds; no partial file.
Prefilter before machinery, not just before the engine. The whole-file memmem check runs before the Searcher is built at all, skipping allocation and line-splitting rather than merely skipping the match loop.
How it works
grep_search parses the query into constraints plus a search text, prefilters the file list, then dispatches on mode. Plain text and regex share a searcher and differ only in matcher/sink; fuzzy returns early into its own path, because it needs whole-file line vectors that the line-oriented searcher does not produce.
The dispatch is deliberately one site, and says so:
// The single sink-selection point: every mode's matcher/sink pairing
// is decided here based on the compiled pattern.
|file_bytes: &[u8], max_matches: usize| { … }— grep.rs [source-verified]
The fallback ladder
Three rungs, in three different places, and they do not agree with each other — which is itself the finding for a picker deciding how modes should behave.
- Constraint → literal, in
grep_search: if the constrained search scanned the whole corpus and found nothing, the raw query is retried as literal text with all constraints dropped, andliteral_fallbackis set. The comment gives the motivating case: "Constraint parsing can swallow tokens the user meant literally (e.g.!=becoming an exclusion)." - Regex → literal, at compile time: a pattern that fails to compile becomes a literal search, and the error string is returned in
regex_fallback_errorso, in the field's own words, "The UI can display this to inform the user their regex was invalid." - Path-constraint retry, in the prefilter: zero files after constraints and a
FilePathconstraint present ⇒ retry without it, because "the path token was likely part of the search text."
Mode selection is not in the engine at all. It lives in callers, via a one-line test:
pub fn has_regex_metacharacters(text: &str) -> bool {
regex::escape(text) != text
}— regex.rs. Escape the pattern; if escaping changed it, it contains metacharacters. [source-verified]
The ten dimensions
1. Pattern language
Three, chosen by GrepMode::{PlainText, Regex, Fuzzy} — PlainText is Default. Regex is the Rust regex crate over bytes with Unicode disabled and multi_line(true); \n escapes in the pattern are expanded to real newlines before compilation, and a multiline pattern automatically widens the after-context so the whole match is shown. Fuzzy is subsequence-with-typos over neo_frizbee (surveyed as frizbee in the sibling tree), not a regular language at all.
Smart case is a per-search option; the regex builder derives case_insensitive from "any uppercase in the pattern".
2. Engine architecture
- Plain:
NeedleFinder, a two-variant enum overmemchr::memmem::Finder(case-sensitive) or a pre-lowered needle searched withmemmem::find(case-insensitive). The case branch is resolved once per line, not per occurrence —for_each_occurrencematches on the variant outside its loop. - Regex: the
regexcrate's own meta-engine, unexamined here; seerust-regex(Phase 2). - Fuzzy: Smith-Waterman with an
exact_match_bonusof 100 and default gap penalties, and the comment explains why the obvious tuning is wrong: raising gap penalties makes the aligner "prefer dropping needle chars over paying gap costs, which inflates the typo count and breaks transposition matching" —shcema→schemabecomes three typos instead of one. - Multi-pattern: Aho-Corasick for an OR-of-literals.
Worst case is bounded by the corpus and the budget rather than by the automaton; no engine here can backtrack catastrophically.
3. Prefilter and literal extraction
Four layers, applied in order of cost:
- Bigram content index (optional) → a candidate file bitmap. Grep-only; see
trigram-indexes(Phase 4). - Constraint prefilter over the file list — path, extension, git status — with the
FilePathretry above. - Whole-file
memmem, before the searcher exists: "Fast whole-file memmem check before entering the grep-searcher machinery. Skips Vec alloc, Searcher setup, and line-splitting for files that can't match." Enabled only whenregex.is_none()— there is no literal extraction from a compiled regex, so regex mode forfeits this layer entirely. - Fuzzy only — distinct-character presence: collect the needle's unique bytes (both cases), require
unique_count - max_typosof them to appear anywhere in the file viamemchr, else skip the file.
That fourth layer is the one worth stealing: it is a memchr-per-distinct-byte test that costs almost nothing and rejects most files for a typical identifier query, and it is derived from the typo budget rather than tuned.
4. Corpus access
Files arrive from the resident index, already walked and flagged. Content comes from get_content_for_search, which serves a reusable 64 KiB read buffer for small files and a fresh mmap slot for cache-miss files above a threshold, under a content-cache budget. Caps: MAX_FFFILE_SIZE = 10 MiB, max_matches_per_file = 200.
Binary detection is not in the grep path. It is a flag set at index time from a sniff, and grep simply honours the filtered list — cheap, and stale if the file changed since the walk.
Line iteration is LineStep from the vendored fork; the fuzzy path materialises Vec<&str> line and metadata vectors per file, sized len / 40.
5. Concurrency
Rayon, with files as the unit and a growth policy that is the most carefully-reasoned code in the module:
"Each chunk is a rayon barrier. A flat small chunk over 500k files = ~7800 barriers; x2 growth makes it logarithmic. But a too-aggressive growth over-scans: when a page fills mid-chunk, the whole submitted chunk still runs. So only grow when the prefilter is weak (large candidate set); when bigram cut the set in half, keep fixed small chunks for cheap page-fill termination." —
grep.rs[source-verified]
Concretely: base_chunk = threads * 4; growth is ×2 and the cap is max(base * 256, 8192) when the prefilter is weak, and ×1 at base_chunk when it is strong (candidates * 2 < total). Per-worker state — matcher clone, read buffer, mmap slot — is created by map_init, once per worker rather than per file.
Abort and budget are polled every eighth file: "perform all the atomic machinery on every 8th". In the plain/regex path the budget is additionally gated on all_matches.len() > 1, so a search that has found nothing keeps going past its deadline; the fuzzy path checks unconditionally. The two paths disagree, and neither comments on the difference.
6. Index
None required. The optional bigram index is a content index — 2-byte case-folded keys over dense column-major bitmaps — and is covered with the rest of the n-gram family in trigram-indexes (Phase 4). It narrows the file list before any of the above runs.
7. Ranking and result model — including classification
A GrepMatch is deliberately rich: file index into a deduplicated file list, 1-based line, 0-based byte column, absolute byte offset (so, per its own doc, "the preview can seek directly without scanning from the top"), the truncated line text, a SmallVec<[(u32,u32); 4]> of highlight spans, an optional fuzzy score, an is_definition flag computed at match time "so output formatters don't need to re-scan", and context vectors.
There is no ranking: results are in scan order, and only the fuzzy path carries a score. Ordering is a caller's problem.
Definition classification is classify.rs, 131 lines, and its own header is the most important sentence on this page for anyone considering porting it:
"Definition and import line classification (vibe coded POC) … Used to rank/annotate grep results for AI/MCP consumers. Gated behind the
definitionsfeature since only such consumers need it."[source-verified]
Mechanically: trim leading whitespace, skip any number of modifier keywords (ten of them, plus pub(crate)-style visibility by scanning to )), then test the first token against thirteen definition keywords with an ASCII word-boundary check. is_import_line is a separate, simpler prefix test. It is entirely language-agnostic — type and object are in the keyword set — and it never checks whether the match falls inside the identifier the keyword introduces, so a line that merely calls the needle inside a function definition classifies as a definition. The option's own doc puts the cost at "~2% overhead on large repos" and advises disabling it "for interactive grep where it is not needed".
8. Unicode
Minimal, and deliberately. Regex compiles with .unicode(false); case-insensitive plain search pre-lowers the needle bytes; the classifier is ASCII-only. The one Unicode-correct behaviour is display truncation, which walks back to a character boundary. The fuzzy path validates the whole file as UTF-8 once and then uses from_utf8_unchecked per line, because — measured — the per-line checks were "~8% of fuzzy grep time".
9. Interactive behaviour
- Budget:
time_budget_ms, 0 = unlimited; checked between files. - Abort: an optional external
Arc<AtomicBool>that "overrides the picker's internal cancellation flag". - Pagination:
file_offsetin,next_file_offsetout — a file-granular cursor,0meaning "no more".page_limitdefaults to 50 and is soft: the file that fills the page completes, andfiles_consumedis tightened to it so the next page resumes correctly. - Partial results are the normal case, and
GrepResultcarries the counters (total_files,filtered_file_count,total_files_searched,files_with_matches) a UI needs to render "332 / 2350" honestly.
There is no streaming: a call returns a page, and the caller asks for the next.
10. Measured evidence
The repository states two numbers relevant here, both as code comments rather than published benchmarks: the per-line UTF-8 validation at "~8% of fuzzy grep time", and definition classification at "~2% overhead on large repos". Both are [literature] by this catalog's labels — they are the authors' observations on unnamed hardware, not reproduced here. No cross-tool comparison appears in the tree, and none should be inferred from these.
The vendored searcher — what a line-oriented searcher must own
fff-grep/ is ripgrep's grep-searcher reduced to 1,024 lines across seven files, and its header states the deletion:
"Simplified grep-searcher for fff.nvim. Provides line-oriented search over byte slices with optional multi-line support. Only
search_sliceis supported -- no file/reader/mmap search." —fff-grep/src/lib.rs
What survived:
Matcher— a trait with one required method,find_at(haystack, at), plus a defaultedfindand aline_terminator()that returnsNonefor a matcher that can cross lines.lines.rs(232 lines) —LineStep/LineIter, the line-boundary walk.searcher/—SliceByLineandMultiLinestrategies over aConfigof exactly three fields:line_term,line_number,multi_line.sink.rs—Sink,SinkMatch,SinkFinish.
What was deleted: reader and mmap search, binary detection, transcoding, passthru, inverted match, before/after context (fff re-implements context in its own SinkState), and most of ConfigError.
That deletion list is close to a scope statement for a @nogc D implementation, and it is evidence rather than opinion: someone needed exactly this subset for an interactive picker and shipped it.
Strengths
- One dispatch point for three modes, marked as such in a comment — the structure
PKS2should copy. - Degradation is visible. Every fallback sets a field the UI can render; the regex path returns the compiler's own error string.
- Prefilters are layered by cost and the cheapest one runs before any machinery is constructed.
- The chunk-growth policy reasons about barriers versus over-scan explicitly, with the failure mode of each extreme written down.
- Thresholds carry their motivating example. Every constant in the fuzzy quality filter names the case it prevents —
ff_flv_encode_picture_headerfor the density floor,flvencodeXfor the typo cap. - The result model is complete: absolute byte offset for seeking, byte columns, span list, all computed once at match time.
Weaknesses
- Mode selection lives outside the engine, in more than one caller, and the callers disagree about the ladder.
- The budget rule differs between paths — plain/regex refuses to abort before two matches exist, fuzzy does not — with no comment acknowledging it.
- Regex mode loses the whole-file prefilter entirely, because no literal is extracted from the compiled pattern. This is the single largest structural gap against ripgrep.
- The classifier is a POC by its own admission: language-agnostic, no check that the match lands in the introduced identifier, feature-gated, and never on the interactive path.
- Binary status is walk-time and cached, so a file that became binary since the walk is still read.
- No ranking at all — scan order, with a score only in fuzzy mode.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| One sink-selection point for all modes | Mode pairing is a single readable decision | Fuzzy still returns early, so it is not actually in that switch |
Soft page_limit, finish the tipping file | No partial file in a page; the cursor stays coherent | A page can overshoot by up to max_matches_per_file |
| Abort polled every 8th file | Amortises the atomic over a per-line loop | Up to seven files of latency after cancellation |
| Budget ignored until ≥ 2 matches (plain/regex) | The user always sees something rather than an empty result | A pathological query with one match runs past its deadline; fuzzy does not do this |
| Chunk growth only when the prefilter is weak | Barriers are logarithmic on big sets; small chunks terminate cheaply on small ones | Two policies to reason about, tied to a candidates * 2 < total heuristic |
Whole-file memmem before the searcher | Skips allocation and line-splitting, not just matching | Unavailable in regex mode — the mode that needs it most |
.unicode(false) for regex | Code search rarely needs Unicode classes; much smaller automata | \w, \b and \p{…} mean ASCII, silently |
Validate UTF-8 once per file, then unchecked per line | Measured at ~8% of fuzzy grep time | An unsafe block, and a mixed-encoding file degrades to per-line checks |
| Truncate display lines to 512 bytes | Bounds the row model and the re-match cost | Match indices must be recomputed on the truncated line; a match past 512 bytes is lost |
| Classification computed at match time | Formatters never re-scan | Paid on every match even when unused, hence the feature gate and the "~2%" advice |
Sources
Read in full at 3a0ce85c54875563bc55888b6b9829b44aeca911[source-verified]:
fff-core/src/grep/mod.rs— the architecture statementfff-core/src/grep/grep.rs— dispatch, fallback,NeedleFinder,perform_grepfff-core/src/grep/types.rs—GrepMode,GrepMatch,GrepSearchOptions,GrepResultfff-core/src/grep/fuzzy_grep.rs— typo budget, character prefilter, quality filtersfff-core/src/grep/regex.rs—has_regex_metacharacters,build_regexfff-core/src/grep/prefilter.rs— constraint prefilter and theFilePathretryfff-core/src/grep/sink.rs— truncation, context, char→byte offset mappingfff-core/src/grep/classify.rs— the definition/import heuristicfff-core/src/constants.rs—MAX_FFFILE_SIZEfff-grep/src/lib.rs,matcher.rs,searcher/mod.rs— the vendored fork