ffuzzy 0.6.2
ffuzzy: ^0.6.2 copied to clipboard
Fast fuzzy search for Flutter, powered by a compact C engine via dart:ffi: fuzzy/substring/prefix/postfix/exact matching over a resident corpus, multi-threading, async filtering, hit highlighting, and [...]
ffuzzy #
English | 中文
Fast fuzzy search for Flutter, powered by a compact C engine.
ffuzzy is a byte-for-byte reimplementation of nucleo
(the matcher behind the Helix editor) in portable C. No Rust toolchain, no
codegen — the engine is a few source files that every platform's SDK compiles
on its own. The native library is ~32 KB stripped.
- Fast — meets or beats the Rust
nucleoengine: faster in every multi-threaded configuration and onsubstringacross the board, at parity on CJK and single-threadedfuzzy. ~100k-item corpus filters in ~1.4 ms. - Tiny — ~32 KB native
.so(arm64), pure C, zero third-party deps. - All platforms — Android, iOS, macOS, Linux, Windows and Web (WASM).
- Search any object —
FuzzyCorpus<T>searches aList<T>; hits carry the original object (hit.raw). - Match modes as methods —
fuzzy(fzf-style, with! ^ ' $operators),substring,prefix,postfix,exact, plus unifiedsearch()withSearchStrategy(fuzzy / approx / fallback / merge) anddual(). - Edit-distance search —
approx()uses Myers bit-parallel Levenshtein; tolerates typos, substitutions and transpositions (opt-in build flag). - Multi-threaded and async scans for large corpora without UI jank.
- Hit highlighting with correct Unicode (codepoint → UTF-16) offsets.
- Unicode / CJK — diacritic + full simple case folding; CJK matched directly.
- Multi-key search — attach host-computed pinyin / romaji / initials so a CJK item is findable by typing latin.
Install #
dependencies:
ffuzzy: ^0.6.2
environment:
sdk: ^3.6.0
flutter: ">=3.24.0"
No native platform setup required — the C sources are compiled and bundled automatically by each platform's SDK on
flutter build.
Web support #
On web, ffuzzy uses a WASM build of the same C engine. Call ffuzzyInit once
at app startup — it's a no-op on native, so it's safe to call unconditionally:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load the WASM engine from the published npm package:
await ffuzzyInit(
webUrl: 'https://cdn.jsdelivr.net/npm/@codejoo/ffuzzy@0.8.0/dist/ffuzzy.mjs',
);
// Or self-host: await ffuzzyInit(webAssetsUrl: '/assets/ffuzzy.mjs');
runApp(const MyApp());
}
After ffuzzyInit returns, the full FuzzyCorpus API works identically on all
platforms.
Web note — WASM runs synchronously on the main thread. For large corpora,
asyncFuzzyyields to the event loop via a microtask but the WASM computation still runs on the main thread (no Web Worker). Keep corpora under ~50k items or usefuzzysynchronously withlimitto stay within a frame budget.
Lazy init (corpus created before ffuzzyInit): If a FuzzyCorpus is
constructed before ffuzzyInit completes, it operates in deferred mode —
sync search methods return [] immediately, while async* methods (asyncFuzzy,
asyncSearch, asyncApprox, asyncDual, etc.) automatically await WASM
initialisation before executing and return real results. All strategies —
fuzzy, approx, fallback, merge, and dual — behave consistently in
both sync and async variants once WASM is ready.
Parameters:
webUrl— CDN or self-hosted URL toffuzzy.mjs. No Flutter asset needed.webAssetsUrl— local Flutter asset path (e.g./assets/ffuzzy.mjs). Requires declaring the file inpubspec.yamlunderassets:. Offline-capable.- Both can be provided;
webAssetsUrltakes priority.
Quick start #
import 'package:ffuzzy/ffuzzy.dart';
// Plain strings:
final corpus = FuzzyCorpus.strings(['src/main.dart', 'lib/widget.dart', '中文搜索']);
for (final h in corpus.fuzzy('srcmn', parallel: true, limit: 50)) {
print('${h.raw} score=${h.score}'); // h.raw is the matched String
}
corpus.dispose(); // or let the NativeFinalizer reclaim it
// Any object — give a `stringOf` extractor; hits carry the object:
final files = FuzzyCorpus<File>(myFiles, stringOf: (f) => f.path);
final hit = files.prefix('lib/').firstOrNull; // hit.raw is a File
A
FuzzyCorpusowns native memory and must be used only on the isolate that created it. The mode methods are synchronous on the calling isolate — for a large corpus use theasync*twins (e.g.asyncFuzzy) or run the corpus on a background isolate so searching doesn't jank the UI.
Use cases #
Type-as-you-go search over file paths, command palettes, contact/song lists, log lines, or any in-memory list where you want fzf-quality ranking at native speed — especially large lists (tens of thousands of items) and CJK content.
API #
Everything is exported from package:ffuzzy/ffuzzy.dart.
ffuzzyInit #
Future<void> ffuzzyInit({String? webUrl, String? webAssetsUrl})
Initialize the WASM engine on web. No-op on native. Call once before
constructing any FuzzyCorpus. Idempotent.
FuzzyCorpus<T> #
A resident corpus of T items you build once and search many times.
Constructors #
FuzzyCorpus<T>(
Iterable<T> items, {
required String Function(T) stringOf, // searchable text for each item
FuzzyOptions options = const FuzzyOptions(),
bool matchPaths = false, // tune delimiters for path-like text
bool preferPrefix = false, // bias scoring toward matches near the start
String? libraryPath, // load a specific native lib (tests / non-bundled)
})
static FuzzyCorpus<String> FuzzyCorpus.strings(Iterable<String> items, {…})
static FuzzyCorpus<Map<String,dynamic>> FuzzyCorpus.byKey(items, String field, {…})
static FuzzyCorpus<Map<String,dynamic>> FuzzyCorpus.byKeys(items, List<String> fields, {…})
static Future<FuzzyCorpus<T>> FuzzyCorpus.buildAsync<T>(items, {required stringOf, …})
byKey— search aList<Map>by one field;hit.rawis the whole map.byKeys— search across multiple fields;hit.matchedKeyis the index intofieldsthat produced the hit.buildAsync— builds the corpus on a background isolate (no UI jank for large datasets). On web, falls back to a microtask yield.
Building & mutating #
| Member | Description |
|---|---|
void add(T item) |
Append one item. |
void addAll(Iterable<T> items) |
Append many. |
Future<void> addAllAsync(Iterable<T> items) |
Append many on a background isolate (native) or microtask (web). Exclusive while running. |
void addKey(T item, List<FuzzyKey> keys) |
Append item with alternate search keys. |
void update(int index, T item) |
Replace item at index (drops alternate keys). |
void removeAt(int index) |
Remove item at index. |
int removeWhere(bool Function(T) test) |
Remove matching items; returns count removed. |
void refresh([Iterable<T>? source]) |
Re-add current items, or replace entire dataset. |
void clear() |
Remove all items; corpus stays usable. |
int get length |
Number of items in the corpus. |
The native corpus is append-only, so
update/removeAt/removeWhere/refreshrebuild it in O(n) — cheap for occasional edits; batch heavy churn.
Search modes #
Classic modes
Each mode returns List<FuzzyHit<T>>:
List<FuzzyHit<T>> fuzzy(String q, {…overrides});
List<FuzzyHit<T>> substring(String q, {…overrides});
List<FuzzyHit<T>> prefix(String q, {…overrides});
List<FuzzyHit<T>> postfix(String q, {…overrides}); // suffix() is an alias
List<FuzzyHit<T>> exact(String q, {…overrides});
Each has *Raws, async*, and async*Raws variants:
corpus.asyncFuzzy(q) // Future<List<FuzzyHit<T>>> — Isolate.run on native
corpus.fuzzyRaws(q) // List<T> — skips FuzzyHit wrapper, faster
corpus.asyncFuzzyRaws(q) // Future<List<T>>
fuzzyparses the query into space-separated terms and fzf-style operators (!negate,^prefix,'substring,$suffix). Other modes treat the whole query as one literal atom.- Overrides (
{FuzzyCase? caseMatching, FuzzyNorm? normalization, bool? parallel, int? threads, int? limit, bool? highlight, FuzzyScoring? scoring}): each non-null argument overrides the corresponding field of the corpus'sFuzzyOptionsfor that call only.
Unified entry point: search()
List<FuzzyHit<T>> search(String q, {
SearchStrategy strategy = SearchStrategy.fuzzy,
int? maxDistance, // for approx/fallback/merge; auto-scaled when null
FuzzyCase? caseMatching,
FuzzyNorm? normalization,
bool? parallel, int? threads, int? limit, bool? highlight, FuzzyScoring? scoring,
})
strategy |
Behaviour |
|---|---|
SearchStrategy.fuzzy |
fzf subsequence (default; same as fuzzy()) |
SearchStrategy.approx |
Edit-distance Levenshtein (same as approx()) |
SearchStrategy.fallback |
Subsequence first; if empty, fall back to edit-distance |
SearchStrategy.merge |
Both algorithms; subsequence hits first, then edit-only hits |
search() has searchRaws(), asyncSearch(), asyncSearchRaws() variants.
Edit-distance shorthand: approx()
List<FuzzyHit<T>> approx(String q, {int? maxDistance, …})
maxDistance auto-scales by query length when omitted (≤2 chars → 0; 3–5 → 1; 6+ → 2).
Also: approxRaws(), asyncApprox(), asyncApproxRaws().
Dual result: dual()
Runs both algorithms independently in a single corpus scan and returns separate buckets:
FuzzyDualResult<T> result = corpus.dual('iphoen');
result.fuzzy // List<FuzzyHit<T>> — subsequence hits
result.approx // List<FuzzyHit<T>> — edit-distance hits
Also: asyncDual().
async* calls (asyncFuzzy, asyncSearch, asyncApprox, asyncDual, …)
may overlap safely. On web they also serve as the deferred-init path — a
call made before ffuzzyInit completes will await WASM before returning
results. Mutations while a search is in flight throw StateError.
Lifecycle #
| Member | Description |
|---|---|
void dispose() |
Idempotent; waits for in-flight async work to complete. Safe to call from State.dispose(). |
Future<void> asyncDispose() |
Like dispose but awaitable. |
@override
void dispose() {
unawaited(_corpus.asyncDispose());
super.dispose();
}
FuzzyOptions #
| Field | Type | Default | Meaning |
|---|---|---|---|
caseMatching |
FuzzyCase |
smart |
Case handling |
normalization |
FuzzyNorm |
smart |
Diacritic normalization |
parallel |
bool |
false |
Multi-threaded scoring (native only) |
threads |
int |
0 |
0 = auto (half CPUs, capped at 8) |
limit |
int |
0 |
Max hits (0 = all) |
highlight |
bool |
false |
true populates FuzzyHit.indices for highlighting |
scoring |
FuzzyScoring |
fast |
fast (rolling DP), off (insertion order), nucleo (full-matrix DP) |
final corpus = FuzzyCorpus.strings(items,
options: const FuzzyOptions(parallel: true, limit: 50));
corpus.fuzzy('foo'); // uses parallel + limit 50
corpus.fuzzy('bar', limit: 10); // same defaults, but limit overridden
FuzzyHit<T> #
| Field | Type | Description |
|---|---|---|
raw |
T |
The original item that matched. |
index |
int |
Insertion order in the corpus. |
score |
int |
Match score (higher = better; only comparable within one query). |
matchedKind |
FuzzyKeyKind |
Which kind of key matched (original / pinyin / …). |
matchedKindCode |
int |
Raw kind code. Same as matchedKind.code for built-in kinds; preserves host-defined values (≥ 100) for keys added via addKey. |
matchedKey |
int |
Which key matched (0 = original; byKeys → index into fields). |
indices |
List<int> |
Matched codepoint positions. Only populated when highlight: true; empty otherwise. Convert with fuzzyCodepointToUtf16. |
Enums #
FuzzyCase #
| Value | Meaning |
|---|---|
respect |
Case-sensitive. |
ignore |
Case-insensitive. |
smart |
Case-insensitive unless the query has uppercase (default). |
FuzzyNorm #
| Value | Meaning |
|---|---|
never |
No diacritic folding. |
smart |
Fold diacritics unless the query uses them (default). |
FuzzyKeyKind #
| Value | .code |
Meaning |
|---|---|---|
original |
0 |
Item's own text (stringOf). |
pinyin |
1 |
Pinyin alternate key. |
initials |
2 |
Initials alternate key. |
romaji |
3 |
Romaji alternate key. |
custom |
100 |
Host-defined (any value ≥ 100). |
FuzzyKey #
FuzzyKey(String text, {int kind = 1}) // kind 1 = pinyin
FuzzyKey.kind(String text, FuzzyKeyKind kind) // recommended
Highlighting #
List<int> fuzzyCodepointToUtf16(String text, List<int> codepointIndices)
Pass highlight: true to populate FuzzyHit.indices. Dart strings are UTF-16 —
convert codepoint positions before building a TextSpan:
final hit = corpus.fuzzy('src', highlight: true).first;
final text = hit.raw as String;
final marks = fuzzyCodepointToUtf16(text, hit.indices).toSet();
final spans = [
for (var i = 0; i < text.length; i++)
TextSpan(text: text[i], style: marks.contains(i) ? boldStyle : null),
];
Multi-key / CJK transliteration #
Attach host-computed alternate keys so a CJK item is findable by typing latin:
corpus.addKey(zhangsan, [
FuzzyKey.kind('zhangsan', FuzzyKeyKind.pinyin),
FuzzyKey.kind('zs', FuzzyKeyKind.initials),
]);
final h = corpus.fuzzy('zs').first;
// h.matchedKind == FuzzyKeyKind.initials
For large datasets, build in a background isolate:
final corpus = await Isolate.run(() {
final c = FuzzyCorpus<Contact>(contacts, stringOf: (c) => c.name);
for (final contact in contacts) {
c.addKey(contact, [FuzzyKey(contact.pinyin, kind: FuzzyKeyKind.pinyin.code)]);
}
return c;
});
FuzzyCorpuscannot be sent across isolates — build inside the isolate and keep it there, or usebuildAsync.
Edit-distance search (typo-tolerant) #
approx() matches items whose best key is within maxDistance Levenshtein edits of the
query. Unlike fuzzy (subsequence), it tolerates substituted or extra characters.
// "iphoen" → finds "iPhone" (2 edits)
corpus.approx('iphoen') // maxDistance auto-scaled
corpus.approx('iphoen', maxDistance: 2) // explicit
corpus.search('iphoen', strategy: SearchStrategy.fallback) // seq first, then approx
corpus.search('iphoen', strategy: SearchStrategy.merge) // both, merged
corpus.dual('iphoen') // both, separate buckets
Results are sorted closest-first (score = −(distance+1) for edit-only hits;
seq hits keep their fzf score). FuzzyHit.indices is always empty for edit-distance hits.
Both algorithms (subsequence + edit-distance) ship together, always compiled
in — native and WASM builds no longer have separate variants to opt into
(prior versions gated edit-distance behind a FFZ_EDIT_DISTANCE build flag;
since 0.6.2 there is exactly one build).
Native (Android / iOS / macOS / Linux / Windows) #
Nothing to configure — the plugin's CMake build always compiles both engines.
Web (WASM) #
One prebuilt engine module, both algorithms included:
await ffuzzyInit(webUrl: 'https://cdn.jsdelivr.net/npm/@codejoo/ffuzzy@0.8.0/dist/ffuzzy.mjs');
Build your own:
# In wasm/:
bash build-engine.sh # → src/ffz.mjs (WASM inlined as base64, self-contained)
npm run build # → dist/ffuzzy.mjs
Errors #
- Recoverable: library/symbol load failure →
FuzzyException; misuse (use afterdispose, mutate during async search) →StateError. - Hard native faults: not catchable — see
FuzzyCrash.
FuzzyCrash (native only) #
Optional last-gasp handler for non-recoverable native faults. Prints a backtrace to stderr before exit and optionally writes it to a breadcrumb file.
final report = FuzzyCrash.lastReport();
if (report != null) log('ffuzzy last crash:\n$report');
FuzzyCrash.install(breadcrumbPath: '${dir.path}/ffuzzy_crash.log');
High-frequency & large-corpus search #
Latest query wins pattern for type-as-you-go:
int _gen = 0;
Future<void> onQueryChanged(String q) async {
final gen = ++_gen;
final hits = await corpus.asyncFuzzy(q, limit: 50);
if (gen != _gen) return; // superseded by newer keystroke
setState(() => _hits = hits);
}
Data races — multiple …Async searches may overlap safely (each gets its
own native matcher scratch). Mutations while a search is in flight throw
StateError; await or asyncDispose first.
Platforms #
| Platform | Engine | Async search |
|---|---|---|
| Android / iOS / macOS / Linux / Windows | C via dart:ffi |
Isolate.run (true background thread) |
| Web | C via WASM (dart:js_interop) |
Microtask yield (main thread) |
On native, the C sources are compiled and bundled per-platform (NDK / CMake / podspec). Consumers need no extra toolchain.
Performance #
Real-device (Flutter Windows, profile mode, 100k items):
| C (ffuzzy) | Rust (nucleo) | |
|---|---|---|
| Resident corpus memory | 15.25 MB | 16.54 MB |
| Filter (fuzzy, top-50) | 1.36 ms | 1.65 ms |
The full methodology, differential-test guarantee (6210/6210 byte-identical to
nucleo), Unicode coverage, and engine design live in
docs/INTERNALS.md.
npm / JavaScript #
The same C engine is published as @codejoo/ffuzzy
for browser and Node projects. Three package variants match the three WASM builds:
// Default (subsequence only)
import { ffuzzyInitialize, FuzzyCorpus } from '@codejoo/ffuzzy';
// Full (both algorithms)
import { ffuzzyInitialize, FuzzyCorpus } from '@codejoo/ffuzzy/full';
// Edit-distance only
import { ffuzzyInitialize, FuzzyCorpus } from '@codejoo/ffuzzy/approx';
await ffuzzyInitialize();
const corpus = FuzzyCorpus.strings(['src/main.rs', 'README.md']);
corpus.fuzzy('src', { highlight: true });
corpus.approx('srcc'); // edit-distance, maxDistance auto-scaled
corpus.search('src', { strategy: 'fallback' });
corpus.dual('src'); // { fuzzy: [...], approx: [...] }
corpus.dispose();
License #
MIT — see LICENSE.