- Zig 98.7%
- Python 1.3%
comb
A fast, ripgrep-like search library in Zig (0.16),
designed to be embedded in command-line tools, editors, and language servers.
The bundled cb CLI is a thin driver over the library.
The core is IO-agnostic: it searches []const u8 buffers and never touches
the filesystem itself, so a host (an editor, an async runtime, a sandbox) stays
in control of how bytes are read — including serving unsaved, in-memory editor
buffers. An optional filesystem adapter is provided for CLI/standalone use.
On the bundled benchmark (a Linux 7.1 source tree, ~1.5 GiB / 84k files), cb
beats or ties ripgrep's wall-clock on every benchmark pattern — literals,
alternations, character classes, word-bounded searches, and regexes. See
Performance below.
Features
-
Two match backends behind one
Matcher— fixed-string (literal) and a regex engine — dispatched through a tagged union (no vtable on the hot path). -
Linear-time regex (Thompson NFA / PikeVM) with a lazy DFA fast path. No catastrophic backtracking. Supported syntax:
- literals,
.,*+?(greedy and lazy*?+???) - character classes
[...]with ranges and negation - anchors
^$, alternation|, grouping(...) \d \w \s(and\D \W \S), word boundaries\b\B- counted repetition
{n}{n,}{n,m}{,m} - case-insensitive matching (
-i, Unicode-aware — see below) - Unicode (on by default):
.and[...]classes/ranges/negation match over codepoints ([α-ω],[^é]);\w\d\sand\buse Unicode properties; and-ifolds non-ASCII case (café↔CAFÉ).--no-unicodereverts to byte matching. - escapes
\n \t \rand\<metachar>
Semantics are leftmost-first (Perl-like greedy), matching ripgrep. Matching is line-oriented:
., negated classes, and\snever cross\n. - literals,
-
Grep-style line search — one result per matching line, line numbers counted in O(n); plus inverted (
-v) and context-line (-A/-B/-C) variants. -
Streaming engine — a worker pool searches sources as a producer discovers them (pipelined walk + search), with coalesced re-grep on pattern change.
-
ripgrep-compatible filtering (in the filesystem adapter) —
.gitignore,.ignore,.rgignore(correct precedence),.git/info/exclude, globalcore.excludesFile, require-git, parent-directory gitignores up to the repo root, hidden-file skipping, and binary-file detection. Exact file-set parity withrg's defaults on the benchmark tree. -
Performance — a layered strategy that picks the cheapest sufficient scanner per pattern shape and per buffer; see Performance.
The cb CLI
cb [flags] <pattern> [path ...]
Search for PATTERN (a regex; -F for a literal string) under each PATH
(default: the current directory, searched recursively, honoring .gitignore
and hidden-file rules like ripgrep). With no paths and piped input — or an
explicit - — it reads stdin.
On a terminal, output is grouped under a filename heading (then line:match
beneath, blank line between files); when piped it switches to the flat
file:line:match, so downstream tools see one self-describing line per match.
| flag | meaning |
|---|---|
-F, --fixed-strings |
treat the pattern as a literal string |
-i, --ignore-case |
case-insensitive match |
-w, --word-regexp |
match only whole words |
-n / -N |
force line numbers on / off (default: on when stdout is a TTY) |
-c, --count |
print only a count of matching lines per file |
-l, --files-with-matches |
print only paths that contain a match |
-o, --only-matching |
print only the matched parts, one per line |
-v, --invert-match |
select lines that do not match |
-A/-B/-C N |
show N lines after / before / around each match |
--color=WHEN |
colorize output: auto (default), always, never |
-H / --no-filename |
force the file path on / off (default: on when recursing) |
--heading / --no-heading |
group matches under a filename heading vs. a per-line path (default: heading on a TTY) |
--hidden |
search hidden files and directories |
--no-ignore |
don't honor .gitignore/.ignore/.rgignore |
--no-unicode |
treat . and \w/\d/\s as bytes, not codepoints |
-j, --threads N |
worker threads (default: the machine's performance cores) |
-h, --help |
print help |
Unreadable paths (permission denied, missing, ...) are reported to stderr as
cb: <path>: <message> and skipped, while the rest of the search continues.
Exit status is 0 if a match was found, 1 if not, 2 on error (including a
failed search with no matches).
$ cb -n 'fn \w+\(' src/ # regex, with line numbers
$ cb -ic todo # case-insensitive count of "todo" per file
$ cb -F 'a[0].b' # literal search (no regex)
$ cb -C2 panic src/ # 2 lines of context around each match
$ cb -v '^\s*#' config.ini # lines that aren't comments
$ cb -o '\w+@\w+\.\w+' . # print just the matches (emails)
$ git diff | cb '^\+' # search a pipe (stdin)
For stable, path-sorted output when piping, add a stable sort on the path
column (each file's lines already stream in order): cb foo | sort -t: -k1,1 -s.
Using the library
Add comb as a dependency in your build.zig.zon and import the comb module.
(comb itself depends on plumbuz for
gitignore parsing in the filesystem adapter.)
The simplest layer — run a matcher over a byte buffer and iterate matching lines:
conststd=@import("std");constcomb=@import("comb");pubfnsearch(alloc:std.mem.Allocator,bytes:[]constu8)!void{// Regex backend; init(alloc, pattern, case_insensitive, unicode).varmatcher:comb.Matcher=.{.regex=trycomb.RegexMatcher.init(alloc,"\\bTODO\\b",false,true),};defermatcher.deinit(alloc);varit=comb.lineIterator(&matcher,bytes);while(it.next())|m|{std.debug.print("{d}: {s}\n",.{m.line_number,m.line});}}The whole-tree, multi-threaded path uses a ContentProvider (the host's
resolver from a source id to bytes) and the streaming Engine:
producer pushes ids ──▶ Engine worker pool ──▶ published snapshot
(paths, buffer URIs) resolve id → bytes (read via tick / matches)
run Matcher per line
This keeps all IO in the host: an editor returns in-memory bytes for open
buffers (overriding disk for dirty files), while a CLI reads or mmaps files. See
src/source.zig (the searchSource leaf), src/engine/engine.zig (the pool),
and src/walk.zig (the optional filesystem walker + provider) for the full API.
Build & test
Requires Zig 0.16.0.
$ zig build # build the cb CLI (ReleaseFast) → zig-out/bin/cb
$ zig build run -- <pattern> [path] # build and run cb
$ zig build test # run the full test suite (Debug, with safety)
$ zig build bench -- [-F] [-j N] <pattern> <root> # benchmark harness
zig build builds the cb CLI in ReleaseFast by default (an end-user tool
should be fast out of the box); pass -Doptimize=ReleaseSmall for a smaller
binary, or -Doptimize=Debug for a debug build. Tests always run in Debug so
safety checks are active.
Performance
Measured against ripgrep 15.1 on a Linux 7.1 source tree (~1.5 GiB, 84k files
after ignore rules — identical file set for both tools), Apple M1 Pro / 32 GB,
warm filesystem cache, counting mode (-c), best of 3 runs:
| pattern | comb | ripgrep | ratio |
|---|---|---|---|
the (literal) |
1.46s | 1.86s | ×ばつ |
[A-Za-z_]+ (dense class) |
1.34s | 2.15s | ×ばつ |
\b[a-z]+\b (word-bounded class) |
1.35s | 2.13s | ×ばつ |
a|e|i|o|u (dense alternation) |
1.49s | 2.15s | ×ばつ |
if|for|while|return |
1.46s | 2.05s | ×ばつ |
error|warn|debug|info|trace |
1.46s | 1.63s | ×ばつ |
\w+@\w+ |
1.47s | 1.67s | ×ばつ |
a.b.c |
1.41s | 1.40s | ×ばつ |
Every result is verified byte-for-byte against ripgrep's counts before it is
believed. Results are similar for X, [QZ], [0-9]+, -w EXPORT_SYMBOL,
\b(if|for)\b, fn \w+\(, MODULE_LICENSE, TODO|FIXME|XXX|HACK, ... — the
full sweep and per-round history live in DESIGN.md.
How
The engine layers scanners and picks the cheapest one that is sufficient for the pattern shape — several decisions are also made per buffer, from a sampled density estimate, because the right strategy depends on the corpus:
- Word-bounded literals (
\bword\b,-w a|b|c): find a literal, check the two boundaries in O(1). No automaton at all. - Single/multi-literal prefilters: a required substring (or a covering set for alternations) is scanned first; the NFA only runs on candidate lines. The multi-literal scan is single-pass SIMD (a simplified Teddy) with a 2- or 3-byte fingerprint chosen by literal length; per-literal next-occurrence caching keeps the fallback O(n).
- Class-only patterns (
[aeiou],[A-Za-z_]+,x): a line matches iff it contains a set byte, so the search is a SIMD range-compare hop from hit to line end — density-gated against the DFA per buffer. - Lazy DFA (one table load per byte: flat transition table with the accept
bit folded into the value) for everything without a selective prefilter —
including
\b/\Bvia RE2-style look-behind-in-state with delayed matches. Unicode\bstays exact: lines containing non-ASCII bytes divert to the PikeVM. - PikeVM (Thompson NFA) is the authoritative fallback and span oracle — linear time, no catastrophic backtracking, ever.
- Pipeline: mmap'd reads, a worker pool sized to performance cores,
batch line scanning (no per-line matcher re-entry), and a counts-only collect
mode so
-c/-lnever materialize match lines.
Caveats
- The numbers above are counting mode. Printing every matching line (tens of millions here) runs ×ばつ ripgrep: each emitted line is copied once into the result snapshot, which is the library's ownership contract (results outlive the searched buffers — what an editor host needs). Realistic printing workloads are far from this extreme.
- Results stream in completion order (lowest latency; first output in ~10 ms); ripgrep's default is also unordered under parallelism.
- One machine, one corpus. Measure your own workload before believing anyone's benchmark — including this one.
Status
Functional and well-tested, but young. Unicode covers ., [...] classes,
\w/\d/\s/\b (full UCD properties), and -i case folding. The CLI covers
ripgrep's common surface (search, filtering, heading/color output, stdin, -o,
-v, context lines). Known gaps: no multiline matching across \n, no capture
groups, no replacement (-r), and results stream in completion order rather than
a stable sort. See DESIGN.md for the roadmap.
License
MIT © 2026 Mikael Säker