1
0
Fork
You've already forked sift-matcher
0
A fast fuzzy matching library for Zig that supports fzf's extended search syntax.
  • Zig 95.4%
  • Python 3.4%
  • Rust 1.2%
Mikael Säker a608108fb0 rename package: zsift-matcher -> sift-matcher (drop the z)
Package name, fingerprint, module name (sift_matcher), and self-imports renamed. Dir moved ../zsift-matcher -> ../sift-matcher.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026年06月27日 10:28:54 +02:00
bench rename package: zsift-matcher -> sift-matcher (drop the z) 2026年06月27日 10:28:54 +02:00
src rename package: zsift-matcher -> sift-matcher (drop the z) 2026年06月27日 10:28:54 +02:00
tools Add Unicode support with full case folding 2026年02月16日 18:13:38 +01:00
.gitignore Migrate to Zig 0.16 2026年04月29日 22:47:50 +02:00
build.zig rename package: zsift-matcher -> sift-matcher (drop the z) 2026年06月27日 10:28:54 +02:00
build.zig.zon rename package: zsift-matcher -> sift-matcher (drop the z) 2026年06月27日 10:28:54 +02:00
CLAUDE.md Initial implementation: scalar fuzzy matching library 2026年02月16日 11:55:43 +01:00
context.md Migrate to Zig 0.16 2026年04月29日 22:47:50 +02:00
DESIGN.md Migrate to Zig 0.16 2026年04月29日 22:47:50 +02:00
LICENSE Add feature parity with nucleo/fzf: pattern parser, smart case, normalization, OR groups, boundary-exact 2026年02月17日 11:40:02 +01:00
README.md docs: update README for the zmatch -> zsift-matcher rename 2026年06月21日 10:51:54 +02:00

zsift-matcher

Fast fuzzy matching library for Zig, inspired by nucleo (Rust). Built for editors, CLI tools, and anything that needs to rank search results by relevance.

Uses Smith-Waterman dynamic programming with affine gap penalties for optimal scoring, with a SIMD-accelerated batch path that processes 8 haystacks simultaneously on ARM NEON (16 on AVX2).

Naming: this library was previously called zmatch. It's the matcher half of a nucleo-style split: zsift-matcher (this repo) is the pure, thread-free SIMD matching layer, and zsift is the async, thread-pooled engine on top of it (item store + worker pool + query state). Use this crate directly when you want to score haystacks yourself; use zsift when you want a streaming, non-blocking match engine for a large/incremental list.

Features

  • Optimal scoring -- Smith-Waterman DP with affine gaps, camelCase/boundary/consecutive bonuses
  • SIMD batch matching -- 2-2.4x faster than scalar for bulk workloads (score-identical)
  • Extended search syntax -- fzf-compatible pattern parsing with AND/OR, negation, prefix/suffix/exact/boundary modes
  • Unicode support -- full case folding (~1484 entries), accent normalization (816 entries), smart case/normalization
  • Top-K selection -- min-heap based O(N log K) without materializing all scores
  • Zero allocations during matching -- all scratch memory pre-allocated
  • Match indices -- get the positions of matched characters for highlighting
  • Greedy fallback -- O(n) path for oversized haystacks
  • Configurable -- custom delimiters, bonus weights, path matching presets, prefix preference

Search syntax

zsift-matcher supports fzf's extended search syntax via the Pattern type. Multiple terms are separated by spaces and combined with AND logic. A standalone | between terms creates OR groups.

Token Match type Description
sbtrkt fuzzy-match Items that fuzzy-match sbtrkt
'wild exact-match Items that include wild
'wild' boundary-exact-match Items that include wild at word boundaries
^music prefix-exact-match Items that start with music
.mp3$ suffix-exact-match Items that end with .mp3
!fire inverse-exact-match Items that do not include fire
!^music inverse-prefix-exact-match Items that do not start with music
!.mp3$ inverse-suffix-exact-match Items that do not end with .mp3

A \ before a special character escapes it: \^foo matches ^foo literally, foo\ bar matches foo bar as one term.

OR groups: go$ | rb$ | py$ matches items ending with .go, .rb, or .py. Combined with AND: ^core go$ | rb$ | py$ matches items starting with core that end with any of the three.

