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.

  • nameregex-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

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.

~/.claude/skills/regex-crafting/SKILL.md

--- name: regex-crafting description: Writes, explains, and tests regular expressions with explicit must-match and must-not-match cases, anchors, and flavor awareness across… license: MIT ---

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.escape where 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 \b boundaries the - 2025-01-31x case fails in search mode; negative cases exist to catch exactly that.
  • --mode fullmatch for validation patterns (equivalent to anchoring both ends).
  • --probe pumps the middle of each positive case and appends a failing suffix, killing the attempt after --timeout seconds; 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

GoalPattern 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 tokenLookarounds (?<![\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

FeaturePCRE2 / PHPJavaScriptPython reJava.NETGo / RE2 / Rustgrep -E (POSIX ERE)
Lookaheadyesyesyesyesyesnono
Lookbehindfixed/boundedyes (ES2018, any length)fixed widthboundedanynono
Backreferences \1yesyesyesyesyesnoGNU yes
Named groups(?<n>)(?<n>)(?P<n>) only(?<n>)(?<n>)(?P<n>), (?<n>)no
Atomic / possessiveyesnoyes (3.11+)yesatomic yesn/a (linear)no
\d matches non-ASCII digitsno (unless UCP)noyes (str patterns)noyesnono \d at all in POSIX
Unicode properties \p{L}yeswith u/v flagno (use regex module)yesyesyesno
Linear-time guaranteenonononoNonBacktracking optionyesimplementation-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):

ShapeExampleFix
Nested quantifiers(a+)+, (\w+\s?)*Flatten: a+, (?:\w+\s)*\w*
Alternation with overlap under a quantifier`(aaa)*, (\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 linesNegated class: ^([^,]*),([^,]*),([^,]*)$
Optional groups repeated(x?)*Remove the inner optionality

Fix order:

  1. Replace .* / .+ with a negated character class bounded by the next delimiter ("[^"]*" instead of ".*").
  2. Make alternatives mutually exclusive.
  3. Use possessive quantifiers or atomic groups ((?>...), a++) where supported.
  4. Bound repetition ({1,64}) and cap input length before matching.
  5. For untrusted input at scale, use a linear-time engine (RE2 bindings, Rust regex, Go regexp, .NET RegexOptions.NonBacktracking) and set timeouts where the platform offers them (.NET matchTimeout, PHP pcre.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)

NeedPattern (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

  1. Run scripts/regex_test.py with the cases file and --probe; all PASS.
  2. Test in the target language too (node -e, python3 -c, go test) when the flavor differs from Python.
  3. For grep/sed/ripgrep usage, run against a real sample file and spot-check counts: grep -cE 'pattern' file vs. rg -c 'pattern' file (ripgrep uses Rust syntax; rg -P for PCRE2).
  4. 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 166 lines · 6.6 KB
#!/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.

esc