DEV Community

BeGoodTool.com
BeGoodTool.com

Posted on

Why regex match highlighting breaks on nested capture groups (I found out the hard way building a tester)

I write enough regex that I got tired of bouncing between the browser console and random online testers just to check whether a pattern actually matched what I thought it matched. So I built a small one myself — text box, pattern box, live highlighting. I assumed the highlighting part would be the easy bit: find the match, slice the string at its start/end index, wrap that slice in a colored <span>. Turns out that's true right up until you add capture-group highlighting, and then it quietly falls apart the moment one group is nested inside another.

Catching a bad pattern without taking down the page

There's no "run" button — every keystroke in the pattern field tries to build a live RegExp. That means for most of the time you're typing something like (abc — a pattern that's invalid until you finish it. If that throws uncaught, the whole reactive chain (highlighting, match table, replace preview) dies with it. So building the regex is wrapped tightly:

function buildRegExp() {
  const f = flags.value.join("");
  try {
    const re = new RegExp(pattern.value, f);
    regexError.value = "";
    return re;
  } catch (e) {
    regexError.value = String(e?.message ?? e);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Everything downstream just checks for null and bails out to a placeholder ("Invalid regex") instead of crashing. It's a small function, but it has to run on essentially every keystroke, cheaply, without ever letting a SyntaxError bubble up into Vue's reactivity system.

Matching once vs. matching everywhere are genuinely different code paths

I originally expected "toggle the g flag" to just change how many results come back. It actually changes which method you're allowed to call:

if (!flags.value.includes("g")) {
  const m = re.exec(s);
  if (m) {
    const idx = m.index ?? s.indexOf(m[0]);
    m.index = idx;
    result.matches.push(m);
  }
} else {
  let lastIndex = 0;
  for (const m of s.matchAll(re)) {
    const idx = m.index ?? s.indexOf(m[0], lastIndex);
    m.index = idx;
    lastIndex = idx + (m[0]?.length ?? 0);
    result.matches.push(m);
  }
}
Enter fullscreen mode Exit fullscreen mode

String.prototype.matchAll throws a TypeError if the regex doesn't have the g flag — there's no iterator to give you without it — so a non-global pattern has to fall back to a single exec() call instead. The nice side effect of using matchAll for the global case is that its internal iterator correctly skips past zero-length matches (patterns like a* matching an empty string between characters) without an infinite loop. If I'd hand-rolled this with a while ((m = re.exec(s))) loop and manually bumped re.lastIndex, forgetting to bump it on a zero-length match is a classic way to freeze the tab.

Highlighting is index math, and per-group colors are opt-in behind the "d" flag

The preview isn't a rich text editor — it's one HTML string, rebuilt from scratch on every change by walking the match list and slicing the original text between match boundaries:

let html = "";
let last = 0;
result.matches.forEach((m, idx) => {
  const i = m.index ?? 0;
  const j = i + (m[0]?.length ?? 0);
  html += `<span>${escapeHtml(s.slice(last, i))}</span>`;
  html += renderGroups(s, m, idx);
  last = j;
});
html += `<span>${escapeHtml(s.slice(last))}</span>`;
Enter fullscreen mode Exit fullscreen mode

Everything gets escapeHtml'd before insertion (this is going into v-html, so unescaped < or & in your test text would otherwise break the markup or, worse, inject it).

The part I didn't expect: colored group borders (the dashed boxes around individual capture groups) only render at all if the regex has the d flag, because that's the flag that populates match.indices — the per-group start/end offsets:

const inds = match.indices;
if (inds && Array.isArray(inds)) {
  // slice out each group's range and wrap it individually
  ...
}
return `<mark class="hl match match-${color}">${escapeHtml(full)}</mark>`;
Enter fullscreen mode Exit fullscreen mode

The default flag set on load is just ["g"]. So unless you manually tick the d checkbox, every group-related visual feature silently reduces to "the whole match is one solid color" — nothing tells you groups exist unless you know to turn on d first.

Where it actually breaks: nested groups

When d is on, renderGroups collects every group's [start, end] range, sorts them by start position, and walks left to right — pushing plain text for any gap, then a highlighted <span> for the group, then advancing a pos cursor to the group's end:

ranges.sort((a, b) => a.gs - b.gs);
ranges.forEach((g, k) => {
  if (g.gs > pos) pieces.push(`<span>${escapeHtml(s.slice(pos, g.gs))}</span>`);
  pieces.push(`<span class="hl group g${(k % 8) + 1}">${escapeHtml(s.slice(g.gs, g.ge))}</span>`);
  pos = g.ge;
});
Enter fullscreen mode Exit fullscreen mode

This works fine when groups sit side by side. It doesn't hold up for a pattern like (\d{4}-(\d{2})-\d{2}), where group 2 (the month) is fully inside group 1 (the whole date). Sorted by start position, group 1 comes first and its slice already covers the month digits — pos jumps to the end of group 1. Then group 2 gets processed next, but its start is now behind pos, so the cursor logic that's supposed to move forward through the string instead has to re-render a chunk it already rendered. The month ends up duplicated in the output instead of nested inside its parent's highlight. The algorithm assumes group ranges are laid out end-to-end; it was never built to handle containment.

Other honest limitations

  • No protection against catastrophic backtracking. Everything runs synchronously on the main thread — there's no worker, no timeout. A pathological pattern like (a+)+$ against a long non-matching string will hang the tab, and the elapsedMs stat won't save you because it never gets a chance to report back.
  • The elapsed-time number isn't a clean regex benchmark. It's measured around the whole run() call, which also does the .replace() for the replace-preview panel — so it's timing matching and replacing together, not matching in isolation.
  • The built-in templates are deliberately simplified, not RFC-grade validators — the date template doesn't check for real month lengths or leap years, and the email/URL templates are described in the tool's own copy as simplified patterns rather than full spec implementations. They're meant as a starting point to edit, not a drop-in production validator.

None of that stops it from being useful for the thing I actually use it for — poking at a pattern against real sample text before I paste it into actual code. I cleaned it up into a small free tool: Regex Tester. No sign-up, everything runs locally in your browser.


Available in other languages

Top comments (0)