comby: Structural Search and Replace That Actually Understands Brackets

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.

Key Takeaway: When you need to rewrite code patterns across a codebase, reach for comby instead of sed — balanced-delimiter matching turns "scary multi-line refactor" into a one-liner you can actually trust.

All newsletters