Regex Tester
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A regular expression is a tiny search language, and most people learn it by guessing. This one opens mid-flight: a working email pattern chewing through a messy pile of sign-up notes, matches lit up in place, every captured piece named and listed, and the pattern itself translated back into plain English — so [a-z0-9._%+-]+ reads as "one or more of a–z, 0–9, or . _ % + -" instead of keyboard noise. Hover or tap a highlight to see what its groups caught, flip the i flag off to watch the shouty address drop out, or try the presets for dates, prices, repeated words and quoted text. Break the pattern on purpose too — it names what the engine choked on, counts your brackets, and keeps explaining every piece it can still read.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Regex Tester — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---- syntax chip palette (deliberately no green: green is earned) ---- */
:root {
--c-group: #DCE7FA; --c-class: #EDE1FB; --c-quant: #FFE7CC;
--c-anchor: #FBDFE4; --c-esc: #D8EDF6; --c-lit: #ECEBE6;
--hl-a: rgba(192, 86, 0, .20); --hl-b: rgba(43, 92, 168, .18);
--hl-a-edge: rgba(192, 86, 0, .55); --hl-b-edge: rgba(43, 92, 168, .5);
--hl-on: rgba(192, 86, 0, .32);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--c-group: #1E3251; --c-class: #32254B; --c-quant: #4A3318;
--c-anchor: #4A2530; --c-esc: #17384A; --c-lit: #2E333B;
--hl-a: rgba(255, 180, 84, .26); --hl-b: rgba(122, 165, 255, .26);
--hl-a-edge: rgba(255, 180, 84, .7); --hl-b-edge: rgba(122, 165, 255, .7);
--hl-on: rgba(255, 180, 84, .40);
}
}
:root[data-theme="dark"] {
--c-group: #1E3251; --c-class: #32254B; --c-quant: #4A3318;
--c-anchor: #4A2530; --c-esc: #17384A; --c-lit: #2E333B;
--hl-a: rgba(255, 180, 84, .26); --hl-b: rgba(122, 165, 255, .26);
--hl-a-edge: rgba(255, 180, 84, .7); --hl-b-edge: rgba(122, 165, 255, .7);
--hl-on: rgba(255, 180, 84, .40);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; max-width: 100%; overflow-x: hidden; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 22px 16px 40px; }
/* ---- header ---- */
header { margin-bottom: 16px; }
h1 { font-size: 1.35rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.lede { margin: 0 0 10px; color: var(--muted); max-width: 66ch; font-size: .95rem; }
.spec { display: flex; flex-wrap: wrap; gap: 6px; }
.spec b {
font-family: var(--font-mono); font-size: .7rem; font-weight: 500; letter-spacing: .02em;
padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px;
color: var(--muted); background: var(--surface); white-space: nowrap;
}
/* ---- layout ---- */
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; align-items: start; }
@media (min-width: 940px) { .grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); } }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px;
}
.card + .card { margin-top: 14px; }
.card h2 {
font-family: var(--font-mono); font-size: .72rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); margin: 0 0 10px;
}
.card h2 .hint { text-transform: none; letter-spacing: 0; font-weight: 500; opacity: .8; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.spread { justify-content: space-between; }
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
/* ---- controls ---- */
button, input, textarea { font: inherit; color: inherit; }
.btn {
font-family: var(--font-mono); font-size: .74rem; padding: 5px 9px;
background: var(--surface); color: var(--muted);
border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
}
.btn:hover { border-color: var(--accent); color: var(--ink); }
.btn[aria-pressed="true"] {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600;
}
.btn.mini { font-size: .68rem; padding: 3px 7px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.label { font-family: var(--font-mono); font-size: .68rem; letter-spacing: .06em;
text-transform: uppercase; color: var(--muted); }
/* ---- pattern field ---- */
.patrow {
display: flex; align-items: stretch; gap: 0; background: var(--bg);
border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
}
.patrow:focus-within { border-color: var(--accent); }
.patrow.bad { border-color: var(--danger); }
.patrow .slash {
display: flex; align-items: center; padding: 0 6px; color: var(--muted);
font-family: var(--font-mono); font-size: 1rem; user-select: none;
}
.patrow input {
border: 0; background: transparent; padding: 9px 2px; min-width: 0;
font-family: var(--font-mono); font-size: .88rem;
}
.patrow input:focus { outline: none; }
#pattern { flex: 1 1 auto; }
#flags { flex: 0 0 7ch; width: 7ch; color: var(--accent); }
.flagbar { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; align-items: center; }
.flagset { display: flex; flex-wrap: wrap; gap: 5px; flex: 1 1 auto; }
.flagbar .btn { min-width: 2.1rem; text-align: center; }
.flagbar .btn i { font-style: normal; opacity: .7; font-size: .66rem; margin-left: 3px; }
/* ---- ribbon (pattern rendered as chips) ---- */
.ribbon {
display: flex; flex-wrap: wrap; align-items: center; gap: 3px;
margin-top: 10px; padding: 8px; border: 1px dashed var(--line);
border-radius: 8px; background: var(--bg); min-height: 40px;
}
.chip {
font-family: var(--font-mono); font-size: .78rem; line-height: 1.3;
padding: 3px 5px; border-radius: 5px; border: 1px solid transparent;
background: var(--c-lit); color: var(--ink); cursor: pointer;
white-space: pre-wrap; word-break: break-word; max-width: 100%;
}
.chip.k-group { background: var(--c-group); }
.chip.k-class { background: var(--c-class); }
.chip.k-quant { background: var(--c-quant); }
.chip.k-anchor { background: var(--c-anchor); }
.chip.k-esc { background: var(--c-esc); }
.chip.k-lit { background: var(--c-lit); }
.chip { text-align: left; -webkit-appearance: none; appearance: none; }
.chip.on, .chip:hover { border-color: var(--accent); }
.ribbon .delim { font-family: var(--font-mono); color: var(--muted); font-size: .82rem; padding: 0 2px; }
.ribbon .empty { font-family: var(--font-mono); font-size: .76rem; color: var(--muted); }
/* ---- error box ---- */
.err {
margin-top: 10px; padding: 9px 10px; border-radius: 8px;
border: 1px solid var(--danger); background: var(--surface);
}
.err .t { font-family: var(--font-mono); font-size: .74rem; color: var(--danger); font-weight: 600; }
.err .m { font-family: var(--font-mono); font-size: .78rem; margin-top: 3px; word-break: break-word; }
.err .h { font-size: .84rem; color: var(--muted); margin-top: 5px; }
/* ---- editor with highlight layer ---- */
.editor { position: relative; }
.editor textarea, .editor .layer {
font-family: var(--font-mono); font-size: .84rem; line-height: 1.62;
padding: 11px 12px; border: 1px solid var(--line); border-radius: 8px;
white-space: pre-wrap; overflow-wrap: break-word; word-break: normal;
letter-spacing: 0; tab-size: 2;
}
.editor textarea {
display: block; width: 100%; height: 268px; resize: vertical;
background: var(--bg); color: var(--ink); position: relative; z-index: 1;
caret-color: var(--accent);
}
.editor textarea:focus { outline: none; border-color: var(--accent); }
.editor.focused .layer { border-color: transparent; }
.layer {
position: absolute; inset: 0; z-index: 2; pointer-events: none;
overflow: hidden; color: transparent; background: transparent;
border-color: transparent; user-select: none;
}
.layer .inner { display: block; }
.layer mark {
color: transparent; background: var(--hl-a); border-radius: 3px;
box-shadow: inset 0 -1.5px 0 var(--hl-a-edge); pointer-events: auto; cursor: pointer;
}
.layer mark.alt { background: var(--hl-b); box-shadow: inset 0 -1.5px 0 var(--hl-b-edge); }
.layer mark.on { background: var(--hl-on); box-shadow: inset 0 0 0 1.5px var(--accent); }
.layer mark.zero {
position: relative; display: inline-block; width: 0; background: none; box-shadow: none;
}
.layer mark.zero::after {
content: ""; position: absolute; left: -1px; top: .08em; width: 2px; height: 1.05em;
background: var(--accent); border-radius: 1px; opacity: .8;
}
.layer mark.zero.on::after { width: 3px; opacity: 1; }
/* the status strip is a sibling of .editor, not a child — keep this selector flat */
.meta {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
margin-top: 8px; font-family: var(--font-mono); font-size: .72rem; color: var(--muted);
}
.meta #hintline { flex: 1 1 12ch; min-width: 0; }
.pill {
font-family: var(--font-mono); font-size: .72rem; padding: 3px 8px; border-radius: 999px;
border: 1px solid var(--line); background: var(--bg); color: var(--muted); white-space: nowrap;
}
.pill.ok { color: var(--run); border-color: var(--run); background: var(--run-soft); font-weight: 600; }
.pill.bad { color: var(--danger); border-color: var(--danger); font-weight: 600; }
.pill.warn { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
/* ---- tooltip ---- */
.tip {
position: absolute; z-index: 5; max-width: min(300px, 88%); pointer-events: none;
background: var(--surface); border: 1px solid var(--accent); border-radius: 8px;
padding: 8px 9px; box-shadow: 0 6px 20px rgba(0,0,0,.14); font-size: .76rem;
}
.tip[hidden] { display: none; }
.tip .th { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); letter-spacing: .04em; }
.tip .tv { font-family: var(--font-mono); font-size: .78rem; word-break: break-word; margin-top: 2px; }
.tip .tg { margin-top: 6px; display: grid; gap: 3px; }
.tip .tg div { font-family: var(--font-mono); font-size: .72rem; word-break: break-word; }
.tip .tg b { color: var(--accent); font-weight: 600; }
.tip .tg span.none { color: var(--muted); font-style: italic; }
/* ---- match list ---- */
.mlist { list-style: none; margin: 0; padding: 0; max-height: 380px; overflow-y: auto; }
.mlist li { margin: 0 0 5px; }
.mrow {
display: block; width: 100%; text-align: left; cursor: pointer;
background: var(--bg); border: 1px solid var(--line); border-left: 3px solid var(--line);
border-radius: 7px; padding: 7px 9px;
}
.mrow:hover { border-color: var(--accent); }
.mrow.on { background: var(--accent-soft); border-color: var(--accent); border-left-color: var(--accent); }
.mrow .top { display: flex; gap: 8px; align-items: baseline; justify-content: space-between; }
.mrow .idx { font-family: var(--font-mono); font-size: .7rem; color: var(--accent); font-weight: 600; }
.mrow .pos { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); white-space: nowrap; }
.mrow .txt {
font-family: var(--font-mono); font-size: .8rem; margin-top: 2px;
word-break: break-word; white-space: pre-wrap;
}
.mrow .gs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.mrow .g {
font-family: var(--font-mono); font-size: .68rem; padding: 2px 5px; border-radius: 5px;
background: var(--c-group); color: var(--ink); word-break: break-word; max-width: 100%;
}
.mrow .g b { font-weight: 600; opacity: .72; margin-right: 4px; }
.mrow .g.none { background: transparent; border: 1px dashed var(--line); color: var(--muted); }
.empty-note { font-size: .86rem; color: var(--muted); margin: 4px 0 0; }
.empty-note code { font-family: var(--font-mono); font-size: .8rem; }
.more { font-family: var(--font-mono); font-size: .7rem; color: var(--muted); padding: 4px 2px; }
/* ---- breakdown ---- */
.bk { list-style: none; margin: 0; padding: 0; max-height: 360px; overflow-y: auto; }
.bk li {
display: flex; gap: 8px; align-items: flex-start; padding: 4px 6px;
border-radius: 6px; margin-left: calc(var(--d, 0) * 13px);
border-left: 2px solid transparent;
}
.bk li.nested { border-left-color: var(--line); }
.bk li.on { background: var(--accent-soft); }
.bk .tk {
font-family: var(--font-mono); font-size: .78rem; padding: 2px 5px; border-radius: 5px;
background: var(--c-lit); white-space: pre-wrap; word-break: break-word; flex: 0 0 auto;
max-width: 45%;
}
.bk .tk.k-group { background: var(--c-group); }
.bk .tk.k-class { background: var(--c-class); }
.bk .tk.k-quant { background: var(--c-quant); }
.bk .tk.k-anchor { background: var(--c-anchor); }
.bk .tk.k-esc { background: var(--c-esc); }
.bk .bd { font-size: .84rem; color: var(--ink); }
.bk .bd em { color: var(--muted); font-style: normal; }
/* ---- replace ---- */
details.rep { margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); padding: 10px 14px; }
details.rep > summary {
cursor: pointer; font-family: var(--font-mono); font-size: .72rem; letter-spacing: .06em;
text-transform: uppercase; color: var(--muted); list-style: none;
}
details.rep > summary::-webkit-details-marker { display: none; }
details.rep > summary::before { content: "▸ "; color: var(--accent); }
details.rep[open] > summary::before { content: "▾ "; }
.repin {
width: 100%; margin-top: 9px; background: var(--bg); border: 1px solid var(--line);
border-radius: 8px; padding: 8px 10px; font-family: var(--font-mono); font-size: .82rem;
}
.repin:focus { outline: none; border-color: var(--accent); }
.repout {
margin: 9px 0 4px; padding: 10px 12px; background: var(--bg); border: 1px solid var(--line);
border-radius: 8px; font-family: var(--font-mono); font-size: .8rem; line-height: 1.6;
white-space: pre-wrap; word-break: break-word; max-height: 190px; overflow: auto;
}
.repnote { font-size: .8rem; color: var(--muted); margin: 0; }
.repnote code { font-family: var(--font-mono); }
footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
@media (prefers-reduced-motion: no-preference) {
.btn, .chip, .mrow, .layer mark, .pill { transition: background-color .12s ease, border-color .12s ease, color .12s ease; }
@keyframes flash { 0%, 100% { box-shadow: inset 0 0 0 1.5px var(--accent); } 50% { box-shadow: inset 0 0 0 3px var(--accent); } }
.layer mark.flash { animation: flash .5s ease 2; }
}
@media (min-width: 940px) { .editor textarea { height: 330px; } }
@media (max-width: 420px) {
.editor textarea { height: 230px; }
.bk .tk { max-width: 100%; }
.bk li { flex-wrap: wrap; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Regex Tester</h1>
<p class="lede">A regular expression is a tiny search language. Type one below and watch it light up
every place it matches — with the captured pieces pulled out and the whole pattern
translated back into plain English, symbol by symbol.</p>
<div class="spec">
<b>live as you type</b>
<b>your browser's own RegExp engine</b>
<b>no network</b>
<b id="specCount">7 matches</b>
</div>
</header>
<div class="grid">
<!-- ============ left column ============ -->
<div>
<section class="card">
<div class="row spread" style="margin-bottom:10px">
<h2 style="margin:0">Pattern</h2>
<div class="row" style="gap:5px">
<span class="label">try</span>
<button class="btn mini" type="button" data-preset="email">emails</button>
<button class="btn mini" type="button" data-preset="date">dates</button>
<button class="btn mini" type="button" data-preset="price">prices</button>
<button class="btn mini" type="button" data-preset="dupe">repeats</button>
<button class="btn mini" type="button" data-preset="quote">quotes</button>
</div>
</div>
<div class="patrow" id="patrow">
<span class="slash" aria-hidden="true">/</span>
<label class="sr" for="pattern">Regular expression pattern</label>
<input id="pattern" type="text" spellcheck="false" autocapitalize="off" autocorrect="off"
autocomplete="off" aria-describedby="errbox">
<span class="slash" aria-hidden="true">/</span>
<label class="sr" for="flags">Flags</label>
<input id="flags" type="text" spellcheck="false" autocapitalize="off" autocorrect="off"
autocomplete="off" maxlength="8" aria-label="Flags">
</div>
<div class="flagbar" id="flagbar">
<div class="flagset" role="group" aria-label="Flags">
<button class="btn" type="button" data-flag="g" aria-pressed="false" title="global — find every match, not just the first">g<i>all</i></button>
<button class="btn" type="button" data-flag="i" aria-pressed="false" title="ignore case — treat A and a as the same">i<i>case</i></button>
<button class="btn" type="button" data-flag="m" aria-pressed="false" title="multiline — ^ and $ match at every line, not just the whole text">m<i>lines</i></button>
<button class="btn" type="button" data-flag="s" aria-pressed="false" title="dot-all — let . match line breaks too">s<i>dot</i></button>
<button class="btn" type="button" data-flag="u" aria-pressed="false" title="unicode — handle emoji and other characters as single units">u<i>uni</i></button>
<button class="btn" type="button" data-flag="y" aria-pressed="false" title="sticky — each match must start exactly where the last one ended">y<i>stick</i></button>
</div>
<button class="btn mini" type="button" id="btnCopy" style="margin-left:auto"
aria-label="Copy the pattern and flags">copy</button>
</div>
<div class="ribbon" id="ribbon" role="group" aria-label="The pattern, piece by piece"></div>
<div class="err" id="errbox" hidden>
<div class="t">PATTERN WON'T COMPILE</div>
<div class="m" id="errmsg"></div>
<div class="h" id="errhint"></div>
</div>
</section>
<section class="card">
<div class="row spread" style="margin-bottom:10px">
<h2 style="margin:0">Test text</h2>
<button class="btn mini" type="button" id="btnReset">reset text</button>
</div>
<div class="editor" id="editor">
<label class="sr" for="text">Text to search</label>
<textarea id="text" spellcheck="false" autocapitalize="off" autocorrect="off"></textarea>
<div class="layer" id="layer" aria-hidden="true"><span class="inner" id="inner"></span></div>
<div class="tip" id="tip" hidden></div>
</div>
<div class="meta">
<span class="pill" id="statusPill">ready</span>
<span class="pill warn" id="riskPill" hidden>nested repeat</span>
<span id="timing">0.0 ms</span>
<span id="hintline">hover or tap a highlight to see its groups</span>
</div>
<p class="sr" id="live" aria-live="polite"></p>
</section>
<details class="rep" id="repwrap">
<summary>Replace preview</summary>
<label class="sr" for="replace">Replacement text</label>
<input class="repin" id="replace" type="text" spellcheck="false" autocapitalize="off"
autocorrect="off" autocomplete="off">
<div class="repout" id="repout"></div>
<p class="repnote">Use <code>$1</code>, <code>$2</code> for numbered groups,
<code>$<name></code> for named ones, and <code>$&</code> for the whole match.</p>
</details>
</div>
<!-- ============ right column ============ -->
<div>
<section class="card">
<h2>Matches <span class="hint" id="mcount"></span></h2>
<ul class="mlist" id="mlist"></ul>
<p class="empty-note" id="mempty" hidden></p>
</section>
<section class="card">
<h2>What this pattern says <span class="hint">— piece by piece</span></h2>
<ul class="bk" id="bk"></ul>
</section>
</div>
</div>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
function attr(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """);
}
function esc(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function humanList(a) {
if (a.length === 0) return "nothing";
if (a.length === 1) return a[0];
return a.slice(0, -1).join(", ") + " or " + a[a.length - 1];
}
/* =====================================================================
samples — everyday text, deliberately messy
===================================================================== */
var SAMPLES = {
email: "Book club sign-ups — please add anyone I missed.\n\nMaya Ortiz <[email protected]> is bringing the chairs.\nForwarded from: [email protected] (he checked the room twice).\[email protected], [email protected]; [email protected]\n\nNot addresses: \"@nobody\", plain-text-at-example, or 100 @ 3pm.\nOne old note says jules AT mapletree DOT example — fix that by hand.\nThe reply-to came back as [email protected] (shouting, sorry).\nEveryone else reads the shared inbox: [email protected].\n\nPhone list lives somewhere else: 555-0134, 555-0182.",
date: "Delivery notes\n--------------\nOrder 4471 went out 3/2/2026, promised by 14/03/2026 (they write day first).\nThe depot is shut 12/25/25, so nothing moves that week.\nBackup slots: 1/1/26 and 7/4/2026.\nNot dates: 3 / 2 / 2026 with spaces, the fraction 22/7, or version 2/3.",
price: "Bake sale — end of day tally\n 12 loaves ..................... $54.00\n jam jars, small ............... $7.50\n jam jars, large ............... $1,250 (bulk order, paid later)\n napkins and cups .............. $12\n refund to Priya ............... -$7.50\nTotal banked: $1,316.00 — the cash box started with $40.\nA lone $ sign, or $ 20 written with a space, should not count.",
dupe: "Proofreading pass, round two\n\nThe the recipe wants two cups of flour, then then a pinch of salt.\nShe said she she would bring the the big pot on Sunday.\nCareful: \"had had\" is fine in \"he had had enough\", and \"that that\"\ncan be correct too — a machine cannot tell, you have to read it.\nNothing repeats on on this line? It does.",
quote: "Notes from the tasting\n\nShe called it \"a bit too sweet\" and then \"actually, perfect\".\nThe label says \"small batch\" but the box says \"made in a big shed\".\nTip: change .*? to .* in the pattern and watch one match swallow\nthe whole line — that is the difference between lazy and greedy."
};
var PRESETS = {
email: {
pattern: "(?<user>[a-z0-9._%+-]+)@(?<domain>[a-z0-9.-]+\\.[a-z]{2,})",
flags: "gi", replace: "$<user> at $<domain>", text: SAMPLES.email
},
date: {
pattern: "\\b(?<d>\\d{1,2})/(?<m>\\d{1,2})/(?<y>\\d{2,4})\\b",
flags: "g", replace: "[$<y>-$<m>-$<d>]", text: SAMPLES.date
},
price: {
pattern: "\\$(?<amount>\\d{1,3}(?:,\\d{3})*(?:\\.\\d{2})?)",
flags: "g", replace: "$<amount> pounds", text: SAMPLES.price
},
dupe: {
pattern: "\\b(?<word>\\w+)\\s+\\k<word>\\b",
flags: "gi", replace: "$<word>", text: SAMPLES.dupe
},
quote: {
pattern: "\"(?<said>.*?)\"",
flags: "g", replace: "“$<said>”", text: SAMPLES.quote
}
};
/* =====================================================================
pattern parser — for the plain-English breakdown only.
Deliberately forgiving: it still explains a pattern that will not compile.
===================================================================== */
var ESC_WORDS = {
d: ["k-esc", "Any digit, 0 through 9."],
D: ["k-esc", "Any character that is not a digit."],
w: ["k-esc", "One “word” character: a letter, a digit, or an underscore."],
W: ["k-esc", "Anything that is not a letter, digit or underscore."],
s: ["k-esc", "One space, tab, or line break."],
S: ["k-esc", "Any character that is not a space, tab or line break."],
b: ["k-anchor", "A word boundary — the seam between a word character and anything else. It matches a position, not a character."],
B: ["k-anchor", "A spot that is NOT a word boundary — somewhere inside a word."],
n: ["k-esc", "A line break (new line)."],
r: ["k-esc", "A carriage return."],
t: ["k-esc", "A tab character."],
f: ["k-esc", "A form feed."],
v: ["k-esc", "A vertical tab."],
"0": ["k-esc", "A null character."]
};
function classPieces(body) {
var named = [], singles = [], i = 0;
while (i < body.length) {
var ch = body.charAt(i);
if (ch === "\\") {
var nx = body.charAt(i + 1);
if (nx === "") { i += 1; continue; }
if (nx === "d") named.push("digits 0–9");
else if (nx === "w") named.push("letters, digits or _");
else if (nx === "s") named.push("whitespace");
else if (nx === "D") named.push("anything but a digit");
else if (nx === "W") named.push("anything but a letter, digit or _");
else if (nx === "S") named.push("anything but whitespace");
else if (nx === "n") named.push("a line break");
else if (nx === "t") named.push("a tab");
else if (nx === "r") named.push("a carriage return");
else singles.push(nx);
i += 2;
continue;
}
if (body.charAt(i + 1) === "-" && i + 2 < body.length) {
named.push(ch + "–" + body.charAt(i + 2));
i += 3;
continue;
}
singles.push(ch);
i += 1;
}
var parts = named.slice();
if (singles.length) {
parts.push("the character" + (singles.length > 1 ? "s" : "") + " " + singles.join(" "));
}
return parts;
}
function describeClass(raw) {
/* an unterminated class ("[abc") has no closing bracket to trim */
var body = raw.charAt(raw.length - 1) === "]" && raw.length > 1 ? raw.slice(1, -1) : raw.slice(1);
var neg = body.charAt(0) === "^";
if (neg) body = body.slice(1);
var parts = classPieces(body);
if (!parts.length) {
return neg
? "An empty “not this” set — it rules nothing out, so it matches any single character."
: "An empty set — there is no character in it, so this can never match.";
}
if (neg) return "One character that is NOT " + humanList(parts) + ".";
return "One character from this set: " + humanList(parts) + ".";
}
function quantWords(text) {
var lazy = /\?$/.test(text) && text.length > 1;
var core = lazy ? text.slice(0, -1) : text;
var base;
if (core === "*") base = "Zero or more of the piece just before it";
else if (core === "+") base = "One or more of the piece just before it";
else if (core === "?") base = "The piece just before it is optional — zero times or once";
else {
var m = /^\{(\d*)(,?)(\d*)\}$/.exec(core);
if (!m) return "A repeat count.";
if (!m[2]) base = "Exactly " + m[1] + " of the piece just before it";
else if (!m[3]) base = "At least " + m[1] + " of the piece just before it, no upper limit";
else base = "Between " + m[1] + " and " + m[3] + " of the piece just before it";
}
if (core === "?" && !lazy) return base + ".";
if (lazy) return base + ", taking as few as it can get away with (lazy).";
return base + ", grabbing as many as it can, then giving some back only if the rest of the pattern needs it (greedy).";
}
function isQuantStart(src, i) {
var c = src.charAt(i);
if (c === "*" || c === "+" || c === "?") return true;
if (c === "{") return /^\{\d+(,\d*)?\}/.test(src.slice(i));
return false;
}
function readQuant(src, i) {
var c = src.charAt(i), end;
if (c === "{") {
var m = /^\{\d+(,\d*)?\}/.exec(src.slice(i));
end = i + m[0].length;
} else {
end = i + 1;
}
if (src.charAt(end) === "?") end += 1;
return end;
}
function parsePattern(src, flags) {
var toks = [], i = 0, depth = 0, capN = 0, names = [];
var multi = flags.indexOf("m") > -1, dotall = flags.indexOf("s") > -1;
var n = src.length, guard = 0;
function push(start, end, kind, desc, d) {
toks.push({
start: start, end: end, text: src.slice(start, end),
kind: kind, desc: desc, depth: d === undefined ? depth : d
});
}
while (i < n && guard++ < 4000) {
var c = src.charAt(i), start = i, end;
if (isQuantStart(src, i) && toks.length) {
end = readQuant(src, i);
push(start, end, "k-quant", quantWords(src.slice(start, end)));
i = end;
continue;
}
if (c === "\\") {
var nx = src.charAt(i + 1);
end = i + 2;
if (nx === "u" && src.charAt(i + 2) === "{") {
var close = src.indexOf("}", i + 3);
end = close === -1 ? n : close + 1;
push(start, end, "k-esc", "The character with this unicode number.");
} else if (nx === "u") {
end = Math.min(i + 6, n);
push(start, end, "k-esc", "The character with this unicode number.");
} else if (nx === "x") {
end = Math.min(i + 4, n);
push(start, end, "k-esc", "The character with this hex code.");
} else if ((nx === "p" || nx === "P") && src.charAt(i + 2) === "{") {
var pc = src.indexOf("}", i + 3);
end = pc === -1 ? n : pc + 1;
push(start, end, "k-esc", (nx === "p" ? "One character with this unicode property." : "One character WITHOUT this unicode property.") + " (Needs the u flag.)");
} else if (nx === "k" && src.charAt(i + 2) === "<") {
var kc = src.indexOf(">", i + 3);
end = kc === -1 ? n : kc + 1;
push(start, end, "k-group", "Back-reference: the exact text that the group named “" + src.slice(i + 3, end - 1) + "” already matched, repeated here.");
} else if (/[1-9]/.test(nx)) {
while (/[0-9]/.test(src.charAt(end))) end += 1;
push(start, end, "k-group", "Back-reference: the exact text that capture group " + src.slice(i + 1, end) + " already matched, repeated here.");
} else if (ESC_WORDS[nx]) {
push(start, end, ESC_WORDS[nx][0], ESC_WORDS[nx][1]);
} else if (nx === "") {
end = i + 1;
push(start, end, "k-lit", "A backslash with nothing after it — there is no character here for it to escape.");
} else {
push(start, end, "k-lit", "A literal " + nx + " character. The backslash strips its special meaning.");
}
i = end;
continue;
}
if (c === "[") {
/* JavaScript closes a class on the first "]" — even the very first one,
which is why [] is an empty set and []] is that set plus a literal ]. */
end = i + 1;
if (src.charAt(end) === "^") end += 1;
while (end < n && src.charAt(end) !== "]") {
end += src.charAt(end) === "\\" ? 2 : 1;
}
end = Math.min(end + 1, n);
push(start, end, "k-class", describeClass(src.slice(start, end)));
i = end;
continue;
}
if (c === "(") {
var head = src.slice(i, i + 4);
if (head.slice(0, 3) === "(?:") {
push(i, i + 3, "k-group", "Start of a group that does not capture — it only bundles what is inside so a repeat or a choice can cover all of it.");
i += 3;
} else if (head.slice(0, 3) === "(?=") {
push(i, i + 3, "k-group", "Look ahead: the text right after must match what is inside, but that text is not part of the match itself.");
i += 3;
} else if (head.slice(0, 3) === "(?!") {
push(i, i + 3, "k-group", "Look ahead, negative: whatever comes next must NOT match what is inside.");
i += 3;
} else if (head === "(?<=") {
push(i, i + 4, "k-group", "Look behind: the text just before must match what is inside, without becoming part of the match.");
i += 4;
} else if (head === "(?<!") {
push(i, i + 4, "k-group", "Look behind, negative: the text just before must NOT match what is inside.");
i += 4;
} else {
var nm = /^\(\?<([A-Za-z_$][A-Za-z0-9_$]*)>/.exec(src.slice(i));
capN += 1;
if (nm) {
names.push(nm[1]);
push(i, i + nm[0].length, "k-group", "Start of capture group " + capN + ", named “" + nm[1] + "”. Whatever it matches is kept aside under that name.");
i += nm[0].length;
} else {
names.push(null);
push(i, i + 1, "k-group", "Start of capture group " + capN + ". Whatever it matches is kept aside so you can pull it out on its own.");
i += 1;
}
}
depth += 1;
continue;
}
if (c === ")") {
depth = Math.max(0, depth - 1);
push(i, i + 1, "k-group", "End of that group.", depth);
i += 1;
continue;
}
if (c === "|") {
push(i, i + 1, "k-quant", "Or — either the part before this bar or the part after it will do.");
i += 1;
continue;
}
if (c === "^") {
push(i, i + 1, "k-anchor", multi
? "Start of a line. With the m flag on, that means every line, not only the top of the text."
: "The very start of the text. Turn on the m flag to make it mean the start of every line.");
i += 1;
continue;
}
if (c === "$") {
push(i, i + 1, "k-anchor", multi
? "End of a line. With the m flag on, that means every line."
: "The very end of the text. Turn on the m flag to make it mean the end of every line.");
i += 1;
continue;
}
if (c === ".") {
push(i, i + 1, "k-esc", dotall
? "Any single character at all — the s flag lets it swallow line breaks too."
: "Any single character except a line break.");
i += 1;
continue;
}
/* literal run */
end = i;
while (end < n && "\\[]()|^$.*+?{".indexOf(src.charAt(end)) === -1) end += 1;
if (end === i) { push(i, i + 1, "k-lit", "A literal " + c + " character."); i += 1; continue; }
if (end - i > 1 && end < n && isQuantStart(src, end)) end -= 1;
var lit = src.slice(i, end);
push(i, end, "k-lit", lit.length === 1
? "The character “" + lit + "”, exactly."
: "The text “" + lit + "”, exactly, in that order.");
i = end;
}
return { toks: toks, names: names, capN: capN };
}
/* =====================================================================
state
===================================================================== */
var state = {
re: null, err: null, blank: false, matches: [], ms: 0, capped: false, risky: false, scanned: -1,
active: -1, parse: { toks: [], names: [], capN: 0 }, rowIdx: 0, chipIdx: 0
};
var MAX_MATCHES = 2000;
var LIST_CAP = 250;
var pattern = $("pattern"), flagsIn = $("flags"), ta = $("text"),
layer = $("layer"), inner = $("inner"), tip = $("tip"),
editor = $("editor"), ribbon = $("ribbon"), bk = $("bk"),
mlist = $("mlist"), mempty = $("mempty"), mcount = $("mcount"),
statusPill = $("statusPill"), timing = $("timing"), live = $("live"),
errbox = $("errbox"), errmsg = $("errmsg"), errhint = $("errhint"),
patrow = $("patrow"), replaceIn = $("replace"), repout = $("repout"),
specCount = $("specCount");
/* =====================================================================
matching — with a guard against runaway backtracking
===================================================================== */
function now() {
return (window.performance && performance.now) ? performance.now() : Date.now();
}
/* A repeat wrapped around another repeat — (a+)+ , (\w+|\s)* — can make the
engine explore exponentially many ways to split the text. Spotting that
shape up front lets us scan carefully instead of freezing the page. */
function unbounded(t) {
return t.kind === "k-quant" && /^(\*|\+|\{\d+,\})\??$/.test(t.text);
}
function optionalQuant(t) {
return t.kind === "k-quant" && /^(\?|\{0(,\d*)?\})\??$/.test(t.text);
}
/* The tail of a branch with everything that is allowed to vanish peeled off.
(\w+\s?)+ is every bit as explosive as (\w+)+ — the \s? just steps aside. */
function endsStretchy(br) {
var j = br.length - 1;
while (j >= 0) {
if (unbounded(br[j])) return true;
if (!optionalQuant(br[j])) return false;
j -= 1;
if (j >= 0 && br[j].text === ")") {
var d = 0;
while (j >= 0) {
if (br[j].text === ")") d += 1;
else if (br[j].kind === "k-group" && br[j].text.charAt(0) === "(") {
d -= 1;
if (d === 0) break;
}
j -= 1;
}
}
j -= 1;
}
return false;
}
function riskyPattern(toks) {
for (var i = 1; i < toks.length; i++) {
if (!unbounded(toks[i]) || toks[i - 1].text !== ")") continue;
var open = -1, d = 0;
for (var j = i - 1; j >= 0; j--) {
if (toks[j].text === ")") d += 1;
else if (toks[j].kind === "k-group" && toks[j].text.charAt(0) === "(") {
d -= 1;
if (d === 0) { open = j; break; }
}
}
if (open < 0) continue;
/* Walk the group's own branches and keep the last piece of each. A branch
that ends on a stretchy repeat is the ambiguous kind; a branch that has
to land on something fixed is not. */
var lvl = toks[open].depth + 1, br = [];
for (var k = open + 1; k < i - 1; k++) {
var t = toks[k];
if (t.depth === lvl && t.text === "|") {
if (endsStretchy(br)) return true;
br = [];
} else {
br.push(t);
}
}
if (endsStretchy(br)) return true;
}
return false;
}
/* JavaScript cannot interrupt a RegExp once it has started: no timer, no
escape hatch. So a pattern with that shape is read against a short head of
the text first — short enough that even an exponential search finishes
inside a blink — and the window only grows while each pass stays cheap. The
first pass that costs real time is the last one, so a pattern that merely
looks explosive still gets to read the whole text, and a truly explosive one
costs a blink instead of a frozen tab. The panel says how far it got. */
var RISK_START = 18; /* first window, in characters */
var RISK_STEP = 8; /* grow the window by this much per pass */
var RISK_STEP_MS = 0.5; /* a pass slower than this is the last one */
var RISK_PASSES = 300; /* and never more passes than this */
function record(m) {
var groups = [];
for (var k = 1; k < m.length; k++) {
groups.push({ n: k, name: state.parse.names[k - 1] || null, v: m[k] });
}
return { start: m.index, end: m.index + m[0].length, text: m[0], groups: groups };
}
function execAll(re, text) {
var out = [], capped = false, t0 = now();
if (re.global || re.sticky) {
re.lastIndex = 0;
var m, steps = 0;
while ((m = re.exec(text)) !== null) {
out.push(record(m));
if (m[0] === "") re.lastIndex += 1;
if (out.length >= MAX_MATCHES) { capped = true; break; }
if ((++steps % 250) === 0 && now() - t0 > 400) { capped = true; break; }
}
} else {
var one = re.exec(text);
if (one) out.push(record(one));
}
return { out: out, capped: capped, ms: now() - t0 };
}
function runMatch() {
var full = ta.value, re = state.re;
state.risky = riskyPattern(state.parse.toks);
state.scanned = -1;
if (!state.risky) {
var plain = execAll(re, full);
state.matches = plain.out;
state.capped = plain.capped;
state.ms = plain.ms;
return;
}
var win = Math.min(RISK_START, full.length);
var res = execAll(re, full.slice(0, win));
var total = res.ms, passes = 0;
while (win < full.length && res.ms < RISK_STEP_MS && !res.capped && passes++ < RISK_PASSES) {
var next = Math.min(full.length, win + RISK_STEP);
res = execAll(re, full.slice(0, next));
total += res.ms;
win = next;
}
state.matches = res.out;
state.capped = res.capped;
state.ms = total;
state.scanned = win < full.length ? win : -1;
}
/* =====================================================================
rendering
===================================================================== */
function syncGutter() {
var sb = ta.offsetWidth - ta.clientWidth - 2;
if (sb < 0) sb = 0;
layer.style.paddingRight = (12 + sb) + "px";
layer.style.height = ta.offsetHeight + "px";
}
function renderLayer() {
var text = ta.value, html = [], pos = 0;
var list = state.matches;
var n = Math.min(list.length, MAX_MATCHES);
for (var k = 0; k < n; k++) {
var m = list[k];
if (m.start < pos) continue;
html.push(esc(text.slice(pos, m.start)));
html.push('<mark class="m' + (m.text === "" ? " zero" : k % 2 ? " alt" : "") +
(k === state.active ? " on" : "") +
'" data-i="' + k + '">' + esc(m.text) + "</mark>");
pos = m.end;
}
html.push(esc(text.slice(pos)) + "\n");
inner.innerHTML = html.join("");
syncGutter();
syncScroll();
}
function syncScroll() {
inner.style.transform = "translateY(" + (-ta.scrollTop) + "px)";
}
function groupChip(g) {
var lab = g.name ? g.name : String(g.n);
if (g.v === undefined) {
return '<span class="g none"><b>' + esc(lab) + "</b>no match</span>";
}
return '<span class="g"><b>' + esc(lab) + "</b>" +
(g.v === "" ? "<i>empty</i>" : esc(g.v)) + "</span>";
}
function renderList() {
var list = state.matches;
mlist.innerHTML = "";
if (state.err) {
mempty.hidden = false;
mempty.innerHTML = "Nothing to show — the pattern above has a syntax error. Fix it and matches come straight back.";
mcount.textContent = "";
return;
}
if (state.blank) {
mempty.hidden = false;
mempty.innerHTML = "Nothing to match yet — type a pattern between the slashes, or pick one of the examples above.";
mcount.textContent = "";
return;
}
if (!list.length) {
mempty.hidden = false;
mempty.innerHTML = state.scanned >= 0
? "Nothing matched in the first <code>" + state.scanned + "</code> characters. This pattern repeats a repeat, so the search stops early on purpose rather than locking up the page — shorten the text or simplify the pattern to see more."
: "The pattern is valid, but it does not match anything in this text. Try loosening a piece — for example swap a <code>+</code> for a <code>*</code>, or turn on the <code>i</code> flag.";
mcount.textContent = "— none";
return;
}
mempty.hidden = true;
mcount.textContent = "— " + list.length + (state.capped ? "+ (stopped early)" : "");
var frag = document.createDocumentFragment();
var shown = Math.min(list.length, LIST_CAP);
for (var k = 0; k < shown; k++) {
var m = list[k];
var li = document.createElement("li");
var gs = "";
if (m.groups.length) {
gs = '<span class="gs">' + m.groups.map(groupChip).join("") + "</span>";
}
li.innerHTML = '<button type="button" class="mrow' + (k === state.active ? " on" : "") +
'" data-i="' + k + '" tabindex="' + (k === state.rowIdx ? "0" : "-1") + '">' +
'<span class="top"><span class="idx">#' + (k + 1) + '</span>' +
'<span class="pos">' + m.start + "–" + m.end + "</span></span>" +
'<span class="txt">' + (m.text === "" ? "<i>empty match</i>" : esc(m.text)) + "</span>" +
gs + "</button>";
frag.appendChild(li);
}
if (list.length > shown) {
var more = document.createElement("li");
more.className = "more";
more.textContent = "+ " + (list.length - shown) + " more not listed";
frag.appendChild(more);
}
mlist.appendChild(frag);
}
function renderRibbon() {
var toks = state.parse.toks;
if (!toks.length) {
ribbon.innerHTML = '<span class="empty">empty pattern — type something between the slashes</span>';
state.chipIdx = 0;
return;
}
if (state.chipIdx >= toks.length) state.chipIdx = 0;
var html = ['<span class="delim" aria-hidden="true">/</span>'];
for (var k = 0; k < toks.length; k++) {
html.push('<button type="button" class="chip ' + toks[k].kind + '" data-t="' + k +
'" tabindex="' + (k === state.chipIdx ? "0" : "-1") +
'" aria-label="' + attr(toks[k].text + " — " + toks[k].desc) + '">' +
esc(toks[k].text) + "</button>");
}
html.push('<span class="delim" aria-hidden="true">/' + esc(flagsIn.value) + "</span>");
ribbon.innerHTML = html.join("");
}
function renderBreakdown() {
var toks = state.parse.toks;
bk.innerHTML = "";
if (!toks.length) {
bk.innerHTML = '<li><span class="bd"><em>Nothing to explain yet — the pattern is empty.</em></span></li>';
return;
}
var frag = document.createDocumentFragment();
for (var k = 0; k < toks.length; k++) {
var t = toks[k];
var li = document.createElement("li");
li.style.setProperty("--d", String(t.depth));
if (t.depth > 0) li.className = "nested";
li.setAttribute("data-t", String(k));
li.innerHTML = '<code class="tk ' + t.kind + '">' + esc(t.text) + "</code>" +
'<span class="bd">' + esc(t.desc) + "</span>";
frag.appendChild(li);
}
bk.appendChild(frag);
}
function renderStatus() {
var risk = $("riskPill"), hint = $("hintline");
if (state.err) {
risk.hidden = true;
hint.textContent = "fix the pattern above and the matches come straight back";
statusPill.className = "pill bad";
statusPill.textContent = "invalid pattern";
timing.textContent = "";
specCount.textContent = "invalid pattern";
live.textContent = "Pattern error: " + state.err;
return;
}
if (state.blank) {
risk.hidden = true;
hint.textContent = "type a pattern between the slashes to start matching";
statusPill.className = "pill";
statusPill.textContent = "no pattern";
timing.textContent = "";
specCount.textContent = "no pattern yet";
live.textContent = "";
return;
}
if (state.risky) {
risk.hidden = false;
risk.textContent = state.scanned >= 0
? "stopped at " + state.scanned + " chars"
: "nested repeat";
hint.textContent = state.scanned >= 0
? "a repeat inside a repeat can take exponentially long here, so the search stopped early"
: "heads-up: this has a repeat inside a repeat, which can get very slow on near-miss text";
} else {
risk.hidden = true;
hint.textContent = "hover or tap a highlight to see its groups";
}
var n = state.matches.length;
if (n > 0) {
statusPill.className = "pill ok";
statusPill.textContent = n + (state.capped ? "+" : "") + (n === 1 ? " match" : " matches");
} else {
statusPill.className = "pill";
statusPill.textContent = "no matches";
}
timing.textContent = (state.ms < 0.01 ? "<0.01" : state.ms < 10 ? state.ms.toFixed(2) : state.ms.toFixed(1)) +
" ms" + (state.capped ? " · stopped early" : "");
specCount.textContent = n + (state.capped ? "+" : "") + (n === 1 ? " match" : " matches");
live.textContent = n + (n === 1 ? " match" : " matches") + " found.";
}
function renderReplace() {
if (!$("repwrap").open) return;
if (state.err || state.blank) { repout.textContent = "—"; return; }
var src = ta.value, cut = false;
if (state.scanned >= 0 && src.length > state.scanned) {
src = src.slice(0, state.scanned);
cut = true;
}
var out;
try {
var re = new RegExp(pattern.value, flagsIn.value);
out = src.replace(re, replaceIn.value);
} catch (e) {
out = "—";
}
repout.textContent = cut ? out + " … preview stops where the search stopped" : out;
}
function bracketTally() {
/* Strip escapes first, then whole character classes: a "(" inside [(] is a
plain character, and counting it would blame the wrong bracket. */
var bare = pattern.value.replace(/\\[\s\S]/g, "").replace(/\[[^\]]*\]/g, "");
return {
bare: bare,
opens: (bare.match(/\(/g) || []).length,
closes: (bare.match(/\)/g) || []).length
};
}
function errorHint(msg) {
/* Engines word these differently, so match on the distinctive phrase each
one uses rather than on any single browser's exact string. */
var t = bracketTally();
if (/invalid flags|invalid regular expression flags/i.test(msg)) {
return "Flags can only be g, i, m, s, u, y, d and v — and each one only once.";
}
if (/unterminated .*class|missing terminating \]/i.test(msg)) {
return "There is a “[” with no closing “]” — a character set has to be shut.";
}
if (/\\ at end of pattern|\\ at end of the pattern/i.test(msg)) {
return "The pattern ends on a lone backslash, which has nothing to escape.";
}
if (/nothing to repeat|invalid quantifier/i.test(msg)) {
return "A repeat sign (* + ? or {2,3}) has nothing in front of it to repeat. Escape it as \\* if you meant the character itself.";
}
if (/numbers out of order/i.test(msg)) {
return "In a {min,max} repeat the first number has to be the smaller one.";
}
if (/range out of order|out of order in character class/i.test(msg)) {
return "A range inside [ ] runs backwards, like [z-a]. Put the lower character first.";
}
if (/duplicate capture group name/i.test(msg)) {
return "Two groups share the same (?<name>. Every group name has to be unique.";
}
if (/invalid named (capture|reference|back ?reference)/i.test(msg)) {
return "A \\k<…> back-reference points at a group name this pattern never defines.";
}
if (/invalid group|invalid regexp group/i.test(msg)) {
return "A “(?” opening the engine does not recognise. The ones it knows are (?: (?= (?! (?<= (?<! and (?<name>.";
}
if (t.opens !== t.closes) {
return "Counting brackets: " + t.opens + " “(” against " + t.closes +
" “)”. Add the missing one, or escape it as \\( if you meant a plain bracket.";
}
if (/\[/.test(t.bare) && t.bare.indexOf("]") === -1) {
return "There is a “[” with no closing “]” — a character set has to be shut.";
}
if (/\\$/.test(t.bare)) {
return "The pattern ends on a lone backslash, which has nothing to escape.";
}
return "The breakdown on the right still explains everything it can parse, so you can see where it goes wrong.";
}
function showError(msg) {
errbox.hidden = false;
errmsg.textContent = msg;
patrow.classList.add("bad");
errhint.textContent = errorHint(msg);
}
function clearError() {
errbox.hidden = true;
errmsg.textContent = "";
errhint.textContent = "";
patrow.classList.remove("bad");
}
/* =====================================================================
the main pass
===================================================================== */
function run() {
state.parse = parsePattern(pattern.value, flagsIn.value);
state.err = null;
state.blank = pattern.value === "";
try {
state.re = new RegExp(pattern.value, flagsIn.value);
} catch (e) {
state.err = e && e.message ? e.message : String(e);
state.re = null;
}
if (state.err) {
state.matches = [];
state.capped = false;
state.ms = 0;
showError(state.err);
} else if (state.blank) {
/* an empty pattern matches the gap before every character — true, useless,
and it would pepper the text with hundreds of phantom marks */
clearError();
state.matches = [];
state.capped = false;
state.ms = 0;
state.risky = false;
state.scanned = -1;
} else {
clearError();
runMatch();
}
if (state.active >= state.matches.length) state.active = -1;
if (state.rowIdx >= Math.min(state.matches.length, LIST_CAP)) state.rowIdx = 0;
renderRibbon();
renderBreakdown();
renderLayer();
renderList();
renderStatus();
renderReplace();
syncFlagButtons();
}
var pending = false;
function schedule() {
if (pending) return;
pending = true;
requestAnimationFrame(function () { pending = false; run(); });
}
/* =====================================================================
match focus / activation
===================================================================== */
function setActive(k, opts) {
opts = opts || {};
state.active = k;
state.rowIdx = k < 0 ? 0 : k;
var marks = inner.querySelectorAll("mark");
for (var j = 0; j < marks.length; j++) {
marks[j].classList.toggle("on", Number(marks[j].getAttribute("data-i")) === k);
}
var rows = mlist.querySelectorAll(".mrow");
var want = k < 0 ? state.rowIdx : k, tabbed = false;
for (var r = 0; r < rows.length; r++) {
var di = Number(rows[r].getAttribute("data-i"));
rows[r].classList.toggle("on", di === k);
rows[r].tabIndex = di === want ? 0 : -1;
if (di === want) tabbed = true;
}
/* the list must always keep exactly one tab stop, even with nothing active */
if (!tabbed && rows.length) {
rows[0].tabIndex = 0;
state.rowIdx = Number(rows[0].getAttribute("data-i"));
}
if (k < 0) return;
if (opts.scrollText) {
var mk = inner.querySelector('mark[data-i="' + k + '"]');
if (mk) {
var top = mk.offsetTop - 12;
var target = top - (ta.clientHeight / 2) + mk.offsetHeight / 2;
ta.scrollTop = Math.max(0, target);
syncScroll();
mk.classList.add("flash");
setTimeout(function () { mk.classList.remove("flash"); }, 1100);
}
}
if (opts.scrollRow) {
var row = mlist.querySelector('.mrow[data-i="' + k + '"]');
if (row && row.scrollIntoView) row.scrollIntoView({ block: "nearest" });
}
}
/* =====================================================================
tooltip on highlights
===================================================================== */
function showTip(mark) {
var k = Number(mark.getAttribute("data-i"));
var m = state.matches[k];
if (!m) return;
var html = '<div class="th">MATCH #' + (k + 1) + " · chars " + m.start + "–" + m.end + "</div>" +
'<div class="tv">' + (m.text === "" ? "<em>empty match</em>" : esc(m.text)) + "</div>";
if (m.groups.length) {
html += '<div class="tg">';
for (var g = 0; g < m.groups.length; g++) {
var gr = m.groups[g];
html += "<div><b>" + esc(gr.name ? gr.name : "group " + gr.n) + "</b> " +
(gr.v === undefined ? '<span class="none">did not match</span>'
: gr.v === "" ? '<span class="none">empty</span>' : esc(gr.v)) + "</div>";
}
html += "</div>";
} else {
html += '<div class="tg"><div><span class="none">no capture groups in this pattern</span></div></div>';
}
tip.innerHTML = html;
tip.hidden = false;
var er = editor.getBoundingClientRect(), mr = mark.getBoundingClientRect();
var tw = tip.offsetWidth, th = tip.offsetHeight;
var left = mr.left - er.left;
if (left + tw > er.width - 6) left = er.width - tw - 6;
if (left < 6) left = 6;
var top = mr.top - er.top - th - 8;
if (top < 4) top = mr.bottom - er.top + 8;
tip.style.left = left + "px";
tip.style.top = top + "px";
}
function hideTip() { tip.hidden = true; }
/* =====================================================================
flags
===================================================================== */
var FLAG_ORDER = "dgimsuvy";
function syncFlagButtons() {
var v = flagsIn.value;
var btns = document.querySelectorAll("[data-flag]");
for (var i = 0; i < btns.length; i++) {
btns[i].setAttribute("aria-pressed", String(v.indexOf(btns[i].getAttribute("data-flag")) > -1));
}
}
function toggleFlag(f) {
var v = flagsIn.value;
var has = v.indexOf(f) > -1;
var set = {};
for (var i = 0; i < v.length; i++) set[v.charAt(i)] = true;
if (has) delete set[f]; else set[f] = true;
var out = "";
for (var j = 0; j < FLAG_ORDER.length; j++) {
if (set[FLAG_ORDER.charAt(j)]) out += FLAG_ORDER.charAt(j);
}
for (var q in set) { if (FLAG_ORDER.indexOf(q) === -1) out += q; }
flagsIn.value = out;
run();
}
/* =====================================================================
events
===================================================================== */
pattern.addEventListener("input", schedule);
flagsIn.addEventListener("input", schedule);
ta.addEventListener("input", function () { hideTip(); schedule(); });
ta.addEventListener("scroll", function () { syncScroll(); hideTip(); });
window.addEventListener("resize", function () { syncGutter(); hideTip(); });
ta.addEventListener("focus", function () { editor.classList.add("focused"); });
ta.addEventListener("blur", function () { editor.classList.remove("focused"); });
if (window.ResizeObserver) {
new ResizeObserver(function () { syncGutter(); }).observe(ta);
}
/* caret inside a match selects it */
function caretMatch() {
var pos = ta.selectionStart;
for (var k = 0; k < state.matches.length; k++) {
var m = state.matches[k];
if (pos >= m.start && pos <= m.end) return k;
}
return -1;
}
["click", "keyup"].forEach(function (ev) {
ta.addEventListener(ev, function () {
var k = caretMatch();
if (k !== state.active) setActive(k, { scrollRow: true });
});
});
/* highlight interactions */
inner.addEventListener("mouseover", function (e) {
var mk = e.target.closest ? e.target.closest("mark") : null;
if (mk) showTip(mk);
});
inner.addEventListener("mouseout", function (e) {
var mk = e.target.closest ? e.target.closest("mark") : null;
if (mk) hideTip();
});
inner.addEventListener("mousedown", function (e) {
var mk = e.target.closest ? e.target.closest("mark") : null;
if (!mk) return;
e.preventDefault();
var k = Number(mk.getAttribute("data-i"));
var m = state.matches[k];
ta.focus();
if (m) ta.setSelectionRange(m.start, m.end);
setActive(k, { scrollRow: true });
showTip(mk);
});
document.addEventListener("mousedown", function (e) {
if (!e.target.closest || !e.target.closest("mark")) hideTip();
});
/* match list */
mlist.addEventListener("click", function (e) {
var row = e.target.closest ? e.target.closest(".mrow") : null;
if (!row) return;
setActive(Number(row.getAttribute("data-i")), { scrollText: true });
row.focus();
});
mlist.addEventListener("keydown", function (e) {
var row = e.target.closest ? e.target.closest(".mrow") : null;
if (!row) return;
var rows = mlist.querySelectorAll(".mrow");
var i = Number(row.getAttribute("data-i")), next = null;
if (e.key === "ArrowDown") next = i + 1;
else if (e.key === "ArrowUp") next = i - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = rows.length - 1;
else return;
if (next < 0 || next >= rows.length) return;
e.preventDefault();
setActive(next, { scrollText: true });
var el = mlist.querySelector('.mrow[data-i="' + next + '"]');
if (el) { el.focus(); if (el.scrollIntoView) el.scrollIntoView({ block: "nearest" }); }
});
/* ribbon <-> breakdown cross-highlight */
function markToken(k) {
var chips = ribbon.querySelectorAll(".chip");
for (var i = 0; i < chips.length; i++) {
chips[i].classList.toggle("on", Number(chips[i].getAttribute("data-t")) === k);
}
var rows = bk.querySelectorAll("li[data-t]");
for (var j = 0; j < rows.length; j++) {
rows[j].classList.toggle("on", Number(rows[j].getAttribute("data-t")) === k);
}
}
function tokenHandlers(host, sel) {
host.addEventListener("mouseover", function (e) {
var el = e.target.closest ? e.target.closest(sel) : null;
if (el) markToken(Number(el.getAttribute("data-t")));
});
host.addEventListener("mouseleave", function () {
var f = document.activeElement;
var held = f && f.classList && f.classList.contains("chip")
? Number(f.getAttribute("data-t")) : -1;
markToken(held);
});
}
tokenHandlers(ribbon, ".chip");
tokenHandlers(bk, "li[data-t]");
function focusChip(k) {
state.chipIdx = k;
var chips = ribbon.querySelectorAll(".chip");
for (var i = 0; i < chips.length; i++) {
chips[i].tabIndex = Number(chips[i].getAttribute("data-t")) === k ? 0 : -1;
}
var el = ribbon.querySelector('.chip[data-t="' + k + '"]');
if (el) el.focus();
}
ribbon.addEventListener("focusin", function (e) {
var chip = e.target.closest ? e.target.closest(".chip") : null;
if (!chip) return;
var k = Number(chip.getAttribute("data-t"));
state.chipIdx = k;
for (var i = 0, cs = ribbon.querySelectorAll(".chip"); i < cs.length; i++) {
cs[i].tabIndex = Number(cs[i].getAttribute("data-t")) === k ? 0 : -1;
}
markToken(k);
var row = bk.querySelector('li[data-t="' + k + '"]');
if (row && row.scrollIntoView) row.scrollIntoView({ block: "nearest" });
});
ribbon.addEventListener("focusout", function () { markToken(-1); });
ribbon.addEventListener("click", function (e) {
var hit = e.target.closest ? e.target.closest(".chip") : null;
if (hit) focusChip(Number(hit.getAttribute("data-t")));
});
ribbon.addEventListener("keydown", function (e) {
var chip = e.target.closest ? e.target.closest(".chip") : null;
if (!chip) return;
var chips = ribbon.querySelectorAll(".chip");
var i = Number(chip.getAttribute("data-t")), next = null;
if (e.key === "ArrowRight") next = i + 1;
else if (e.key === "ArrowLeft") next = i - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = chips.length - 1;
else return;
if (next < 0 || next >= chips.length) return;
e.preventDefault();
focusChip(next);
});
/* flags */
$("flagbar").addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("[data-flag]") : null;
if (b) toggleFlag(b.getAttribute("data-flag"));
});
/* presets */
var loadedPreset = "email";
document.querySelectorAll("[data-preset]").forEach(function (b) {
b.addEventListener("click", function () {
var key = b.getAttribute("data-preset"), p = PRESETS[key];
if (!p) return;
loadedPreset = key;
pattern.value = p.pattern;
flagsIn.value = p.flags;
ta.value = p.text;
replaceIn.value = p.replace;
state.active = -1;
state.rowIdx = 0;
ta.scrollTop = 0;
hideTip();
run();
});
});
/* "reset text" restores the sample that came with whichever example is loaded,
even if the pattern has since been edited by hand. */
$("btnReset").addEventListener("click", function () {
ta.value = PRESETS[loadedPreset].text;
state.active = -1;
state.rowIdx = 0;
ta.scrollTop = 0;
hideTip();
run();
});
/* copy */
var copyBtn = $("btnCopy");
copyBtn.addEventListener("click", function () {
var txt = "/" + pattern.value + "/" + flagsIn.value;
function done(ok) {
copyBtn.textContent = ok ? "copied" : "press ⌘/Ctrl+C";
live.textContent = ok ? "Pattern copied to the clipboard." : "Copying was blocked — press Command or Control C.";
if (ok) copyBtn.style.color = "var(--run)";
setTimeout(function () {
copyBtn.textContent = "copy";
copyBtn.style.color = "";
}, 1400);
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(function () { done(true); }, function () { done(fallback(txt)); });
return;
}
} catch (e) { /* fall through */ }
done(fallback(txt));
});
function fallback(txt) {
try {
var tmp = document.createElement("textarea");
tmp.value = txt;
tmp.setAttribute("readonly", "");
tmp.style.position = "absolute";
tmp.style.left = "-9999px";
document.body.appendChild(tmp);
tmp.select();
var ok = document.execCommand("copy");
document.body.removeChild(tmp);
return ok;
} catch (e) { return false; }
}
/* replace */
replaceIn.addEventListener("input", renderReplace);
$("repwrap").addEventListener("toggle", renderReplace);
/* platform theme */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* boot */
pattern.value = PRESETS.email.pattern;
flagsIn.value = PRESETS.email.flags;
ta.value = PRESETS.email.text;
replaceIn.value = PRESETS.email.replace;
run();
setActive(0, {});
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.