A ripgrep-like fast Zig search library, usable by CLI tools, editors, etc.
  • Zig 96.7%
  • Python 2.5%
  • Shell 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker 01b9183c0e
All checks were successful
test / test (push) Successful in 1m48s
Merge branch 'dev'
2026-10-06 18:28:39 +02:00
.forgejo/workflows Migrate to Zig 0.17 2026-10-06 18:20:45 +02:00
scripts Migrate to Zig 0.17 2026-10-06 18:20:45 +02:00
src zig fmt (0.17) 2026-10-06 18:21:20 +02:00
tests zig fmt (0.17) 2026-10-06 18:21:20 +02:00
.devkit.conf dist: build, checksum and publish the cb binary 2026-08-09 00:41:46 +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 scripts: one setup-tools, and the clones move to .tools/ 2026-09-22 21:05:44 +02:00
build.zig Migrate to Zig 0.17 2026-10-06 18:20:45 +02:00
build.zig.zon Merge branch 'dev' 2026-10-06 18:28:39 +02:00
CLAUDE.md docs: tool URLs follow simgrep/zigmap to git.urverk.org 2026-07-25 09:41:10 +02:00
context.md Migrate to Zig 0.17 2026-10-06 18:20:45 +02:00
DESIGN.md Migrate to Zig 0.17 2026-10-06 18:20:45 +02:00
LICENSE docs: add README and MIT license 2026-06-30 01:55:59 +02:00
README.md Migrate to Zig 0.17 2026-10-06 18:20:45 +02:00

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/\B via RE2-style look-behind-in-state with delayed matches. Unicode \b stays 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/-l never 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