Notes › Tech Notes › Rule order and the $ sign: why the second rule ate the result of the first

Rule order and the $ sign: why the second rule ate the result of the first

1. Two rules in, a screen full of Class

I was tidying the terminology of a document that had been dragged along for years, so I lined up two rules in the replace page: rule one turns Type into Kind, rule two turns Kind into Class. Each word was supposed to land in its own place. The run printed "Class Class" with a replacement count of 3 - rule two had also eaten the Kind that rule one had just made.

The rules themselves are fine. The premise nobody puts into a handover note is that rules do not act on the source text independently: they queue, so rule 2 sees the output of rule 1. Ticking case sensitive does not break the chain, still 3 hits.

2. Order decides the output and the count

Shorten the chain and the gap is plain. Input "a1 b2" with [1→2, 2→3] ends as "a3 b3", count 3. The same rules reversed, [2→3, 1→2], end as "a2 b3", count 2. Run each rule alone as the baseline: [1→2] gives "a2 b2" with 1 hit, [2→3] gives "a1 b3" with 1 hit, so independent runs add up to 2. The extra hit belongs to b2, which holds no 1 at all: the first rule manufactures a 2 and the second one takes it away.

One more link and it gets worse. Input "abc" with [a→b, b→c, c→d] outputs "ddd", count 6. Those three rules alone only produce "bbc", "acc", "abd", 1 hit each. Same rules, same input: let them queue on the page and the count moves from 1+1+1 to 6.

three alone 1 hit each two chained 3 hits three chained 6 hits "abc" with [a→b, b→c, c→d] chained gives "ddd" no up or down button, so reorder means delete and re-add

Fig. 1: the same rules, hits when run alone versus chained

The page offers three buttons only: add rule, clear rules, copy. Rule rows carry no move up or move down. To fix the order you delete the rows and add them back, or you run two passes - copy the output of the first pass in as the input of the second.

3. The $ inside a replacement ignores the regex checkbox

The replacement side is the easier trap, because it has nothing to do with that checkbox. Rule [Hello→$&] in plain literal mode prints an output identical to the input, yet the count still reads 1 - $& is the matched content itself, so this pass changes nothing while the count refuses to admit it.

To wrap a marker around the hit: [Hello→<b>$&</b>] comes out as <b>Hello</b>, regex ticked or not.

Size jumps as well. The default three lines are 43 characters; add a single [World→$`|$'] and they swell to 77 characters from one hit. $` takes all text before the match, $' takes all text after, and the replacement inserts the two joined.

$1 in regex mode is thinner. [(Hello)|(World)→$1] cuts the 11-character "Hello World" down to 6 characters: when the hit comes from the second branch, the group behind $1 never took part in the match, so the replacement becomes an empty string. To print a literal $1 you have to write $$1.

4. "Replacement count" does not measure how much you changed

The default gear adds up per occurrence: the same default text logs 3. Tick replace only the first match and the ruler becomes at most +1 for each rule, leaving 1 for that same input. Both are legitimate, but reading either as proof of edit volume misleads you: [Hello→Hello], which moves not a single character, also logs 3 by default.

The original characters and result characters boxes cannot be read as bytes either: they count UTF-16 code units, not bytes and not words - 中文测试 shows 4 on the page while its UTF-8 encoding is 12 bytes.

5. When the regex is wrong the page keeps quiet

This page hands the work to the JS engine and builds only the two flags g and i. It does not accept inline writings from other dialects, such as (?i) or (?m). On a text of 1000 lines and 4889 characters, using (?m)^ to give every line a prefix ends with 0 hits and 4889 characters untouched, while the rule count box still shows 1. Write ^ without (?m) and it counts 1 place: that input only got a prefix on its first line, 4889 becoming 4891.

The catch in the template is empty, so a broken rule is skipped with no message and the only clue is a hit count below what you expected. A single backslash in the find box, with regex ticked, is thrown away the same way while the rule count still reports it. To check a writing itself, verify it once inside the regex testing article.

6. Large text is steady, nested quantifiers freeze the page

Scale cuts both ways. Literal replacement is steady: an input of 100000 characters with one rule measures 8.8 ms. Chain the rules instead, and 5 of [a→b, b→c, c→d, d→e, e→f] push the replacement count up to 500000. The other end is the act of adding rules: 500 rule lines measured 1068.7 ms, because the page reruns the whole job for every row it adds.

What really locks the page is backtracking. Regex (a+)+b: 20 a characters take 52.1 ms, 24 take 758.2 ms, 26 reach 3211.7 ms, so every 2 more characters cost roughly 4.2 times more. It runs synchronously on the main thread, so during those seconds nothing on the page moves and no warning appears.

20 a 52.1 ms 24 a 758.2 ms 26 a 3211.7 ms three timings for (a+)+b about 4.2x per 2 more chars, zero warnings

Fig. 2: input length against time under a nested quantifier

7. Five things I check before I touch anything

SymptomWhat to look at firstMeasured basis
Output changed one pass more than I plannedRule row order: does the later find text hit the output of the earlier rule"abc" with three chained rules logs 6, alone 1 hit each
Output identical to the inputThe $& inside the replacement, which equals the match itselfliteral [Hello→$&] logs 1
Text suddenly longer or shorter$` and $' each copy the whole context, $1 may be empty43 characters stretch to 77, 11 characters cut to 6
Zero hits and no error either(?m), (?i) and a bare backslash4889 characters untouched, rule count still shows 1
The whole page stops for secondsNested quantifiers in the regex(a+)+b on 26 characters takes 3211.7 ms

It comes down to one sentence: read the rules as a pipeline with intermediate states, not as a lookup table. I put in one rule, run it, note the replacement count, then add the second rule and watch how the number moves. After that I paste the original input and the final output into the two text compare to see on which rows the difference lands, and only then feed the rules that do not eat each other, in two batches, into the box of text batch replace.

Q: Why did the second rule rewrite what the first one produced?

Because rules overwrite in sequence, so rule 2 acts on the output of rule 1. "a1 b2" with [1→2, 2→3] gives "a3 b3" and 3 hits, while the reversed [2→3, 1→2] gives "a2 b3" and 2 hits. Avoid it by keeping the later find text out of the earlier replacement, or simply run two passes.

Q: How do I type a $ in a replacement so it stays literal?

Write $$1 for a literal $1. $&, $` and $' are interpreted even when regex is off: [Hello→$&] prints the input unchanged yet logs 1, and [World→$`|$'] stretches 43 characters to 77 characters.

Q: The page stopped moving, did it crash?

Not necessarily, it may be backtracking. A literal replacement over 100000 characters only needs 8.8 ms, while (a+)+b on 26 characters needs 3211.7 ms, synchronously on the main thread and with no message at all. Look for nesting around heavy quantifiers before you blame the size of the text.

📎 Related articles

Merged line counts never add up: a missing trailing newline, a swallowed BOM and an unreliable order 2026-10-26 Excel pasted into JSON: dates, long numbers and precision are already wrong the moment you paste 2026-10-26 The decoder only echoes the raw text: EAN-13 check digit, UPC-A leading zero, CODE128 mod 103 2026-10-26 QR code decoding tested: the error-correction level cannot beat an occlusion, 2 pixels per module is the real line 2026-10-26 Character frequency or word frequency: one Chinese sentence, a 23-row board and an empty table 2026-10-26