1
0
Fork
You've already forked comb
0
A ripgrep-like fast Zig search library, usable by CLI tools, editors, etc.
  • Zig 98.7%
  • Python 1.3%
2026年07月06日 22:30:46 +02:00
scripts unicode: move generator to scripts/ (tools/ is gitignored) 2026年06月30日 11:03:35 +02:00
src perf: counts-only collect mode — skip line materialization for -c/-l 2026年07月02日 08:56:08 +02:00
tests walk: global core.excludesFile (full rg-default ignore parity) 2026年06月28日 16:32:17 +02:00
.gitattributes main/dev split: build.zig.zon is merge=ours (needs merge.ours.driver true) 2026年07月06日 22:30:46 +02:00
.gitignore unicode: move generator to scripts/ (tools/ is gitignored) 2026年06月30日 11:03:35 +02:00
build.zig build: default the cb CLI to ReleaseFast (was Debug → ~3x slower) 2026年06月30日 02:32:49 +02:00
build.zig.zon deps: pin plumbuz by url+hash — main must build from a clean clone 2026年07月06日 10:10:35 +02:00
CLAUDE.md Bootstrap comb: literal search core + line search + CLI 2026年06月27日 10:04:38 +02:00
context.md docs: counts-only mode + final scoreboard (cb beats/ties rg on all rows) 2026年07月02日 09:00:32 +02:00
DESIGN.md docs: counts-only mode + final scoreboard (cb beats/ties rg on all rows) 2026年07月02日 09:00:32 +02:00
LICENSE docs: add README and MIT license 2026年06月30日 01:55:59 +02:00
README.md docs: README performance section (scoreboard, strategy, honest caveats) 2026年07月02日 09:09:04 +02:00

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 \s and \b use Unicode properties; and -i folds non-ASCII case (caféCAFÉ). --no-unicode reverts to byte matching.
    • escapes \n \t \r and \<metachar>

    Semantics are leftmost-first (Perl-like greedy), matching ripgrep. Matching is line-oriented: ., negated classes, and \s never cross \n.

  • 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, global core.excludesFile, require-git, parent-directory gitignores up to the repo root, hidden-file skipping, and binary-file detection. Exact file-set parity with rg'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/\B via RE2-style look-behind-in-state with delayed matches. Unicode \b stays 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/-l never 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