{ "name": "regex-tester", "type": "registry:ui", "title": "Regex Tester", "description": "A pattern against a test string, with matches highlighted and capture groups listed. Zero-length matches are guarded — `/a*/g` matches empty at every position and hangs a naive exec loop.", "category": "Code Tools", "dependencies": [ "lucide-react@^1.39.0" ], "registryDependencies": [ "badge", "input", "label", "lib-motion", "lib-styles", "lib-utils", "textarea" ], "files": [ { "path": "components/ui/regex-tester.tsx", "type": "registry:ui", "content": "'use client'\n\nimport { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Textarea } from '@/components/ui/textarea'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A regular expression against a test string, with matches highlighted.\n *\n * The pattern is compiled in a try/catch and the error is shown as you type. An\n * unfinished pattern is the normal state while typing, so a syntax error is a\n * hint, not a failure — the previous good match set is not thrown away.\n *\n * Zero-length matches are guarded. `/a*\\/g` matches empty at every position and\n * a naive `exec` loop over it never terminates; the index is advanced manually\n * when a match consumes nothing. This is the bug in most hand-rolled regex\n * testers.\n *\n * Named and numbered capture groups are listed per match. A pattern that\n * matches but captures the wrong thing is the failure people actually hit, and\n * a highlight alone cannot show it.\n */\nexport type RegexMatch = {\n index: number\n text: string\n groups: { name: string; value: string | undefined }[]\n}\n\nconst FLAG_SET = ['g', 'i', 'm', 's', 'u', 'y'] as const\n\nfunction RegexTester({\n pattern: patternProp,\n onPatternChange,\n flags: flagsProp,\n onFlagsChange,\n input: inputProp,\n onInputChange,\n patternLabel = 'Pattern',\n flagsLabel = 'Flags',\n inputLabel = 'Test string',\n matchesLabel = 'Matches',\n noMatchLabel = 'No matches',\n groupsLabel = 'Groups',\n className,\n ...props\n}: Omit, 'onChange' | 'children'> & {\n pattern?: string\n onPatternChange?: (pattern: string) => void\n flags?: string\n onFlagsChange?: (flags: string) => void\n input?: string\n onInputChange?: (input: string) => void\n patternLabel?: ReactNode\n flagsLabel?: ReactNode\n inputLabel?: ReactNode\n matchesLabel?: ReactNode\n noMatchLabel?: ReactNode\n groupsLabel?: ReactNode\n}) {\n const [ownPattern, setOwnPattern] = useState(patternProp ?? '')\n const [ownFlags, setOwnFlags] = useState(flagsProp ?? 'g')\n const [ownInput, setOwnInput] = useState(inputProp ?? '')\n\n const pattern = patternProp ?? ownPattern\n const flags = flagsProp ?? ownFlags\n const input = inputProp ?? ownInput\n\n const { matches, error } = useMemo(() => {\n if (!pattern) return { matches: [] as RegexMatch[], error: undefined }\n let regex: RegExp\n try {\n regex = new RegExp(pattern, flags.includes('g') ? flags : `${flags}g`)\n } catch (exception) {\n return { matches: [] as RegexMatch[], error: (exception as Error).message }\n }\n\n const found: RegexMatch[] = []\n let hit: RegExpExecArray | null\n let guard = 0\n while ((hit = regex.exec(input)) && guard++ < 5000) {\n found.push({\n index: hit.index,\n text: hit[0],\n groups: [\n ...hit.slice(1).map((value, i) => ({ name: String(i + 1), value })),\n ...Object.entries(hit.groups ?? {}).map(([name, value]) => ({ name, value })),\n ],\n })\n // A zero-length match leaves lastIndex where it was; without this the\n // loop never terminates.\n if (hit[0] === '') regex.lastIndex++\n }\n return { matches: found, error: undefined }\n }, [pattern, flags, input])\n\n /** Splits the input into plain and matched runs for highlighting. */\n const segments = useMemo(() => {\n const out: { text: string; hit: boolean }[] = []\n let cursor = 0\n for (const match of matches) {\n if (match.index > cursor) out.push({ text: input.slice(cursor, match.index), hit: false })\n out.push({ text: match.text, hit: true })\n cursor = match.index + match.text.length\n }\n if (cursor < input.length) out.push({ text: input.slice(cursor), hit: false })\n return out\n }, [matches, input])\n\n const setPattern = (next: string) => {\n if (patternProp === undefined) setOwnPattern(next)\n onPatternChange?.(next)\n }\n const setFlags = (next: string) => {\n if (flagsProp === undefined) setOwnFlags(next)\n onFlagsChange?.(next)\n }\n const setInput = (next: string) => {\n if (inputProp === undefined) setOwnInput(next)\n onInputChange?.(next)\n }\n\n return (\n \n
\n
\n \n setPattern(event.target.value)}\n />\n
\n
\n \n \n setFlags([...new Set(event.target.value.split(''))]\n .filter((f) => (FLAG_SET as readonly string[]).includes(f))\n .join(''))\n }\n />\n
\n
\n\n {error && (\n

\n \n {error}\n

\n )}\n\n
\n \n setInput(event.target.value)}\n />\n
\n\n
\n {segments.length === 0 ? (\n {noMatchLabel}\n ) : (\n segments.map((segment, index) =>\n segment.hit ? (\n \n {segment.text}\n \n ) : (\n {segment.text}\n ),\n )\n )}\n
\n\n
\n \n {matchesLabel}\n {matches.length}\n \n\n {matches.length > 0 && (\n
    \n {matches.slice(0, 20).map((match, index) => (\n
  • \n \n {match.index}\n \n {match.text || '∅'}\n {/* A pattern that matches but captures the wrong thing is the\n failure people actually hit; a highlight cannot show it. */}\n {match.groups.length > 0 && (\n \n {groupsLabel}:\n {match.groups.map((group) => (\n \n {group.name}={group.value === undefined ? '—' : `\"${group.value}\"`}\n \n ))}\n \n )}\n
  • \n ))}\n
\n )}\n
\n \n )\n}\n\nexport { RegexTester }\n" } ] }