Both bugs said no matches
A structural rewrite that found nothing, twice, for two unrelated reasons in two different layers. What it takes to tell an empty answer from a broken one, and why the fix was tracing rather than thinking harder.

On this page · 4 sections
Changing the shape of a call everywhere is the refactor no tool wants to own. A rename is not
enough: the name is fine, the shape is wrong. Find-and-replace is worse than useless, because
it matches the word in a comment, misses the call split over three lines, and cannot tell your
unwrap from somebody else’s.
rust-analyzer has had the right answer for years, and we had been shipping it without knowing:
ra_ap_ide_ssr, structural search and replace, compiled into our gateway as a dependency of
the analyzer and exposed by nothing. A rule is a pattern and a template with placeholders, and
the matching happens on the syntax tree with names resolved:
$ prod-code codemod '$a.unwrap() ==>> $a.expect("missing setting")'
4 changed line(s) in 1 file(s)
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,9 +1,9 @@
/// Reads a config value, panicking when it is missing.
pub fn read_setting(map: &HashMap<String, String>, key: &str) -> String {
- map.get(key).unwrap().clone()
+ map.get(key).expect("missing setting").clone()
}
Nothing is written until you ask. Wiring it up took an afternoon. Making it return anything at all took the rest of the day, and that part is the story.
Both bugs said “no matches”
The first version printed matches nothing in this workspace for every rule I gave it. So did
the second. The two had nothing in common except their output.
changes from an answer that carries documentChanges. The engine found eight edits while the client reported none.The resolver used an unscoped position. SSR resolves the paths in a pattern as if the
pattern appeared at a particular position in a particular file, because that is how Rust
decides what tokenize or to_string means. I passed the obvious one: the start of the
context file, offset zero, which is before any item and therefore inside no scope. Moving the
position into the first function of that file is what made rules start matching; I did not
keep a trace of the failing case, so take the mechanism as the diagnosis that the fix
confirmed, not as something the log below proves.
The handler read the wrong field. The gateway answers a rewrite with the same workspace
edit it returns for rename, which uses LSP’s documentChanges. My handler read changes.
That one the log did prove, and it is the only reason I stopped testing the wrong layer. One
tracing::info! in the engine, on the line after the search:
🔧 [SSR] resolving rule="tokenize($a) ==>> tokenize(&$a)" offset=2374 scoped=1
🔧 [SSR] done rule="tokenize($a) ==>> tokenize(&$a)" files=1 edits=8
edits=8, while the client printed matches nothing in this workspace. Until that line
existed, every experiment I ran, changing the rule, the scope, the file, was a test of the
layer that was already working.
What the tree buys over the text
With both fixed, the difference from a regular expression is visible in one run. This is our own search indexer, rewritten to take a reference:
$ prod-code codemod 'tokenize($a) ==>> tokenize(&$a)' --path crates/prod-code-gateway/src/search.rs
16 changed line(s) in 1 file(s)
- tokenize(decl.container.as_deref().unwrap_or("")),
+ tokenize(&decl.container.as_deref().unwrap_or("")),
The argument there is an expression with its own calls and a string literal inside it, and the
rewrite wraps the whole of it. A regular expression that captures that correctly is a puzzle;
the pattern tokenize($a) captures it because $a binds a syntax node rather than a span of
characters. The same property is what makes the rule safe on a name that also appears in prose
or in another crate: the match is a resolved call, not an occurrence of a word.
What it costs, corrected
I wrote in the tool’s own description that passing a file makes the rewrite fast. Then I measured it, and it does not.
All on a 32-core Linux node, against this repository (seven crates) unless stated:
| a rule on a cold workspace: analyzer load and first search | 87 s |
| a new rule once the engine is resident | 9.6 s |
| the same rule again, its search cached | 0.9 s |
| any rule on a two-file crate | under a second |
| restricting the rewrite to one file | no effect on any of these |
The expensive part is the usage search: for a resolved path, the analyzer walks the crates with type inference to find every call site. Restricting the rewrite to one file restricts where edits land, not how much is searched. The description now says that, and the roadmap entry says it too, because a wrong performance claim in a tool’s own description is a claim an agent will plan around.
What it does not do yet
- Rust only. The engine is rust-analyzer’s; gopls, clangd and the rest have no equivalent, so
code_codemodis absent rather than degraded for them. - Not interactive: about ten seconds for a new rule on a resident engine, and a minute and a half when the workspace has to load first.
- One rule per call. Chaining rewrites means chaining calls, and each pays the search again.
- No preview of why a rule matched nothing: an unresolvable pattern and a pattern with no occurrences still return the same message, which is exactly the shape of the bug above. The engine knows the difference; the tool does not yet say it.
So the thing to fix next is not the matcher, it is the report. Inside the engine an empty result already has three distinguishable causes: a pattern that did not parse, one that parsed but resolved to nothing, and a resolved pattern with no call sites. The tool flattens all three into one sentence. And the day above proves the taxonomy has to reach further than the engine, because the second bug produced that same sentence from outside it entirely: the engine returned eight edits and the client dropped them. A negative answer should name the stage that produced it, including the stage that is only the wire between two of ours. Ours named nothing, and two unrelated bugs hid behind it for a day.
Cite this article
Alexander Panasenko (2026-09-27). Both bugs said no matches. https://prod.codes/blog/both-bugs-said-no-matches/