Display

Regex Tester

A regex workbench: flag toggles, matches tinted in place, a group table, the engine's own error text, and a worker-backed budget that survives catastrophic backtracking.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CircleAlert, TimerOff, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"

/** The flags the toolbar offers. `d` / `v` still work if a consumer passes them in `flags`. */
export type RegexFlag = "g" | "i" | "m" | "s" | "u" | "y"

export interface RegexFlagDescriptor {
  flag: RegexFlag
  /** The property the engine exposes it under. */
  name: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/regex-tester.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "RegexTester" component (lucide-react icons,
shadcn Label, no other dependency). Everything runs in the browser; nothing is sent
anywhere.

Contract
- Export a forwardRef <div> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">.
- Three independently controllable values, each with a controlled prop, a
  default* prop and a change callback:
    pattern / defaultPattern / onPatternChange  — the source, without delimiters
    flags   / defaultFlags   / onFlagsChange    — a flag string such as "gim"
    value   / defaultValue   / onValueChange    — the test string
- availableFlags?: readonly ("g"|"i"|"m"|"s"|"u"|"y")[] (default all six). An empty
  array hides the toolbar; flags outside the list (d, v) are still honoured if they
  arrive in `flags`, they simply get no toggle.
- timeBudgetMs?: number (default 60) — how long one run may take before it is killed.
- maxMatches?: number (default 200) — how many matches one run may collect.
- rows?: number (default 6), label?: string, className.
- onResultChange?: (result: RegexEvaluation) => void fires after every completed
  run, refusals included.
- Also export the type RegexEvaluation
  { status: "idle" | "ok" | "invalid" | "limit" | "timeout";
    matches: { index: number; value: string;
               groups: { label: string; value: string | null }[] }[];
    message: string | null; guard: "worker" | "inline" }
  and the REGEX_FLAGS table (flag, engine property name, one-line hint).

Behavior — the guard (this is the point of the component)
- A regular expression that backtracks catastrophically cannot be interrupted from
  the inside: there is no callback, no yield point, no abort signal. The only real
  cancel is a thread you are allowed to kill. So the scan runs in a worker built
  from a Blob of its own source (new Worker(URL.createObjectURL(blob))), and the
  main thread arms setTimeout(timeBudgetMs) next to it.
- Answer first: clear the timer, commit the result. Timer first: terminate() the
  worker mid-exec, report status "timeout" with a message that names the budget and
  explains what nested quantifiers cost. The next run builds a fresh worker.
- One run at a time. Starting a run while another is still out means the previous
  one may never answer, and its corpse would block every message queued behind it —
  so terminate before posting. Every request carries an incrementing job id and
  every answer is matched against it; a late answer to an abandoned pattern is
  dropped, never rendered.
- Compile once per change, not once per match: build the RegExp at the top of the
  run and reuse it for the whole scan.
- If a worker cannot be created (a strict CSP blocks blob: workers) or its thread
  errors, fall back to running the same function on the main thread, mark the result
  guard: "inline", and say so under the summary. That fallback has no hard cancel —
  be honest about it rather than pretending the guard is still armed.
- The scan function is stringified into the worker source, so it must close over
  nothing in the module and must survive minification: no imports, no module
  constants, everything through the request object.

Behavior — the scan
- Two compiles: the user's exact flags first, so an illegal flag is reported as an
  illegal flag; then the scanning clone. Walking a string needs "g", so the clone
  adds it when the user did not — without it exec would return the same match
  forever. Whether the user asked for "g" decides the outcome: without it, keep
  exactly one match, because that is what their code will get.
- Any compile failure returns the caught error's message verbatim. Wording differs
  per engine — quote it, never paraphrase it, and never render a highlight from a
  previous pattern next to it.
- Zero-length matches (lookarounds, \b, an empty alternative) leave lastIndex where
  it is. Step over exactly one code point — two units for an astral character under
  /u — or the loop never ends, and stepping a whole character would skip a position.
- Stop at maxMatches with status "limit" and say the rest was not scanned. Silently
  truncating is a lie; scanning a million matches is a freeze.
- Per match, collect the numbered captures (a group that never participated is null,
  distinct from a group that matched the empty string) and whatever the engine named.
- Group labels: walk the pattern source counting real capturing parens — skip
  escapes, let a character class swallow its contents, ignore (?: (?= (?! (?<= (?<!
  and inline modifiers — so the table can print "2 code" instead of "2". If that
  count disagrees with the engine's, fall back to numbers plus the engine's own
  named groups instead of mislabelling anything.

Behavior — highlighting
- The test string is a real <textarea>. A mirror div sits underneath it, absolutely
  positioned, clipped, holding the same text with transparent colour and one <mark>
  per match; the textarea keeps its own text and paints on top. Both layers share
  one font, one padding, one line-height, whitespace: pre-wrap, overflow-wrap:
  break-word and scrollbar-gutter: stable — a scrollbar on one layer only would wrap
  the two at different columns.
- Pin the mirror with transform: translateY(-textarea.scrollTop) on the textarea's
  scroll event, throttled through one requestAnimationFrame, and re-pin after any
  change that moves the text without emitting a scroll event.
- Tints cycle through var(--chart-1..5), so adjacent matches never share one and
  their edges stay readable. box-decoration-break: clone keeps a wrapped match
  looking like one band per line.
- A zero-length match owns no characters, so it renders as a 2px caret inside a
  position: relative inline span. Do not use inline-block: an atomic inline adds a
  line-break opportunity the textarea does not have, and the two layers drift.
- Results arrive asynchronously, so for one frame the offsets can belong to the
  previous keystroke. Clamp every slice into the current string and keep the old
  bands rather than blanking them — highlights that blink off on every keystroke are
  worse than highlights that lag one frame.

Behavior — keyboard and ARIA
- Flags are a role="toolbar" of aria-pressed toggle buttons with a roving tabindex:
  one Tab stop for the whole group, ArrowLeft/ArrowRight wrap, Home/End jump to the
  ends, and focus moves the tab stop with it.
- The match list is a role="grid" whose rows are the focus unit: aria-selected,
  a single roving tab stop, ArrowUp/ArrowDown between rows, Home/End to the ends,
  and selection follows focus. Clicking a row focuses it. The selected match tints
  harder in the mirror and is scrolled into view by writing the textarea's own
  scrollTop — never scrollIntoView, which drags the whole page.
- Rows can vanish under a focused user when the matches change. Track whether the
  grid owns focus, and after each result, if focus has been dropped on <body>, move
  it to the nearest surviving row, or to the test-string field when nothing survives.
  Focus must never be left on <body>.
- The pattern field takes aria-invalid and aria-describedby pointing at the notice
  when a run is refused. One role="status" aria-live="polite" line carries the
  summary ("4 matches in 231 characters", "Invalid pattern.", "Stopped — the pattern
  is too slow on this input.").
- Control characters are printed as glyphs (↵ ␍ ⇥) in the table, and an empty match
  reads "∅ empty" — a blank cell looks like a bug.

Behavior — cleanup
- On unmount and before every new run: clear the pending timeout, terminate the
  worker, revoke the object URL, cancel the pending animation frame. A leaked worker
  is a leaked thread.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel with border, muted
  labels and hints, bg-primary + text-primary-foreground for a flag that is on,
  border-destructive + text-destructive for a refusal, bg-accent for the selected
  row, ring-ring on focus-visible, var(--chart-1..5) for the match tints (mixed with
  color-mix so text stays readable through them).
- Monospace at one size for the pattern, the test string and the flag chips; the
  pattern sits between two slash glyphs with the live flag string after it, so the
  field reads as the literal it is.
- transition-colors on the tints, chips and rows, every one paired with
  motion-reduce:transition-none. Nothing in the result depends on a transition.
- Merge the consumer className with cn(); the panel is fluid and min-w-0, and the
  match grid scrolls inside a max-height instead of stretching the card.

Customization levers
- Density: drop the flag hint line and the Range column for a toolbar-sized panel,
  or raise rows and the grid's max-height for a full-page tool.
- Budget: timeBudgetMs and maxMatches are the two knobs that decide how patient the
  tool is. A docs page can afford 20ms; an internal rule editor over big payloads
  might want 500ms and 5000 matches.
- Flags: availableFlags trims the toolbar to the flags your target engine supports —
  pass ["g","i","m"] for a backend that speaks RE2, and the panel stops advertising
  what will not survive the round trip.
- Palette: swap MATCH_TONES for two alternating tokens if the chart ramp is loud, or
  key the tone off a group index to colour by capture instead of by match.
- Extras worth adding on the same skeleton: a replacement field previewing
  String.replace output, a "copy /pattern/flags" button, an explain-this-pattern
  column, or a readOnly mode (fields readOnly, flag chips aria-disabled plus a
  handler guard — never the native disabled attribute) for embedding a fixed example
  in documentation.

Concepts

  • Kill switch, not a stopwatch — a catastrophic regex has no yield point, so measuring how long it has been running is useless; the only honest guard is running it on a thread you are allowed to terminate, which is why the scan lives in a worker built from a Blob of its own source.
  • Job ids instead of cancellation — a worker answer that arrives after the pattern moved on is worse than no answer, so every request carries an id and every answer is matched against the current one before it is allowed to render.
  • The g-flagged clone — scanning always adds g because exec without it never advances, while the user's own g decides whether to keep one match or all of them; a zero-length match steps exactly one code point, which is what makes lookaheads and \b terminate instead of spinning.
  • Mirror, not a re-render of the text — the tints are a transparent copy of the string sitting under a live textarea, pinned by transform to the textarea's own scrollTop, so typing, selecting and undo all stay native while the bands follow along.
  • Refusals carry their evidence — an illegal pattern shows the engine's own SyntaxError, a capped scan says how many it stopped at, and a killed run names the budget it blew; none of them leave the previous highlight on screen pretending to still be true.
  • Selection follows focus — the match grid is one Tab stop with arrow-key rows, and the focused row, the aria-selected state and the darkened band in the text are the same fact rendered three ways.

On This Page