2026-06-06
Every grizzled engineer eventually writes a sed one-liner to rename an API call across a codebase, runs it, and watches it eat half-matched function calls and break a parenthesis on a line they didn't even look at. Regex doesn't balance brackets. comby does.
Comby is a structural search-and-replace tool. It parses code well enough to know that (, [, {, ", and friends come in pairs, and it lets you write holes (:[name]) that span balanced regions. Works on C, Go, Rust, Python, JS, OCaml, Bash, SQL, and roughly thirty other languages with one binary.
The headline trick — rename a function call without losing your mind:
comby 'log.Println(:[args])' 'slog.Info(:[args])' .go -in-place
That :[args] matches the entire argument list, even when it contains nested calls like log.Println(fmt.Sprintf("x=%d", f(g(y)))). Try doing that with sed -E and you'll be debugging escape characters until next Tuesday.
Multiple holes, with the same name reused for capture:
# Swap argument order
comby 'errors.Wrap(:[err], :[msg])' 'fmt.Errorf("%s: %w", :[msg], :[err])' .go -in-place
Match-only mode is great for codebase audits — find every panic with a custom message:
comby 'panic(:[msg])' '' .go -match-only
Preview before committing. The -diff flag prints a unified diff instead of writing:
comby 'context.TODO()' 'context.Background()' .go -diff | less
Restrict to specific holes. :[[name]] matches an identifier only — no whitespace, no operators:
# Only matches calls where the receiver is a single identifier
comby ':[[recv]].Lock()' ':[[recv]].mu.Lock()' .go -in-place
Regex inside holes when you need it:
comby ':[x~[0-9]+]' 'N' .py -match-only
Pipe-friendly stdin mode for editor integration:
cat main.go | comby 'TODO(:[x])' 'FIXME(:[x])' .go -stdin
And JSON output, so you can chain it into review tooling:
comby 'http.Get(:[url])' '' .go -match-only -json-lines | jq .
Comby treats the file extension as a language hint and switches its lexer accordingly. That's why .go understands backtick strings as atomic, .py respects triple-quoted blocks, and .sh knows heredocs. You can also force a matcher with -matcher .generic when working on something exotic.
The genuinely magical bit is rewrite templates that restructure code:
# Convert if-err-return blocks to a helper call
comby 'if err := :[call]; err != nil { return :[ret] }' \
'if err := :[call]; err != nil { return wrap(err, :[ret]) }' \
.go -in-place
This isn't a full AST refactor — comby doesn't know types, scopes, or imports. But for the 80% of mechanical rewrites where you'd reach for sed and then regret it, comby is the right tool. It's also fast: written in OCaml, parallelized over files, and happy to chew through a million-line repo in seconds.
One install: brew install comby, or grab the static binary from the GitHub releases. No language toolchain required.