constzsm=@import("zsift_matcher");varmatcher=tryzsm.Matcher.initDefault(allocator);defermatcher.deinit();constpattern=zsm.Pattern.parse("^src/ 'config !.bak$");if(pattern.match(&matcher,"src/config.zig"))|score|{// matches: starts with "src/", contains "config", doesn't end with ".bak"}

Usage

As a Zig package

// build.zig.zon dependencies:.zsift_matcher=.{.url="https://codeberg.org/sicher/zsift-matcher/archive/main.tar.gz",.hash="...",// zig build --fetch will fill this in},
// build.zigconstzsm_mod=b.dependency("zsift_matcher",.{}).module("zsift_matcher");exe.root_module.addImport("zsift_matcher",zsm_mod);

The module is imported as zsift_matcher; the examples below alias it to zsm for brevity.

Single match

constzsm=@import("zsift_matcher");varmatcher=tryzsm.Matcher.initDefault(allocator);defermatcher.deinit();// Score-only (returns ?u16 -- null means no match, higher is better)if(matcher.match("src/fuzzy_optimal.zig","fopt",.fuzzy))|score|{std.debug.print("score: {d}\n",.{score});}// With match positions (byte indices for highlighting)varindices:[128]u32=undefined;if(matcher.matchIndices("FooBarBaz","fbz",.fuzzy,&indices))|score|{// indices[0..3] = { 0, 3, 6 }std.debug.print("score: {d}, positions: {any}\n",.{score,indices[0..3]});}

Batch matching (SIMD)

constzsm=@import("zsift_matcher");varbatch=tryzsm.BatchMatcher.initDefault(allocator);deferbatch.deinit();consthaystacks=[_][]constu8{"foo","foobar","baz","football"};varresults:[haystacks.len]?u16=undefined;batch.matchAll(&haystacks,"foo",.fuzzy,&results);// results = { score, score, null, score }

BatchMatcher uses SIMD internally for throughput but produces identical scores to the scalar Matcher. Use it when scoring many haystacks against the same needle (e.g. filtering a file list as the user types). zsift_matcher.BatchMatcher is DefaultBatchMatcher -- the lane count is chosen for the host SIMD width.

Top-K selection

varbuf:[10]zsm.TopKResult=undefined;consttop=batch.matchTopK(&haystacks,"foo",.fuzzy,&buf);// top = sorted slice of best matches (score descending)for(top)|r|{std.debug.print("index={d} score={d}\n",.{r.index,r.score});}

Match modes

The MatchMode parameter controls how the needle is matched against haystacks:

Mode Description
.fuzzy Subsequence match with Smith-Waterman scoring (default for interactive search)
.exact Substring match (case-aware)
.prefix Haystack must start with needle
.suffix Haystack must end with needle
.equal Full string equality (case-aware)
.boundary_exact Substring at word boundaries (non-alphanumeric or string edge)

Each mode has an inverse_ variant (e.g. .inverse_exact) that inverts the match -- returns a score when the haystack does not match.

You typically don't need to use match modes directly -- the Pattern parser (see Search syntax above) maps fzf syntax to the appropriate mode automatically.

Configuration

// Smart case (default): case-insensitive unless needle has uppercasevarmatcher=tryzsm.Matcher.init(allocator,.{.case_matching=.respect_case,// or .smart, .ignore_case.normalization=.never,// or .smart (default), .always.prefer_prefix=true,// bonus for matches near string start});// Path matching presetvarpath_matcher=tryzsm.Matcher.init(allocator,zsm.Config.pathMatchingUnix());

Limits

Limit Value Behavior when exceeded
Max haystack length 2048 bytes Returns null (no match) for optimal DP; greedy fallback handles longer strings
Max needle length 128 bytes Returns null (no match)
Max pattern atoms 16 Excess terms silently ignored

These limits are generous for interactive use (file pickers, command palettes, etc.). The greedy fallback produces reasonable scores for haystacks beyond 2048 bytes but without guaranteed optimal alignment.

Thread safety

Each Matcher and BatchMatcher instance owns pre-allocated scratch buffers and is not thread-safe. Create one instance per thread. Instances share no mutable state, so multiple threads can match independently without synchronization. (This is exactly how zsift drives a worker pool: one BatchMatcher per worker thread.)

Building

Requires Zig 0.16.0 or later.

zig build # build library + demo
zig build test # run all tests (369)
zig build run # run the demo

Benchmarks

zig build bench # run with hardcoded corpus (501 haystacks)
zig build bench-setup # download large corpus (~7400 markdown files)
zig build bench # re-run with both corpora (~1M haystacks)

Kubernetes corpus (Apple M-series, ~1M haystacks from markdown files)

Needle scalar SIMD nucleo fzf
"zsift" 6.2M/s 6.3M/s 2.6M/s 2.3M/s
"webpack" 5.2M/s 5.6M/s 2.5M/s 2.3M/s
"config" 3.4M/s 4.1M/s 2.1M/s 1.9M/s
"install" 2.7M/s 3.5M/s 1.9M/s 1.7M/s
"the" 2.5M/s 3.6M/s 1.7M/s 1.3M/s
"st" 2.4M/s 3.1M/s 1.7M/s 1.0M/s

The scalar path is 1.4-2.4x faster than nucleo; SIMD is 1.8-2.4x faster. SIMD produces bit-identical scores to scalar.

Linux kernel file paths (84,550 paths -- nucleo's benchmark)

Needle zsift-matcher nucleo Ratio
"never_matches" 40.0M/s 37.0M/s 1.08x
"copying" 35.7M/s 34.5M/s 1.04x
"/doc/kernel" 31.3M/s 30.3M/s 1.03x
"//.h" 8.9M/s 11.1M/s 0.80x

Match sets and scores verified identical to nucleo on all needles.

Algorithm

The scoring algorithm follows nucleo's Smith-Waterman variant:

  • Affine gap penalties: opening a gap costs more than extending one, so consecutive matches are preferred
  • Character class bonuses: boundary transitions (e.g. /f, _b, cC) get bonus points, rewarding matches at word boundaries and camelCase humps
  • Consecutive bonus: sustained runs of matches accumulate increasing bonuses
  • Reduced-width matrix: only the feasible region is computed (width = h_len - n_len + 1)

The SIMD path interleaves L haystacks into a Structure-of-Arrays layout and runs the same DP using @Vector(L, u16) operations -- branch-free via @select, with LUT-based bonus precomputation.

License

MIT