From 568ad79c0f6abcfec609962497bff2f33562ed36 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:19:35 +0000 Subject: [PATCH 1/6] feat: enhance regex tester with interactive playground features - Add toggleable pattern flags (g, i, m, s, u, y) with tooltips - Add preset regex patterns (email, URL, phone, date, IP, hex color, HTML tag, credit card) - Add real-time pattern explanation with color-coded components - Add capture group visualization with CSS box model style - Add match statistics dashboard (total matches, unique, avg length, exec time) - Add collapsible regex cheat sheet with click-to-insert functionality - Enhance RegexHighlightText with hover effects and match info tooltips - Add Tooltip component via shadcn/ui Co-Authored-By: petar@jam.dev --- components/RegexHighlightText.tsx | 46 +- .../regex/RegexCaptureGroupVisualizer.tsx | 161 +++++++ components/regex/RegexCheatSheet.tsx | 92 ++++ components/regex/RegexFlagToggle.tsx | 49 ++ components/regex/RegexMatchStats.tsx | 69 +++ components/regex/RegexPatternExplainer.tsx | 90 ++++ components/regex/RegexPresetPatterns.tsx | 30 ++ components/ui/tooltip.tsx | 28 ++ components/utils/regex-tester.utils.ts | 398 ++++++++++++++++ package-lock.json | 448 ++++++++++++++++++ package.json | 1 + pages/utilities/regex-tester.tsx | 173 ++++++- 12 files changed, 1558 insertions(+), 27 deletions(-) create mode 100644 components/regex/RegexCaptureGroupVisualizer.tsx create mode 100644 components/regex/RegexCheatSheet.tsx create mode 100644 components/regex/RegexFlagToggle.tsx create mode 100644 components/regex/RegexMatchStats.tsx create mode 100644 components/regex/RegexPatternExplainer.tsx create mode 100644 components/regex/RegexPresetPatterns.tsx create mode 100644 components/ui/tooltip.tsx diff --git a/components/RegexHighlightText.tsx b/components/RegexHighlightText.tsx index 89e1a7d..42908c5 100644 --- a/components/RegexHighlightText.tsx +++ b/components/RegexHighlightText.tsx @@ -1,3 +1,10 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + interface RegexHighlightTextProps { text: string; matches: string[]; @@ -16,6 +23,7 @@ export default function RegexHighlightText(props: RegexHighlightTextProps) { ); let lastIndex = 0; + let matchNumber = 0; props.matches.forEach((match, index) => { const offset = props.text.indexOf(match, lastIndex); @@ -28,10 +36,36 @@ export default function RegexHighlightText(props: RegexHighlightTextProps) { ); } + matchNumber++; + const currentMatchNumber = matchNumber; + const matchLength = match.length; + const startPos = offset; + const endPos = offset + matchLength; + parts.push( - - {match === "\n" ? newLine : match} - + + + + {match === "\n" ? newLine : match} + + + +
+

Match #{currentMatchNumber}

+

+ Position: {startPos} - {endPos} +

+

+ Length: {matchLength} character{matchLength !== 1 ? "s" : ""} +

+ {match.length <= 50 && ( +

+ "{match === "\n" ? "\\n" : match}" +

+ )} +
+
+
); lastIndex = offset + match.length; @@ -45,5 +79,9 @@ export default function RegexHighlightText(props: RegexHighlightTextProps) { ); } - return
{parts}
; + return ( + +
{parts}
+
+ ); } diff --git a/components/regex/RegexCaptureGroupVisualizer.tsx b/components/regex/RegexCaptureGroupVisualizer.tsx new file mode 100644 index 0000000..269ea28 --- /dev/null +++ b/components/regex/RegexCaptureGroupVisualizer.tsx @@ -0,0 +1,161 @@ +import { useMemo } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { getDetailedMatches, RegexMatch } from "@/components/utils/regex-tester.utils"; +import { cn } from "@/lib/utils"; + +interface RegexCaptureGroupVisualizerProps { + pattern: string; + testString: string; +} + +const GROUP_COLORS = [ + "border-blue-400 bg-blue-50 dark:bg-blue-950/30", + "border-green-400 bg-green-50 dark:bg-green-950/30", + "border-purple-400 bg-purple-50 dark:bg-purple-950/30", + "border-orange-400 bg-orange-50 dark:bg-orange-950/30", + "border-pink-400 bg-pink-50 dark:bg-pink-950/30", + "border-cyan-400 bg-cyan-50 dark:bg-cyan-950/30", +]; + +export default function RegexCaptureGroupVisualizer({ + pattern, + testString, +}: RegexCaptureGroupVisualizerProps) { + const matches: RegexMatch[] = useMemo(() => { + if (!pattern || !testString) return []; + try { + return getDetailedMatches(pattern, testString); + } catch { + return []; + } + }, [pattern, testString]); + + if (matches.length === 0) { + return ( +
+ No capture groups found. Add groups with parentheses () to see them here. +
+ ); + } + + const hasGroups = matches.some( + (m) => m.captureGroups.length > 0 || Object.keys(m.groups).length > 0 + ); + + if (!hasGroups) { + return ( +
+ No capture groups in pattern. Use parentheses () to create capture groups. +
+ ); + } + + return ( + +
+ {matches.map((match, matchIndex) => ( +
+
+ Match {matchIndex + 1} at position {match.index} +
+
+
Full Match
+
+ {match.fullMatch || empty} +
+ + {match.captureGroups.length > 0 && ( +
+
Capture Groups (CSS Box Model)
+
+ {match.captureGroups.map((group, groupIndex) => ( + + +
0 ? "8px" : "0", + }} + > +
+ + Group {groupIndex + 1} + + + {group ?? undefined} + +
+
+
+ +

Capture Group {groupIndex + 1}

+

+ Captured value: {group ?? "undefined"} +

+

+ Access via: match[{groupIndex + 1}] or $ + {groupIndex + 1} +

+
+
+ ))} +
+
+ )} + + {Object.keys(match.groups).length > 0 && ( +
+
Named Groups
+
+ {Object.entries(match.groups).map(([name, value], index) => ( + + +
+
{name}
+
+ {value ?? undefined} +
+
+
+ +

Named Group: {name}

+

+ Captured value: {value ?? "undefined"} +

+

+ Access via: match.groups.{name} +

+
+
+ ))} +
+
+ )} +
+
+ ))} +
+
+ ); +} diff --git a/components/regex/RegexCheatSheet.tsx b/components/regex/RegexCheatSheet.tsx new file mode 100644 index 0000000..c256202 --- /dev/null +++ b/components/regex/RegexCheatSheet.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { CHEAT_SHEET } from "@/components/utils/regex-tester.utils"; +import { cn } from "@/lib/utils"; + +interface RegexCheatSheetProps { + onInsert?: (syntax: string) => void; +} + +export default function RegexCheatSheet({ onInsert }: RegexCheatSheetProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [expandedSections, setExpandedSections] = useState>(new Set(["Character Classes"])); + + const toggleSection = (section: string) => { + setExpandedSections((prev) => { + const next = new Set(prev); + if (next.has(section)) { + next.delete(section); + } else { + next.add(section); + } + return next; + }); + }; + + return ( +
+ + + {isExpanded && ( +
+ {Object.entries(CHEAT_SHEET).map(([category, items]) => ( +
+ + + {expandedSections.has(category) && ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+ ))} + {onInsert && ( +

+ Click any syntax to insert it into your pattern +

+ )} +
+ )} +
+ ); +} diff --git a/components/regex/RegexFlagToggle.tsx b/components/regex/RegexFlagToggle.tsx new file mode 100644 index 0000000..8e0f8f3 --- /dev/null +++ b/components/regex/RegexFlagToggle.tsx @@ -0,0 +1,49 @@ +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { RegexFlags, FLAG_DESCRIPTIONS } from "@/components/utils/regex-tester.utils"; + +interface RegexFlagToggleProps { + flags: RegexFlags; + onFlagChange: (flag: keyof RegexFlags) => void; +} + +export default function RegexFlagToggle({ flags, onFlagChange }: RegexFlagToggleProps) { + const flagKeys = Object.keys(flags) as (keyof RegexFlags)[]; + + return ( + +
+ {flagKeys.map((flag) => ( + + + + + +

{FLAG_DESCRIPTIONS[flag].name}

+

+ {FLAG_DESCRIPTIONS[flag].description} +

+
+
+ ))} +
+
+ ); +} diff --git a/components/regex/RegexMatchStats.tsx b/components/regex/RegexMatchStats.tsx new file mode 100644 index 0000000..cbee80d --- /dev/null +++ b/components/regex/RegexMatchStats.tsx @@ -0,0 +1,69 @@ +import { useMemo } from "react"; +import { getMatchStats, MatchStats } from "@/components/utils/regex-tester.utils"; + +interface RegexMatchStatsProps { + pattern: string; + testString: string; +} + +export default function RegexMatchStats({ pattern, testString }: RegexMatchStatsProps) { + const stats: MatchStats | null = useMemo(() => { + if (!pattern || !testString) return null; + try { + return getMatchStats(pattern, testString); + } catch { + return null; + } + }, [pattern, testString]); + + if (!stats) { + return ( +
+ Enter pattern and test string to see statistics +
+ ); + } + + return ( +
+ 0} + /> + + + +
+ ); +} + +interface StatCardProps { + label: string; + value: string; + highlight?: boolean; +} + +function StatCard({ label, value, highlight }: StatCardProps) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/components/regex/RegexPatternExplainer.tsx b/components/regex/RegexPatternExplainer.tsx new file mode 100644 index 0000000..d998c72 --- /dev/null +++ b/components/regex/RegexPatternExplainer.tsx @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { explainPattern } from "@/components/utils/regex-tester.utils"; +import { cn } from "@/lib/utils"; + +interface RegexPatternExplainerProps { + pattern: string; +} + +const getComponentColor = (type: string): string => { + switch (type) { + case "escape": + return "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300"; + case "characterClass": + return "bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300"; + case "groupStart": + case "groupEnd": + return "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300"; + case "quantifier": + return "bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"; + case "special": + return "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300"; + default: + return "bg-muted text-foreground"; + } +}; + +export default function RegexPatternExplainer({ pattern }: RegexPatternExplainerProps) { + const components = useMemo(() => { + if (!pattern) return []; + try { + return explainPattern(pattern); + } catch { + return []; + } + }, [pattern]); + + if (components.length === 0) { + return ( +
+ Enter a pattern to see explanation +
+ ); + } + + return ( + +
+
+ {components.map((component, index) => ( + + + + {component.value} + + + +

{component.type}

+

+ {component.explanation} +

+
+
+ ))} +
+
+
Legend:
+
+ Escape + Character Class + Group + Quantifier + Special + Literal +
+
+
+
+ ); +} diff --git a/components/regex/RegexPresetPatterns.tsx b/components/regex/RegexPresetPatterns.tsx new file mode 100644 index 0000000..ada53b1 --- /dev/null +++ b/components/regex/RegexPresetPatterns.tsx @@ -0,0 +1,30 @@ +import { PRESET_PATTERNS } from "@/components/utils/regex-tester.utils"; +import { cn } from "@/lib/utils"; + +interface RegexPresetPatternsProps { + onSelect: (pattern: string, testString: string) => void; + selectedPattern: string; +} + +export default function RegexPresetPatterns({ onSelect, selectedPattern }: RegexPresetPatternsProps) { + return ( +
+ {PRESET_PATTERNS.map((preset) => ( + + ))} +
+ ); +} diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..60d9678 --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/components/utils/regex-tester.utils.ts b/components/utils/regex-tester.utils.ts index ef73a5f..3b9cdab 100644 --- a/components/utils/regex-tester.utils.ts +++ b/components/utils/regex-tester.utils.ts @@ -1,3 +1,65 @@ +export interface RegexFlags { + g: boolean; + i: boolean; + m: boolean; + s: boolean; + u: boolean; + y: boolean; +} + +export interface CaptureGroup { + index: number; + name: string | null; + pattern: string; + start: number; + end: number; + content: string | null; + nested: CaptureGroup[]; +} + +export interface RegexMatch { + fullMatch: string; + index: number; + groups: { [key: string]: string | undefined }; + captureGroups: (string | undefined)[]; +} + +export interface PatternComponent { + type: string; + value: string; + explanation: string; + start: number; + end: number; +} + +export interface MatchStats { + totalMatches: number; + matchPositions: { start: number; end: number; text: string }[]; + averageMatchLength: number; + executionTime: number; + uniqueMatches: number; +} + +export const FLAG_DESCRIPTIONS: Record = { + g: { name: "Global", description: "Find all matches rather than stopping after the first match" }, + i: { name: "Case Insensitive", description: "Match letters regardless of case (a matches A)" }, + m: { name: "Multiline", description: "^ and $ match start/end of each line, not just the string" }, + s: { name: "DotAll", description: "Dot (.) matches newline characters as well" }, + u: { name: "Unicode", description: "Enable full Unicode support for the pattern" }, + y: { name: "Sticky", description: "Match only from the lastIndex position in the target string" }, +}; + +export const PRESET_PATTERNS = [ + { name: "Email", pattern: "/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/", testString: "test@example.com\ninvalid-email\nuser.name+tag@domain.co.uk" }, + { name: "URL", pattern: "/https?:\\/\\/[\\w\\-]+(\\.[\\w\\-]+)+[/#?]?.*/gi", testString: "Visit https://example.com/path?query=1\nOr http://sub.domain.org/page#section" }, + { name: "Phone (US)", pattern: "/\\(?\\d{3}\\)?[-.\\s]?\\d{3}[-.\\s]?\\d{4}/g", testString: "(555) 123-4567\n555.123.4567\n555-123-4567" }, + { name: "Date (YYYY-MM-DD)", pattern: "/\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])/g", testString: "2024-01-15\n2023-12-31\n2024-13-45 (invalid)" }, + { name: "IPv4 Address", pattern: "/\\b(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\b/g", testString: "192.168.1.1\n10.0.0.255\n256.1.1.1 (invalid)" }, + { name: "Hex Color", pattern: "/#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\\b/g", testString: "#FF5733\n#abc\n#GGGGGG (invalid)" }, + { name: "HTML Tag", pattern: "/<([a-z]+)([^<]+)*(?:>(.*)<\\/\\1>|\\s+\\/>)/gi", testString: "
Content
\n" }, + { name: "Credit Card", pattern: "/\\b(?:\\d{4}[- ]?){3}\\d{4}\\b/g", testString: "4111-1111-1111-1111\n4111 1111 1111 1111\n4111111111111111" }, +]; + export const createRegex = (pattern: string): RegExp => { if (typeof pattern !== "string" || pattern.trim() === "") { throw new Error("Pattern must be a non-empty string"); @@ -40,3 +102,339 @@ export const createRegex = (pattern: string): RegExp => { throw error; } }; + +export const parseFlags = (pattern: string): RegexFlags => { + const flags: RegexFlags = { g: false, i: false, m: false, s: false, u: false, y: false }; + + if (pattern.startsWith("/")) { + const lastSlashIndex = pattern.lastIndexOf("/"); + if (lastSlashIndex > 0) { + const flagStr = pattern.slice(lastSlashIndex + 1); + for (const flag of flagStr) { + if (flag in flags) { + flags[flag as keyof RegexFlags] = true; + } + } + } + } + + return flags; +}; + +export const buildPatternWithFlags = (patternBody: string, flags: RegexFlags): string => { + const flagStr = Object.entries(flags) + .filter(([, enabled]) => enabled) + .map(([flag]) => flag) + .join(""); + return `/${patternBody}/${flagStr}`; +}; + +export const getPatternBody = (pattern: string): string => { + if (pattern.startsWith("/")) { + const lastSlashIndex = pattern.lastIndexOf("/"); + if (lastSlashIndex > 0) { + return pattern.slice(1, lastSlashIndex); + } + } + return pattern; +}; + +export const findCaptureGroups = (pattern: string): CaptureGroup[] => { + const patternBody = getPatternBody(pattern); + const groups: CaptureGroup[] = []; + const stack: { start: number; name: string | null }[] = []; + let i = 0; + + while (i < patternBody.length) { + if (patternBody[i] === "\\") { + i += 2; + continue; + } + + if (patternBody[i] === "[") { + let j = i + 1; + while (j < patternBody.length && patternBody[j] !== "]") { + if (patternBody[j] === "\\") j++; + j++; + } + i = j + 1; + continue; + } + + if (patternBody[i] === "(") { + let name: string | null = null; + let isCapturing = true; + + if (patternBody.slice(i + 1, i + 3) === "?:") { + isCapturing = false; + } else if (patternBody.slice(i + 1, i + 4) === "?<" && patternBody[i + 4] !== "=" && patternBody[i + 4] !== "!") { + const nameEnd = patternBody.indexOf(">", i + 4); + if (nameEnd !== -1) { + name = patternBody.slice(i + 4, nameEnd); + } + } else if (patternBody[i + 1] === "?" && (patternBody[i + 2] === "=" || patternBody[i + 2] === "!" || patternBody[i + 2] === "<")) { + isCapturing = false; + } + + if (isCapturing) { + stack.push({ start: i, name }); + } else { + stack.push({ start: -1, name: null }); + } + } else if (patternBody[i] === ")") { + const groupInfo = stack.pop(); + if (groupInfo && groupInfo.start !== -1) { + const groupPattern = patternBody.slice(groupInfo.start, i + 1); + groups.push({ + index: groups.length + 1, + name: groupInfo.name, + pattern: groupPattern, + start: groupInfo.start, + end: i + 1, + content: null, + nested: [], + }); + } + } + + i++; + } + + return groups.sort((a, b) => a.start - b.start); +}; + +export const explainPattern = (pattern: string): PatternComponent[] => { + const patternBody = getPatternBody(pattern); + const components: PatternComponent[] = []; + let i = 0; + + const explanations: Record = { + "^": "Start of string/line", + "$": "End of string/line", + ".": "Any character (except newline)", + "*": "Zero or more of the preceding", + "+": "One or more of the preceding", + "?": "Zero or one of the preceding (optional)", + "\\d": "Any digit (0-9)", + "\\D": "Any non-digit", + "\\w": "Any word character (a-z, A-Z, 0-9, _)", + "\\W": "Any non-word character", + "\\s": "Any whitespace character", + "\\S": "Any non-whitespace character", + "\\b": "Word boundary", + "\\B": "Non-word boundary", + "\\n": "Newline", + "\\t": "Tab", + "\\r": "Carriage return", + "|": "Alternation (OR)", + }; + + while (i < patternBody.length) { + const char = patternBody[i]; + + if (char === "\\") { + const escaped = patternBody.slice(i, i + 2); + const explanation = explanations[escaped] || `Escaped character: ${patternBody[i + 1]}`; + components.push({ type: "escape", value: escaped, explanation, start: i, end: i + 2 }); + i += 2; + continue; + } + + if (char === "[") { + let j = i + 1; + const negated = patternBody[j] === "^"; + while (j < patternBody.length && patternBody[j] !== "]") { + if (patternBody[j] === "\\") j++; + j++; + } + const charClass = patternBody.slice(i, j + 1); + const explanation = negated + ? `Character class (NOT): matches any character NOT in ${charClass}` + : `Character class: matches any character in ${charClass}`; + components.push({ type: "characterClass", value: charClass, explanation, start: i, end: j + 1 }); + i = j + 1; + continue; + } + + if (char === "(") { + let groupType = "Capturing group"; + let skipChars = 1; + + if (patternBody.slice(i + 1, i + 3) === "?:") { + groupType = "Non-capturing group"; + skipChars = 3; + } else if (patternBody.slice(i + 1, i + 3) === "?=") { + groupType = "Positive lookahead"; + skipChars = 3; + } else if (patternBody.slice(i + 1, i + 3) === "?!") { + groupType = "Negative lookahead"; + skipChars = 3; + } else if (patternBody.slice(i + 1, i + 4) === "?<=") { + groupType = "Positive lookbehind"; + skipChars = 4; + } else if (patternBody.slice(i + 1, i + 4) === "?", i + 3); + if (nameEnd !== -1) { + const name = patternBody.slice(i + 3, nameEnd); + groupType = `Named capturing group: "${name}"`; + } + } + + components.push({ type: "groupStart", value: "(", explanation: groupType, start: i, end: i + skipChars }); + i += skipChars; + continue; + } + + if (char === ")") { + components.push({ type: "groupEnd", value: ")", explanation: "End of group", start: i, end: i + 1 }); + i++; + continue; + } + + if (char === "{") { + let j = i + 1; + while (j < patternBody.length && patternBody[j] !== "}") j++; + const quantifier = patternBody.slice(i, j + 1); + const inner = quantifier.slice(1, -1); + let explanation = ""; + + if (inner.includes(",")) { + const [min, max] = inner.split(","); + if (max === "") { + explanation = `${min} or more of the preceding`; + } else { + explanation = `Between ${min} and ${max} of the preceding`; + } + } else { + explanation = `Exactly ${inner} of the preceding`; + } + + components.push({ type: "quantifier", value: quantifier, explanation, start: i, end: j + 1 }); + i = j + 1; + continue; + } + + if (explanations[char]) { + components.push({ type: "special", value: char, explanation: explanations[char], start: i, end: i + 1 }); + } else { + components.push({ type: "literal", value: char, explanation: `Literal character: "${char}"`, start: i, end: i + 1 }); + } + i++; + } + + return components; +}; + +export const getMatchStats = (pattern: string, testString: string): MatchStats => { + const startTime = performance.now(); + const stats: MatchStats = { + totalMatches: 0, + matchPositions: [], + averageMatchLength: 0, + executionTime: 0, + uniqueMatches: 0, + }; + + try { + const regex = createRegex(pattern); + const globalRegex = new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : regex.flags + "g"); + + let match; + const uniqueSet = new Set(); + let totalLength = 0; + + while ((match = globalRegex.exec(testString)) !== null) { + stats.totalMatches++; + stats.matchPositions.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0], + }); + uniqueSet.add(match[0]); + totalLength += match[0].length; + + if (match[0].length === 0) { + globalRegex.lastIndex++; + } + } + + stats.uniqueMatches = uniqueSet.size; + stats.averageMatchLength = stats.totalMatches > 0 ? totalLength / stats.totalMatches : 0; + } catch { + // Pattern is invalid, return empty stats + } + + stats.executionTime = performance.now() - startTime; + return stats; +}; + +export const getDetailedMatches = (pattern: string, testString: string): RegexMatch[] => { + const matches: RegexMatch[] = []; + + try { + const regex = createRegex(pattern); + const globalRegex = new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : regex.flags + "g"); + + let match; + while ((match = globalRegex.exec(testString)) !== null) { + matches.push({ + fullMatch: match[0], + index: match.index, + groups: match.groups || {}, + captureGroups: match.slice(1), + }); + + if (match[0].length === 0) { + globalRegex.lastIndex++; + } + } + } catch { + // Pattern is invalid, return empty matches + } + + return matches; +}; + +export const CHEAT_SHEET = { + "Character Classes": [ + { syntax: ".", description: "Any character except newline" }, + { syntax: "\\w", description: "Word character [a-zA-Z0-9_]" }, + { syntax: "\\W", description: "Non-word character" }, + { syntax: "\\d", description: "Digit [0-9]" }, + { syntax: "\\D", description: "Non-digit" }, + { syntax: "\\s", description: "Whitespace" }, + { syntax: "\\S", description: "Non-whitespace" }, + { syntax: "[abc]", description: "Any of a, b, or c" }, + { syntax: "[^abc]", description: "Not a, b, or c" }, + { syntax: "[a-z]", description: "Character range a-z" }, + ], + "Anchors": [ + { syntax: "^", description: "Start of string/line" }, + { syntax: "$", description: "End of string/line" }, + { syntax: "\\b", description: "Word boundary" }, + { syntax: "\\B", description: "Non-word boundary" }, + ], + "Quantifiers": [ + { syntax: "*", description: "0 or more" }, + { syntax: "+", description: "1 or more" }, + { syntax: "?", description: "0 or 1 (optional)" }, + { syntax: "{n}", description: "Exactly n" }, + { syntax: "{n,}", description: "n or more" }, + { syntax: "{n,m}", description: "Between n and m" }, + { syntax: "*?", description: "0 or more (lazy)" }, + { syntax: "+?", description: "1 or more (lazy)" }, + ], + "Groups & Lookaround": [ + { syntax: "(abc)", description: "Capturing group" }, + { syntax: "(?:abc)", description: "Non-capturing group" }, + { syntax: "(?abc)", description: "Named group" }, + { syntax: "(?=abc)", description: "Positive lookahead" }, + { syntax: "(?!abc)", description: "Negative lookahead" }, + { syntax: "(?<=abc)", description: "Positive lookbehind" }, + { syntax: "(?("Please fill out"); const [matches, setMatches] = useState(null); + const [flags, setFlags] = useState({ + g: false, + i: false, + m: false, + s: false, + u: false, + y: false, + }); + const patternInputRef = useRef(null); + + const handleFlagChange = useCallback( + (flag: keyof RegexFlags) => { + const newFlags = { ...flags, [flag]: !flags[flag] }; + setFlags(newFlags); + + const patternBody = getPatternBody(pattern) || ""; + if (patternBody) { + const newPattern = buildPatternWithFlags(patternBody, newFlags); + setPattern(newPattern); + } + }, + [flags, pattern] + ); + + const handlePatternChange = useCallback((newPattern: string) => { + setPattern(newPattern); + const parsedFlags = parseFlags(newPattern); + setFlags(parsedFlags); + }, []); + + const handlePresetSelect = useCallback( + (presetPattern: string, presetTestString: string) => { + setPattern(presetPattern); + setTestString(presetTestString); + const parsedFlags = parseFlags(presetPattern); + setFlags(parsedFlags); + }, + [] + ); + + const handleCheatSheetInsert = useCallback( + (syntax: string) => { + const patternBody = getPatternBody(pattern) || ""; + const newPatternBody = patternBody + syntax; + const newPattern = buildPatternWithFlags(newPatternBody, flags); + setPattern(newPattern); + patternInputRef.current?.focus(); + }, + [pattern, flags] + ); const handleTest = useCallback(() => { try { @@ -60,49 +128,73 @@ export default function RegexTester() { return (
-
- -