notes · · 5 min

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
  1. Both bugs said “no matches”
  2. What the tree buys over the text
  3. What it costs, corrected
  4. What it does not do yet

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.

A rule travels from the client to the engine, which resolves it at a position, searches and produces edits; the gateway answers with documentChanges and the client reads them. The first bug resolved the pattern at offset zero where nothing is in scope; the second read the wrong field of the answer. Both produced the same empty result
Two failures, two layers, one indistinguishable symptom: the engine resolving the pattern where nothing is in scope, and the client reading 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_codemod is 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
Citation
Alexander Panasenko (2026-09-27). Both bugs said no matches. https://prod.codes/blog/both-bugs-said-no-matches/