Skip to content

nucleo (Rust)

fzf's algorithm, corrected and library-shaped: the two-matrix optimal DP with fzf's constants (one deliberate retune), the sharpest prefilter in the scalar field, and — arguably more valuable than the matcher — the concurrent picker harness (Injector / tick() / incremental reparse) that Helix consumes.

LanguageRust (two crates: nucleo-matcher 0.3.1, nucleo 0.5.0)
LicenseMPL-2.0
Repositoryhelix-editor/nucleo
Surveyed revision8c16d47c (all file/line citations pin this commit)
CategoryMatcher library + concurrent picker harness
Algorithm classSmith-Waterman, no substitution, two matrices (optimal)
First release2023

Overview

What it solves

A matcher Helix can embed: same ranking family as fzf, but optimal under its own scoring function, allocation-free per match, grapheme-aware in its outputs, and wrapped in a harness that keeps a UI responsive while millions of items stream in and rematch across keystrokes.

Design philosophy

Correct the algorithm, keep the calibration. The README's "exact same scoring system as fzf" is not literally true, and the deviation is documented in the source — on why BONUS_CAMEL123 had to change after removing fzf's consecutive-chunk bonus:

Their value should be BONUS_BOUNDARY - PENALTY_GAP_EXTENSION = 7. However, this priporitzes camel case over non-camel case. … we don't do that (because its incorrect) so to avoids prioritizing camel we use a lower bonus. … This also has the nice sideeffect of perfectly balancing out camel case, snake case and the consecutive version of the word

Also documented as intent: nucleo is "a substring matching tool … with no penalty assigned to matches that start later" — no leading-gap penalty and no candidate-length penalty in the score; length is a sort tie-breaker in the worker, never a score term.

Algorithm & scoring model

Constants are u16, unsigned — penalties are magnitudes applied with saturating_sub, giving Smith-Waterman's floor-at-zero for free:

rust
pub(crate) const SCORE_MATCH: u16 = 16;
pub(crate) const PENALTY_GAP_START: u16 = 3;
pub(crate) const PENALTY_GAP_EXTENSION: u16 = 1;
pub(crate) const BONUS_BOUNDARY: u16 = SCORE_MATCH / 2;                     // 8
pub(crate) const BONUS_CAMEL123: u16 = BONUS_BOUNDARY - PENALTY_GAP_START; // 5  ← diverges from fzf's 7
pub(crate) const BONUS_NON_WORD: u16 = BONUS_BOUNDARY;                      // 8
pub(crate) const BONUS_CONSECUTIVE: u16 = PENALTY_GAP_START + PENALTY_GAP_EXTENSION; // 4
pub(crate) const BONUS_FIRST_CHAR_MULTIPLIER: u16 = 2;
pub(crate) const PREFIX_BONUS_SCALE: u16 = 2;
pub(crate) const MAX_PREFIX_BONUS: u16 = BONUS_BOUNDARY;

(bonus_boundary_white = 10 and bonus_boundary_delimiter = 9 are Config fields.) What it actually fixed over fzf:

  1. Two matrices instead of one. fzf's single-matrix affine approximation returns matches non-optimal under fzf's own function — the README's reproducible example: matching foo against xf foo, nucleo picks x__foo, fzf picks xf_oo, at any word length.
  2. Space. fzf allocates the full m×n matrix even when only ranking; nucleo keeps one score row when indices aren't requested.
  3. Matrix width n − m + 1, not n — "the p char requires p-1 chars before it and m-p chars after it, so there are always m+1 chars that can never match the current char."

Two smaller divergences a port should know: bonus_for returns 0 for a trailing Delimiter where fzf returns bonusNonWord; and the opt-in prefer_prefix decays at different rates on the DP path (PENALTY_GAP_EXTENSION) vs the greedy/exact path (PENALTY_GAP_START) — an inconsistency in the surveyed release.

Dispatch is budget-triggered, not length-triggered. fuzzy_match_optimal always tries the DP; greedy fires only when the slab refuses the allocation:

