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 theelapsedMsstat 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
- 正則表達式測試器 — 繁體中文
- 正则表达式测试器 — 简体中文
- Regex Tester — English
- 正規表現テスター — 日本語
- 정규식 테스터 — 한국어
- Testeur d’expressions régulières — Français
- Тестер регулярных выражений — Русский
- Regex-Tester — Deutsch
- Penguji Regex — Bahasa Indonesia
- Probador de expresiones regulares — Español
- Trình kiểm tra biểu thức chính quy — Tiếng Việt
- เครื่องทดสอบ Regex — ไทย
- Tester wyrażeń regularnych — Polski
- Regex Test Aracı — Türkçe
- Tester di espressioni regolari — Italiano
- Testador de Regex — Português
- Regex-tester — Nederlands
- Тестер регулярних виразів — Українська
답글 남기기