Regex Crafting skill for Claude Code
Patterns with test cases and no ReDoS surprises. Claude writes regexes test-first: a cases file of must-match, must-not-match and expected captures, then the simplest anchored pattern that passes. It knows where PCRE, JavaScript, Python, Java, Go and grep disagree, rewrites nested quantifiers and overlapping alternations, and runs a bundled script that pumps inputs to expose catastrophic backtracking before it ships.
- name
regex-crafting - size184 lines · ≈2,150 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes, explains, and tests regular expressions with explicit must-match and must-not-match cases, anchors, and flavor awareness across PCRE, JavaScript, Python, Java, .NET, Go/RE2, Rust, and grep, and detects catastrophic backtracking (ReDoS) with a bundled probe script. Use when asked to write or fix a regex, explain what a pattern does, validate input formats, extract fields from logs, port a pattern between languages, or when a regex hangs or a CPU spike traces to pattern matching. Try asking
- Write a regex that validates our order IDs like ORD-2025-000123 and test it
- Explain what this 120-character regex in the log parser actually matches
- This validation regex pegs the CPU on some inputs — find out why and fix it
Use it when
- Writing or fixing a regex for validation, search, or extraction
- Explaining or documenting an existing pattern
- Porting a pattern between languages or grep tools
- A regex hangs or a CPU spike traces to pattern matching
Skip it when
- Validating structured request payloads end to end — use input-validation
- Parsing large log volumes for incidents — use log-analysis
How to check the result
scripts/regex_test.py passes every must-match, must-not-match and capture case with --probe reporting no backtracking blow-ups, and the pattern is retested in the target language.
The common mistake it prevents
Anchoring a validation regex with ^ and $ and assuming it rejects trailing newlines, or in Ruby, multi-line input, which lets crafted values slip past.
Done well means: the pattern is anchored to the right scope, it passes a written list of positive and negative cases including edge cases, it cannot backtrack catastrophically on hostile input, it uses only features the target engine supports, and a reader can follow it (verbose mode or a comment per part when longer than ~40 characters).
Ground rules
- Ask what the regex is for: validation (whole string), search (find occurrences), or extraction (capture parts). The answer decides anchors and groups.
- Identify the engine before writing a character.
\d, lookbehind, possessive quantifiers, named groups, and Unicode classes differ by flavor. - Write test cases first: at least 3 that must match, 3 that must not, and the nasty edges (empty string, whitespace, Unicode, very long input, trailing newline).
- Prefer a parser or library for structured formats: URLs (
URL/urllib.parse), emails beyond a sanity check, HTML/XML, JSON, CSV, dates. Regex is for regular languages. - Never put a user-supplied string into a pattern unescaped (
re.escape,RegExp.escapewhere available,Pattern.quote,regexp.QuoteMeta).
Workflow
- [ ] 1. Pin down purpose (validate / search / extract) and the engine + version
- [ ] 2. Write the cases file: + must match, - must not match, = expected capture
- [ ] 3. Draft the simplest pattern that passes; anchor it
- [ ] 4. Tighten: replace .* with specific classes, bound repetition where sensible
- [ ] 5. Check for ReDoS: nested/overlapping quantifiers, then run the probe
- [ ] 6. Port syntax to the target flavor; test in that language if possible
- [ ] 7. Document: verbose form or a breakdown table next to the pattern
Testing with the bundled script
cat > cases.txt <<'EOF'
+ 2025-01-31
+ 1999-12-01
- 2025-13-01
- 2025-1-31
- 2025-01-31x
= due 2025-02-14 please => 2025-02-14
EOF
python3 scripts/regex_test.py --pattern '\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])\b' \
--cases cases.txt --mode search --probe
- Without the
\bboundaries the- 2025-01-31xcase fails in search mode; negative cases exist to catch exactly that. --mode fullmatchfor validation patterns (equivalent to anchoring both ends).--probepumps the middle of each positive case and appends a failing suffix, killing the attempt after--timeoutseconds; a FAIL means exponential or steep polynomial backtracking. It uses Python’s backtracking engine, which is representative of PCRE, JS, Java, .NET, and Ruby. It is a heuristic: a PASS does not prove safety; still review the structure (below).- The script exits 1 on any failed case or probe, so it can run in CI.
Anchors and scope
| Goal | Pattern shape |
|---|---|
| Whole string is valid | ^...$ with no multiline flag; in Python use fullmatch; in Java matches() |
| Exact end, no trailing newline | \z in PCRE, Ruby, Java, .NET; \Z in Python (3.14 adds \z as an alias); JS has none, so avoid the m flag and reject \n separately |
| Each line | ^...$ with m flag |
| Whole word | \b...\b (ASCII word chars in most engines unless Unicode mode) |
| Not inside a longer token | Lookarounds (?<![\w.])...(?![\w.]) if supported |
$ matches before a trailing \n in PCRE, Python, Java, and Ruby, so ^\d+$ accepts
"123\n". For strict validation use fullmatch / \z / \A...\z.
Ruby: ^ and $ are always line anchors. Validation must use \A and \z or newline
injection passes.
Flavor differences that bite
| Feature | PCRE2 / PHP | JavaScript | Python re | Java | .NET | Go / RE2 / Rust | grep -E (POSIX ERE) |
|---|---|---|---|---|---|---|---|
| Lookahead | yes | yes | yes | yes | yes | no | no |
| Lookbehind | fixed/bounded | yes (ES2018, any length) | fixed width | bounded | any | no | no |
Backreferences \1 | yes | yes | yes | yes | yes | no | GNU yes |
| Named groups | (?<n>) | (?<n>) | (?P<n>) only | (?<n>) | (?<n>) | (?P<n>), (?<n>) | no |
| Atomic / possessive | yes | no | yes (3.11+) | yes | atomic yes | n/a (linear) | no |
\d matches non-ASCII digits | no (unless UCP) | no | yes (str patterns) | no | yes | no | no \d at all in POSIX |
Unicode properties \p{L} | yes | with u/v flag | no (use regex module) | yes | yes | yes | no |
| Linear-time guarantee | no | no | no | no | NonBacktracking option | yes | implementation-dependent |
Safe advice: in Python, use [0-9] instead of \d when you mean ASCII digits, or pass
re.ASCII. Python re only understands (?P<name>...) named groups; port (?<name>...) when moving patterns in.
JavaScript specifics: always use the u (or v) flag for Unicode input; g and y
make RegExp objects stateful via lastIndex — do not reuse a global regex across
test() calls on different strings.
Catastrophic backtracking: recognize and fix
Danger shapes (any backtracking engine):
| Shape | Example | Fix |
|---|---|---|
| Nested quantifiers | (a+)+, (\w+\s?)* | Flatten: a+, (?:\w+\s)*\w* |
| Alternation with overlap under a quantifier | `(a | aa)*, (\w |
| Adjacent quantified tokens that overlap | \s*\s*, .*.*=, \d+\d+ | Merge or separate with a required delimiter |
.* followed by more pattern with no anchor | ^(.*),(.*),(.*)$ on long lines | Negated class: ^([^,]*),([^,]*),([^,]*)$ |
| Optional groups repeated | (x?)* | Remove the inner optionality |
Fix order:
- Replace
.*/.+with a negated character class bounded by the next delimiter ("[^"]*"instead of".*"). - Make alternatives mutually exclusive.
- Use possessive quantifiers or atomic groups (
(?>...),a++) where supported. - Bound repetition (
{1,64}) and cap input length before matching. - For untrusted input at scale, use a linear-time engine (RE2 bindings, Rust
regex, Goregexp, .NETRegexOptions.NonBacktracking) and set timeouts where the platform offers them (.NETmatchTimeout, PHPpcre.backtrack_limit).
Readability
Long patterns go in verbose mode with comments:
import re
LOG_LINE = re.compile(r"""
^(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) # ISO-8601 UTC timestamp
\s+(?P<level>DEBUG|INFO|WARN|ERROR) # level
\s+\[(?P<req>[0-9a-f-]{36})\] # request id (uuid)
\s+(?P<msg>.*)$ # rest of line
""", re.VERBOSE)
In languages without verbose mode (JavaScript), build from named parts:
const ts = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z`;
const level = String.raw`(?<level>DEBUG|INFO|WARN|ERROR)`;
const LOG_LINE = new RegExp(String.raw`^(?<ts>${ts})\s+${level}\s+(?<msg>.*)$`, "u");
Explaining an existing regex
Produce a breakdown table, then the cases that reveal its behavior:
Pattern: `^(?=.*\d)(?=.*[a-z])[\w!@#$%]{8,64}$`
| Part | Meaning |
|---|---|
| `^` / `$` | Whole string (but `$` also accepts a trailing newline) |
| `(?=.*\d)` | Somewhere ahead there is a digit |
| `(?=.*[a-z])` | Somewhere ahead there is a lowercase ASCII letter |
| `[\w!@#$%]{8,64}` | 8–64 chars from word chars and `!@#$%` |
Matches: `abc12345` Rejects: `ABCDEFGH1` (no lowercase), `abc 1234` (space)
Surprises: accepts `"abcdefg1\n"` in PCRE/Python; `\w` includes `_` and, in Python, Unicode letters.
Common building blocks (validate, then adapt)
| Need | Pattern (PCRE/JS/Python) | Notes |
|---|---|---|
| Integer | `^-?(?:0 | [1-9]\d*)$` |
| Decimal | ^-?\d+(?:\.\d+)?$ | Use a number parser if exponents matter |
| UUID | ^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$ | Any version |
| Hex color | ^#(?:[0-9a-fA-F]{3}){1,2}$ | 3 or 6 digits |
| Slug | ^[a-z0-9]+(?:-[a-z0-9]+)*$ | No leading/trailing/double hyphens |
| IPv4 (strict) | `^(?:(?:25[0-5] | 2[0-4]\d |
| Quoted string with escapes | `”(?:[^”\] | \.)*”` |
Verification
- Run
scripts/regex_test.pywith the cases file and--probe; all PASS. - Test in the target language too (
node -e,python3 -c,go test) when the flavor differs from Python. - For grep/sed/ripgrep usage, run against a real sample file and spot-check counts:
grep -cE 'pattern' filevs.rg -c 'pattern' file(ripgrep uses Rust syntax;rg -Pfor PCRE2). - Deliver the pattern with its cases file so future edits can rerun them.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/regex_test.py
#!/usr/bin/env python3
"""Test a regular expression against must-match / must-not-match cases and probe for
catastrophic backtracking. Uses Python's `re` engine (a backtracking engine, so the
probe is representative of PCRE, JavaScript, Java, .NET, Ruby; not of RE2/Go/Rust).
Usage:
python3 regex_test.py --pattern 'REGEX' --cases cases.txt [--flags imsx] [--mode search|fullmatch]
[--probe] [--timeout 2.0]
cases.txt format (one case per line):
+ text that must match
- text that must NOT match
= text => expected first match (or group 1 if the pattern has groups)
# comment lines and blank lines are ignored
Use \\t, \\n, \\x00 style escapes inside cases if needed.
Exit code: 0 all cases pass and no backtracking blow-up found; 1 otherwise; 2 bad input.
"""
from __future__ import annotations
import argparse
import multiprocessing as mp
import re
import sys
import time
FLAG_MAP = {"i": re.IGNORECASE, "m": re.MULTILINE, "s": re.DOTALL, "x": re.VERBOSE, "a": re.ASCII}
def compile_pattern(pattern: str, flags: str) -> re.Pattern[str]:
value = 0
for ch in flags:
if ch not in FLAG_MAP:
raise SystemExit(f"unknown flag {ch!r}; use any of {''.join(FLAG_MAP)}")
value |= FLAG_MAP[ch]
try:
return re.compile(pattern, value)
except re.error as exc:
print(f"INVALID PATTERN: {exc}", file=sys.stderr)
sys.exit(2)
def unescape(text: str) -> str:
return text.encode("utf-8").decode("unicode_escape") if "\\" in text else text
def load_cases(path: str) -> list[tuple[str, str, str | None]]:
cases: list[tuple[str, str, str | None]] = []
with open(path, encoding="utf-8") as fh:
for lineno, raw in enumerate(fh, 1):
line = raw.rstrip("\n")
if not line.strip() or line.lstrip().startswith("#"):
continue
kind, _, rest = line.partition(" ")
if kind in ("+", "-"):
cases.append((kind, unescape(rest), None))
elif kind == "=":
text, sep, expected = rest.partition(" => ")
if not sep:
raise SystemExit(f"line {lineno}: '=' cases need 'text => expected'")
cases.append((kind, unescape(text), unescape(expected)))
else:
raise SystemExit(f"line {lineno}: start with '+', '-', '=' or '#': {line!r}")
if not cases:
raise SystemExit("no cases found")
return cases
def run_match(rx: re.Pattern[str], mode: str, text: str) -> re.Match[str] | None:
return rx.fullmatch(text) if mode == "fullmatch" else rx.search(text)
def check_cases(rx: re.Pattern[str], mode: str, cases) -> int:
failures = 0
for kind, text, expected in cases:
m = run_match(rx, mode, text)
if kind == "+":
ok = m is not None
detail = "" if ok else "expected a match, got none"
elif kind == "-":
ok = m is None
detail = "" if ok else f"expected no match, matched {m.group(0)!r}"
else:
got = None if m is None else (m.group(1) if rx.groups else m.group(0))
ok = got == expected
detail = "" if ok else f"expected {expected!r}, got {got!r}"
status = "PASS" if ok else "FAIL"
print(f"{status} {kind} {text!r}" + (f" -- {detail}" if detail else ""))
failures += 0 if ok else 1
return failures
def _timed_worker(pattern: str, flags: int, mode: str, text: str, queue) -> None:
rx = re.compile(pattern, flags)
start = time.perf_counter()
run_match(rx, mode, text)
queue.put(time.perf_counter() - start)
def timed(rx: re.Pattern[str], mode: str, text: str, timeout: float) -> float | None:
"""Return seconds taken, or None if it exceeded the timeout (process is killed)."""
queue: mp.Queue = mp.Queue()
proc = mp.Process(target=_timed_worker, args=(rx.pattern, rx.flags, mode, text, queue))
proc.start()
proc.join(timeout)
if proc.is_alive():
proc.terminate()
proc.join()
return None
return queue.get() if not queue.empty() else None
def probe(rx: re.Pattern[str], mode: str, cases, timeout: float) -> int:
"""Pump the middle of each matching case and append a failing suffix; watch growth."""
seeds = [text for kind, text, _ in cases if kind in ("+", "=") and len(text) >= 1]
if not seeds:
print("PROBE skipped: no positive cases to pump")
return 0
problems = 0
for seed in seeds:
third = max(1, len(seed) // 3)
prefix, pump = seed[:third], seed[third:2 * third] or seed
suffix = seed[2 * third:] + "\x00!"
times: list[tuple[int, float]] = []
verdict = "linear"
for reps in (8, 16, 32, 64, 128, 256, 1024, 4096):
text = prefix + pump * reps + suffix
took = timed(rx, mode, text, timeout)
if took is None:
verdict = f"CATASTROPHIC: >{timeout}s at input length {len(text)}"
break
times.append((len(text), took))
if len(times) >= 2:
(n1, t1), (n2, t2) = times[-2], times[-1]
if t1 > 0.002 and t2 / t1 > 6 * (n2 / n1):
verdict = f"SUPER-LINEAR: {t1:.4f}s at len {n1} -> {t2:.4f}s at len {n2}"
break
flagged = verdict != "linear"
problems += 1 if flagged else 0
last = times[-1] if times else (0, 0.0)
tail = "" if flagged else f" (len {last[0]} in {last[1]:.4f}s)"
print(f"PROBE {'FAIL' if flagged else 'PASS'} pump={pump!r}: {verdict}{tail}")
return problems
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--pattern", required=True)
ap.add_argument("--cases", required=True, help="path to cases file")
ap.add_argument("--flags", default="", help="any of: i m s x a")
ap.add_argument("--mode", choices=("search", "fullmatch"), default="search")
ap.add_argument("--probe", action="store_true", help="probe for catastrophic backtracking")
ap.add_argument("--timeout", type=float, default=2.0, help="seconds per probe attempt")
args = ap.parse_args()
rx = compile_pattern(args.pattern, args.flags)
cases = load_cases(args.cases)
failures = check_cases(rx, args.mode, cases)
problems = probe(rx, args.mode, cases, args.timeout) if args.probe else 0
total = sum(1 for _ in cases)
print(f"\n{total - failures}/{total} cases passed; {problems} backtracking problem(s)")
return 1 if failures or problems else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.