rust
let Some(mut matrix) = self.slab.alloc(&haystack[start..end], needle.len()) else {
    return self.fuzzy_match_greedy_::<INDICES, H, N>(haystack, needle, start, greedy_end, indices);
};
// const MAX_MATRIX_SIZE: usize = 100 * 1024;   (fzf's slab16Size, verbatim)
// const MAX_HAYSTACK_LEN / MAX_NEEDLE_LEN: usize = 2048;

So the honest complexity claim is O(mn) bounded by a hard ceiling of 102,400 cell updates per candidate (on the prefilter-trimmed region), else O(n) greedy, plus an O(n) prefilter. Short-circuits before the DP: needle longer than haystack → None; equal lengths → exact_match; ASCII haystack with Unicode needle → None; single-char needle → best-bonus occurrence scan; and if the prefiltered region is exactly needle-length the match is contiguous — straight to calculate_score, no matrix.

Prefiltering

The ASCII path is a complete subsequence test returning a three-way window:

rust
fn find_ascii_ignore_case(c: u8, haystack: &[u8]) -> Option<usize> {
    if c >= b'a' && c <= b'z' { memchr2(c, c - 32, haystack) } else { memchr(c, haystack) }
}

start = first occurrence of needle[0] within haystack[..len - needle.len() + 1]; greedy_end = end of the leftmost greedy match; end = last occurrence of the final needle char via memrchr. [start, end) is the DP window. The needle is pre-lowercased at parse time, so memchr2(c, c-32) gets case-insensitivity in one SIMD-backed pass with no haystack folding. The Unicode prefilter is scalar and checks only first/last needle chars — not exact, hence the ASCII path asserts a non-match "should have been caught by the prefilter". Exact atoms use memmem::find_iter, picking the highest-bonus occurrence with an early exit at bonus >= bonus_boundary_white.

Memory strategy

One eager alloc_zeroed per Matcher — 133,120 bytes, reused forever:

rust
struct MatcherData {
    haystack: [char; MAX_HAYSTACK_LEN],            //   8 KiB
    bonus: [u8; MAX_HAYSTACK_LEN],                 //   2 KiB
    row_offs: [u16; MAX_NEEDLE_LEN],               //   4 KiB
    scratch_space: [ScoreCell; MAX_HAYSTACK_LEN],  //  16 KiB
    matrix: [u8; MAX_MATRIX_SIZE],                 // 100 KiB
}

Nothing allocated, freed, or zeroed per match. Cell types worth stealing:

rust
#[repr(align(8))]
pub(crate) struct ScoreCell { pub score: u16, pub consecutive_bonus: u8, pub matched: bool }
#[repr(transparent)]
pub struct MatrixCell(pub(crate) u8);   // 2 bits used — the backtrack matrix

The backtracking matrix is 2 bits per cell, written only when the INDICES const-generic is true; per-row trimming (row_offs[i] = first index where needle char i can match) composes with the n−m+1 width.

WARNING

One real bug not to inherit: fieds_from_ptr sizes the matrix slice with * self.haystack_len where the layout reserved * self.needle_len, constructing a &mut [MatrixCell] far larger than the allocation. Only the used prefix is indexed so it doesn't misbehave, but forming the reference is already UB. Size by needle_len.

SIMD & parallelism

No SIMD in the DP — the prefilter's memchr/memmem are the vectorized parts. Parallelism lives in the nucleo crate: a rayon pool with one 133 KiB Matcher per worker thread in UnsafeCell indexed by rayon::current_thread_index() (thread-locality without TLS), and par_sortsort_unstable's pdqsort copied and parallelized with rayon_core::join plus cancellation. On why there is no fzf-style chunked lazy merge: sorting is ≤5 % of match time, and "I think fzf does it because go doesn't have a good parallel sort."

Unicode & case handling

The strongest part of the design:

