- Zig 96.7%
- Python 2.5%
- Shell 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| scripts | ||
| src | ||
| tests | ||
| .devkit.conf | ||
| .gitattributes | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| context.md | ||
| DESIGN.md | ||
| LICENSE | ||
| README.md | ||
comb
A ripgrep-like search library in Zig 0.17, for embedding
in command-line tools, editors, and language servers. The bundled cb CLI is a
thin driver over it.
The core never touches the filesystem — it searches []const u8 buffers, so the
host decides how bytes are read, including serving unsaved in-memory editor
buffers. A filesystem adapter ships alongside it for standalone use.
Matching is linear time (Thompson NFA / PikeVM with a lazy DFA fast path), so
there is no catastrophic backtracking. Semantics are leftmost-first like
ripgrep, and line-oriented: ., negated classes and \s never cross \n.
Syntax is at rust-regex parity except capture groups, backreferences and
lookaround; Unicode is on by default for ., [...] classes, \w \d \s
\b and -i folding, and --no-unicode reverts to bytes. DESIGN.md has the
full list.
The filesystem adapter reproduces ripgrep's default filtering: .gitignore,
.ignore and .rgignore with correct precedence, .git/info/exclude, global
core.excludesFile, require-git, parent gitignores up to the repo root, hidden
files and binary detection — exact file-set parity with rg on the benchmark
tree.
The cb CLI
cb [flags] <pattern> [path ...]
Searches PATTERN (a regex; -F for a literal) under each PATH, defaulting
to the current directory searched recursively. With no paths and piped input —
or an explicit - — it reads stdin.
On a terminal, matches are grouped under a filename heading; piped, it switches
to flat file:line:match so downstream tools see one self-describing line each.
| 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; the rest of the search continues. Exit
status is 0 if a match was found, 1 if not, 2 on error.
$ 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)
Results stream in completion order. For path-sorted output, sort on the path
column — each file's lines already arrive in order:
cb foo | sort -t: -k1,1 -s.
Using the library
Add comb to your build.zig.zon and import the comb module. It depends on
plumbuz for gitignore parsing and
handoff for the worker-pool result
handoff.
Run a matcher over a buffer and iterate matching lines:
const std = @import("std");
const comb = @import("comb");
pub fn search(alloc: std.mem.Allocator, bytes: []const u8) !void {
// Regex backend; init(alloc, pattern, case_insensitive, unicode).
var matcher: comb.Matcher = .{
.regex = try comb.RegexMatcher.init(alloc, "\\bTODO\\b", false, true),
};
defer matcher.deinit(alloc);
var it = comb.lineIterator(&matcher, bytes);
while (it.next()) |m| {
std.debug.print("{d}: {s}\n", .{ m.line_number, m.line });
}
}
Whole trees go through 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
All IO stays in the host: an editor returns in-memory bytes for open buffers,
overriding disk for dirty files, while a CLI reads or mmaps. See
src/source.zig (the searchSource leaf), src/engine/engine.zig (the pool)
and src/walk.zig (the filesystem walker and provider).
Build & test
Requires Zig 0.17.0.
$ zig build # build the cb CLI (fast) → 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 produces a fast (optimized) cb, since an end-user tool should be fast
out of the box; -Doptimize=ReleaseSmall or -Doptimize=Debug override it.
Tests always run in Debug so safety checks are active.
Performance
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
cache, counting mode (-c), best of 3:
| pattern | comb | ripgrep | ratio |
|---|---|---|---|
the (literal) |
1.46s | 1.86s | 0.78× |
[A-Za-z_]+ (dense class) |
1.34s | 2.15s | 0.62× |
\b[a-z]+\b (word-bounded class) |
1.35s | 2.13s | 0.63× |
a|e|i|o|u (dense alternation) |
1.49s | 2.15s | 0.69× |
if|for|while|return |
1.46s | 2.05s | 0.71× |
error|warn|debug|info|trace |
1.46s | 1.63s | 0.90× |
\w+@\w+ |
1.47s | 1.67s | 0.88× |
a.b.c |
1.41s | 1.40s | 1.01× |
Match counts are verified byte-for-byte against ripgrep before any timing 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 are in DESIGN.md.
How
The engine layers scanners and picks the cheapest one sufficient for the pattern shape. Several choices are 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. - Literal prefilters: a required substring, or a covering set for alternations, is scanned first and 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 scan is a SIMD range-compare hop from hit to line end — density-gated against the DFA per buffer. - Lazy DFA for anything without a selective prefilter: one table load per byte,
flat transitions with the accept bit folded into the value, and
\b/\Bvia RE2-style look-behind-in-state with delayed matches. Unicode\bstays exact by diverting lines containing non-ASCII bytes 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, and a counts-only mode so
-c/-lnever materialize match lines.
Caveats
- These are counting-mode numbers. Printing every matching line (tens of millions here) runs ~1.4× ripgrep: each emitted line is copied once into the result snapshot, which is the ownership contract that lets results outlive the searched buffers — what an editor host needs. Realistic printing workloads are nowhere near this extreme.
- Results stream in completion order, 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. 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