rust
pub enum Utf32Str<'a> { Ascii(&'a [u8]), Unicode(&'a [char]) }

Strings are pre-segmented once at injection, amortized over the many re-matches a live picker performs. The ASCII discriminant is string.is_ascii() && !contains("\r\n") — because \r\n is a single grapheme. It matches graphemes by proxy: length equals grapheme count, each element is the first codepoint of its grapheme, the rest discarded (Utf32String::from("u\u{0308}").to_string() == "u" — not round-trip). The payoff: emitted match indices are directly usable as grapheme indices for highlighting — the README frames this as the concrete advantage over fzf/skim, where "multi codepoint graphemes can have weird effects (match multiple times, weirdly change the score)." Case folding is a generated simple-fold table (Unicode 15.0.0, ~347 pairs, binary-searched; ASCII folds arithmetically); normalization is fzf-style Latin→ASCII transliteration, but as three dense directly-indexed arrays rather than a hash map.

Incremental & streaming architecture

The nucleo crate is the part a picker actually needs:

  • boxcar::Vec<T> — lock-free append-only vector whose elements never move: 27 fixed-size buckets of length 1 << (i + SKIP_BUCKET), index → location is pure bit math, growth CAS-installs a new bucket. Indices are handed out by fetch_add before the slot is filled, so get() returns Option and the worker keeps an in_flight retry list. Each entry carries the item's match columns inline in a variable-length tail.

  • Injector<T>Clone + Send; push is documented lock-free and wait-free; extend reserves N indices in one fetch_add.

  • Incremental reparse — the key optimization, a three-state lattice Status: Unchanged < Update < Rescore:

    rust
    if append && old_status != Status::Rescore
        && self.cols[column].0.atoms.last().map_or(true, |last| !last.negative)
    { self.cols[column].1 = Status::Update; } else { self.cols[column].1 = Status::Rescore; }

    Update = the caller promised the old query is a prefix of the new one, so the match set can only shrink — rescore survivors in place, never revisit rejected items. A trailing negative atom disqualifies this (appending to !foo can grow the set). Non-matches are marked idx = u32::MAX, sorted to the end, dropped with one truncate.

  • tick(timeout) — the whole UI contract: non-blocking beyond timeout (10 ms recommended, once per frame), try_lock_arc_for on the normal path so a busy worker never stalls the frame, returning Status { changed, running }.

Pattern syntax is fully fzf-compatible (' ^ $ ! \ escapes, AtomKind::{Fuzzy, Substring, Prefix, Postfix, Exact} + negative), with one deliberate omission: no negated fuzzy ("too many false positives") — !foo is forced to Substring, matching fzf. CaseMatching and Normalization resolve per atom at parse time (needle pre-folded).

Strengths

  • Optimal DP with fzf's field-tested calibration (one justified retune).
  • The best prefilter in the scalar field; the memchr2(c, c−32) trick.
  • True zero-allocation steady state; 2-bit backtrack cells; single-row score-only mode.
  • Grapheme-indexed output — match positions are directly usable by a cell-grid renderer.
  • The harness contract (Injector + tick + Update/Rescore) is the survey's cleanest producer/matcher/UI seam (see helix-integration).

Weaknesses

  • UTF-32 transcode of every non-ASCII candidate at injection — the architecture bill frizbee's byte-wise matching avoids (its 20–46× Unicode wins are mostly this difference).
  • No typo tolerance (no substitution transition in the DP).
  • The matrix-slice sizing bug above (latent UB).
  • prefer_prefix decay inconsistency between paths.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Two-matrix DP, fzf constants, camel 7→5Optimality + preserve calibration balanceDiverges from fzf's exact ranking in camel-adjacent cases
Slab-refusal (not length) greedy fallbackHard 102,400-cell budget per candidatePathological inputs silently ranked by a weaker algorithm
Pre-segmented Utf32Str at injectionAmortized over many rematches; grapheme-stable indicesLossy (first codepoint per grapheme); transcode cost
No leading-gap / length penalty in score"Substring matching tool" semanticsNeeds a sort tie-breaker to prefer shorter candidates
Update vs Rescore on appendInteractive typing is the common caseCorrectness depends on the trailing-negative-atom guard
Per-worker Matcher via thread indexThread-locality without TLSUnsafeCell discipline; workers pinned to pool size

Sources

  • nucleo repository at 8c16d47cmatcher/src/{lib,score,matrix,prefilter,exact,config}.rs, src/{lib,worker,boxcar,par_sort}.rs, src/pattern.rs (constants, quotes, and mechanics above; verbatim file copies cached during this survey).
  • README performance discussion + fzf discussion #3491 — both headline nucleo-vs-fzf claims were confounded (whole-TUI measurements); see the comparison for the numbers this survey trusts instead.