diff --git a/README.md b/README.md
index 9cb01fc..e9fb543 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ article; each module's own README has that article's version table, quickstart,
| [`arithmetic`](arithmetic/) | Add Two Numbers in Java Without Overflow: addExact, Widening, and BigInteger |
| [`hashmap-concurrenthashmap`](hashmap-concurrenthashmap/) | Java HashMap vs ConcurrentHashMap: Complete Interview Guide |
| [`exceptions`](exceptions/) | Java Exception Handling Deep Dive: Checked vs Unchecked, Suppression, and What Exceptions Actually Cost |
+| [`regex`](regex/) | Java Regex Tutorial: Pattern, Matcher, Groups, Lookarounds and ReDoS |
## License
diff --git a/pom.xml b/pom.xml
index 2035d06..94b6fef 100644
--- a/pom.xml
+++ b/pom.xml
@@ -23,6 +23,7 @@
arithmetic
hashmap-concurrenthashmap
exceptions
+ regex
diff --git a/regex/README.md b/regex/README.md
new file mode 100644
index 0000000..ff879b8
--- /dev/null
+++ b/regex/README.md
@@ -0,0 +1,38 @@
+# regex
+
+Companion code for the ankurm.com post *"Java Regex Tutorial: Pattern, Matcher, Groups, Lookarounds
+and ReDoS."* Module `regex` in `java-core-examples`.
+
+All explanation lives in the post; this module holds the runnable evidence and the captured output.
+
+## Versions
+
+| Component | Version |
+|---|---|
+| JDK | 25.0.4.1+1 (Temurin, LTS) |
+| JUnit Jupiter | 5.11.0 |
+| Hardware | 2 vCPU x86-64 VM (timings are indicative, not a leaderboard) |
+
+## Quickstart
+
+```bash
+export JDK25_HOME=/path/to/jdk-25
+./scripts/run-all.sh # rebuilds, runs the tests, regenerates everything in output/
+```
+
+## What is in here
+
+| File | Shows | Output |
+|---|---|---|
+| `BasicsDemo` | `matches` / `lookingAt` / `find`, numbered and named groups, `results()`, flags | `01` |
+| `LookaroundDemo` | lookahead, lookbehind, password policy, thousands separators, lookbehind limits | `02` |
+| `ReplaceDemo` | `$1` / `${name}`, the `$` trap, `replaceAll(Function)`, `quoteReplacement`, `appendReplacement` | `03` |
+| `BlowupDemo` | timings at growing input: what the JDK tames, what it does not | `04` |
+| `FixesDemo` | rewrite, possessive, atomic group; `find()` is still quadratic | `05` |
+| `LimitsDemo` | `StackOverflowError` from `(?:a\|b)*` on long input | `06` |
+| `RegexTimeout`, `Safe` | a `CharSequence` that aborts a match at a deadline; length limit + deadline wrapper | used by `04`, `05` |
+| JDK `Pattern.java` excerpt | the loop-memoisation gate, and the absence of any timeout API | `07` |
+| `RegexClaimsTest` | 11 assertions behind the claims above | `08` |
+
+Every timing run is capped (2 s in `BlowupDemo`) by `RegexTimeout`, so the demos always finish.
+Timings move by tens of percent between runs; the growth shape and which patterns abort do not.
diff --git a/regex/output/01-basics.txt b/regex/output/01-basics.txt
new file mode 100644
index 0000000..744fee5
--- /dev/null
+++ b/regex/output/01-basics.txt
@@ -0,0 +1,23 @@
+matches() = true
+group(0) = 2026-03-14 ERROR disk /dev/sda1 is 97% full
+group(2) = ERROR
+level = ERROR
+start(msg) = 17, end(msg) = 43
+namedGroups = {date=1, level=2, msg=3}
+
+matches() on "order-42 shipped" = false
+lookingAt() on "order-42 shipped" = false
+find() on "order-42 shipped" = true
+
+results() over "a1 b22 c333":
+ a1 -> group(1)=1
+ b22 -> group(1)=22
+ c333 -> group(1)=333
+
+no flags : 0
+CASE_INSENSITIVE : 0
+MULTILINE : 1
+MULTILINE|CASE_INS. : 3
+embedded (?im) : 3
+'.' vs newline : 0 / DOTALL 1
+COMMENTS : 1
diff --git a/regex/output/02-lookarounds.txt b/regex/output/02-lookarounds.txt
new file mode 100644
index 0000000..788be0f
--- /dev/null
+++ b/regex/output/02-lookarounds.txt
@@ -0,0 +1,12 @@
+\d+(?=px) -> "120", "48"
+foo(?!bar) -> "foo", "foo"
+(?<=\$)\d+(?:\.\d\d)? -> "19.99", "23"
+(? "3"
+policy abcdefgh -> false
+policy Abcdefg1 -> true
+policy Ab1 -> false
+policy ABCDEFG1x -> true
+1,234,567
+compiled (?<=a+)b
+compiled (?<=a{1,9})b
+rejected (?<=(?:ab)+)c : Look-behind group does not have an obvious maximum length
diff --git a/regex/output/03-replace.txt b/regex/output/03-replace.txt
new file mode 100644
index 0000000..4c94512
--- /dev/null
+++ b/regex/output/03-replace.txt
@@ -0,0 +1,9 @@
+price: 5 dollars, fee: 12 dollars
+price: USD 5, fee: USD 12
+replaceAll("$") -> IllegalArgumentException: Illegal group reference: group index is missing
+price: 5 $, fee: 12 $
+price: 10 USD, fee: 24 USD
+unquoted lambda -> IndexOutOfBoundsException: No group 5
+hello ankur, you owe $5
+hi ${nobody}
+rEgUlAr ExprEssIOns
diff --git a/regex/output/04-blowup.txt b/regex/output/04-blowup.txt
new file mode 100644
index 0000000..8ddb42e
--- /dev/null
+++ b/regex/output/04-blowup.txt
@@ -0,0 +1,28 @@
+cap per run: 2000 ms; input = n letters 'a' followed by '!' (no match possible)
+
+pattern (a+)+b
+ n=20 0 ms matches=false
+ n=36 0 ms matches=false
+ n=500 1 ms matches=false
+ n=1000 23 ms matches=false
+ n=2000 63 ms matches=false
+ n=4000 161 ms matches=false
+
+pattern (a+)+\1?b
+ n=20 27 ms matches=false
+ n=24 690 ms matches=false
+ n=26 ABORTED after 2036 ms (cap)
+
+pattern (?:(?:a+)+)+b
+ n=16 3 ms matches=false
+ n=18 13 ms matches=false
+ n=20 101 ms matches=false
+ n=22 896 ms matches=false
+ n=24 ABORTED after 2000 ms (cap)
+
+pattern a*a*a*a*b
+ n=50 2 ms matches=false
+ n=100 69 ms matches=false
+ n=150 285 ms matches=false
+ n=200 824 ms matches=false
+
diff --git a/regex/output/05-fixes.txt b/regex/output/05-fixes.txt
new file mode 100644
index 0000000..a1dda0c
--- /dev/null
+++ b/regex/output/05-fixes.txt
@@ -0,0 +1,12 @@
+input: 40 x 'a' + '!' budget 1000 ms
+vulnerable (?:(?:a+)+)+b ABORTED after 1019 ms
+rewritten a+b 0 ms matches=false
+possessive (?:a++)++b 7 ms matches=false
+atomic (?>(?:a+)+)b 0 ms matches=false
+
+find() a+b n=5000 139 ms found=false
+find() a+b n=10000 489 ms found=false
+find() a+b n=20000 1906 ms found=false
+find() (? true
+(?:a|b)*c length 5000 -> StackOverflowError
+(?:a|b)*c length 20000 -> StackOverflowError
+(?:a|b)*c length 100000 -> StackOverflowError
+[ab]*c length 100000 -> true
+(?:a|b)*+c length 100000 -> true
diff --git a/regex/output/07-jdk-source.txt b/regex/output/07-jdk-source.txt
new file mode 100644
index 0000000..dfcd2c6
--- /dev/null
+++ b/regex/output/07-jdk-source.txt
@@ -0,0 +1,22 @@
+$ java -version
+openjdk version "25.0.4.1" 2026-08-18 LTS
+OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
+OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
+
+$ unzip -p lib/src.zip java.base/java/util/regex/Pattern.java | sed -n "/Optimize the greedy Loop/,/^ }$/p"
+ // Optimize the greedy Loop to prevent exponential backtracking, IF there
+ // is no group ref in this pattern. With a non-negative localTCNCount value,
+ // the greedy type Loop, Curly will skip the backtracking for any starting
+ // position "i" that failed in the past.
+ if (!hasGroupRef) {
+ for (Node node : topClosureNodes) {
+ if (node instanceof Loop) {
+ // non-deterministic-greedy-group
+ ((Loop)node).posIndex = localTCNCount++;
+ }
+ }
+ }
+
+$ grep -ci timeout Pattern.java Matcher.java
+Pattern.java: 0
+Matcher.java: 0
diff --git a/regex/output/08-tests.txt b/regex/output/08-tests.txt
new file mode 100644
index 0000000..792101c
--- /dev/null
+++ b/regex/output/08-tests.txt
@@ -0,0 +1,4 @@
+-------------------------------------------------------------------------------
+Test set: com.ankurm.regex.RegexClaimsTest
+-------------------------------------------------------------------------------
+Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.131 s -- in com.ankurm.regex.RegexClaimsTest
diff --git a/regex/pom.xml b/regex/pom.xml
new file mode 100644
index 0000000..d6d8470
--- /dev/null
+++ b/regex/pom.xml
@@ -0,0 +1,43 @@
+
+
+ 4.0.0
+
+
+ com.ankurm
+ java-core-examples
+ 1.0
+
+
+ regex
+ regex
+ java.util.regex: groups, flags, lookarounds, replaceAll with lambdas, catastrophic backtracking, and ways to bound it.
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.0
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 25
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
diff --git a/regex/scripts/run-all.sh b/regex/scripts/run-all.sh
new file mode 100755
index 0000000..c576b0b
--- /dev/null
+++ b/regex/scripts/run-all.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Regenerates every file in ../output/. Requires JDK25_HOME.
+set -euo pipefail
+[[ -z "${JDK25_HOME:-}" ]] && { echo "JDK25_HOME must be set" >&2; exit 1; }
+cd "$(dirname "$0")/.."
+OUT=output; mkdir -p "$OUT"
+export JAVA_HOME="$JDK25_HOME"
+mvn -q -f ../pom.xml -pl regex -am package
+J="$JDK25_HOME/bin/java"
+run() { echo "==> $1"; "$J" -cp target/classes "com.ankurm.regex.$1" 2>&1 | grep -v "Picked up" > "$OUT/$2"; }
+run BasicsDemo 01-basics.txt
+run LookaroundDemo 02-lookarounds.txt
+run ReplaceDemo 03-replace.txt
+run BlowupDemo 04-blowup.txt
+run FixesDemo 05-fixes.txt
+run LimitsDemo 06-limits.txt
+echo "==> JDK source: the backtracking mitigation and the absence of a timeout"
+{
+ echo '$ java -version'; "$J" -version 2>&1 | grep -v "Picked up"
+ echo
+ echo '$ unzip -p lib/src.zip java.base/java/util/regex/Pattern.java | sed -n "/Optimize the greedy Loop/,/^ }$/p"'
+ unzip -p "$JDK25_HOME/lib/src.zip" java.base/java/util/regex/Pattern.java | sed -n '/Optimize the greedy Loop/,/^ }$/p'
+ echo
+ echo '$ grep -ci timeout Pattern.java Matcher.java'
+ for f in Pattern Matcher; do echo "$f.java: $(unzip -p "$JDK25_HOME/lib/src.zip" java.base/java/util/regex/$f.java | grep -ci timeout)"; done
+} > "$OUT/07-jdk-source.txt"
+cp target/surefire-reports/com.ankurm.regex.RegexClaimsTest.txt "$OUT/08-tests.txt"
+echo Done
diff --git a/regex/src/main/java/com/ankurm/regex/BasicsDemo.java b/regex/src/main/java/com/ankurm/regex/BasicsDemo.java
new file mode 100644
index 0000000..ae33d40
--- /dev/null
+++ b/regex/src/main/java/com/ankurm/regex/BasicsDemo.java
@@ -0,0 +1,53 @@
+package com.ankurm.regex;
+
+import java.util.regex.MatchResult;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Pattern, Matcher, the three match methods, numbered and named groups, flags. */
+public class BasicsDemo {
+ static final Pattern LOG = Pattern.compile(
+ "(?\\d{4}-\\d{2}-\\d{2}) (?INFO|WARN|ERROR) (?.+)");
+
+ public static void main(String[] args) {
+ String line = "2026-03-14 ERROR disk /dev/sda1 is 97% full";
+
+ Matcher m = LOG.matcher(line);
+ System.out.println("matches() = " + m.matches());
+ System.out.println("group(0) = " + m.group(0));
+ System.out.println("group(2) = " + m.group(2));
+ System.out.println("level = " + m.group("level"));
+ System.out.println("start(msg) = " + m.start("msg") + ", end(msg) = " + m.end("msg"));
+ System.out.println("namedGroups = " + new java.util.TreeMap<>(m.namedGroups()));
+
+ System.out.println();
+ String s = "order-42 shipped";
+ Pattern digits = Pattern.compile("\\d+");
+ System.out.println("matches() on \"" + s + "\" = " + digits.matcher(s).matches());
+ System.out.println("lookingAt() on \"" + s + "\" = " + digits.matcher(s).lookingAt());
+ System.out.println("find() on \"" + s + "\" = " + digits.matcher(s).find());
+
+ System.out.println();
+ System.out.println("results() over \"a1 b22 c333\":");
+ Pattern.compile("[a-z](\\d+)").matcher("a1 b22 c333").results()
+ .map((MatchResult r) -> r.group() + " -> group(1)=" + r.group(1))
+ .forEach(x -> System.out.println(" " + x));
+
+ System.out.println();
+ String text = "Error\nerror\nERROR";
+ System.out.println("no flags : " + count("^error$", 0, text));
+ System.out.println("CASE_INSENSITIVE : " + count("^error$", Pattern.CASE_INSENSITIVE, text));
+ System.out.println("MULTILINE : " + count("^error$", Pattern.MULTILINE, text));
+ System.out.println("MULTILINE|CASE_INS. : " + count("^error$", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE, text));
+ System.out.println("embedded (?im) : " + count("(?im)^error$", 0, text));
+ System.out.println("'.' vs newline : " + count("a.b", 0, "a\nb") + " / DOTALL " + count("a.b", Pattern.DOTALL, "a\nb"));
+ System.out.println("COMMENTS : " + count("\\d{3} # area code\n - \\d{4} # number", Pattern.COMMENTS, "555-1234"));
+ }
+
+ static int count(String regex, int flags, String text) {
+ Matcher m = Pattern.compile(regex, flags).matcher(text);
+ int n = 0;
+ while (m.find()) n++;
+ return n;
+ }
+}
diff --git a/regex/src/main/java/com/ankurm/regex/BlowupDemo.java b/regex/src/main/java/com/ankurm/regex/BlowupDemo.java
new file mode 100644
index 0000000..7ddc74d
--- /dev/null
+++ b/regex/src/main/java/com/ankurm/regex/BlowupDemo.java
@@ -0,0 +1,43 @@
+package com.ankurm.regex;
+
+import java.util.regex.Pattern;
+
+/** Times patterns at growing input sizes. Every run is capped by RegexTimeout so the demo always finishes. */
+public class BlowupDemo {
+ static final long CAP_MS = 2000;
+
+ public static void main(String[] args) {
+ warmUp();
+ System.out.println("cap per run: " + CAP_MS + " ms; input = n letters 'a' followed by '!' (no match possible)");
+ System.out.println();
+ // A: the textbook nested quantifier. Looks deadly; JDK 25 handles it.
+ series("(a+)+b", new int[] {20, 36, 500, 1_000, 2_000, 4_000});
+ // B: the same pattern with a backreference somewhere in it: the JDK's optimisation is switched off
+ series("(a+)+\\1?b", new int[] {20, 24, 26, 28, 30});
+ // C: nested quantifiers without a capturing group: not covered by the optimisation
+ series("(?:(?:a+)+)+b", new int[] {16, 18, 20, 22, 24, 26});
+ // D: polynomial, not exponential: adjacent quantifiers over the same characters
+ series("a*a*a*a*b", new int[] {50, 100, 150, 200});
+ }
+
+ /** Let the JIT see each pattern before anything is timed, so the first row is not a cold start. */
+ static void warmUp() {
+ for (String re : new String[] {"(a+)+b", "(a+)+\\1?b", "(?:(?:a+)+)+b", "a*a*a*a*b"})
+ for (int i = 0; i < 300; i++)
+ RegexTimeout.matchesWithin(Pattern.compile(re), "a".repeat(12) + "!", CAP_MS);
+ }
+
+ static void series(String regex, int[] sizes) {
+ Pattern p = Pattern.compile(regex);
+ System.out.println("pattern " + regex);
+ for (int n : sizes) {
+ String input = "a".repeat(n) + "!";
+ long t0 = System.nanoTime();
+ Boolean r = RegexTimeout.matchesWithin(p, input, CAP_MS);
+ long ms = (System.nanoTime() - t0) / 1_000_000;
+ System.out.printf(" n=%-6d %s%n", n, r == null ? "ABORTED after " + ms + " ms (cap)" : ms + " ms matches=" + r);
+ if (r == null) break;
+ }
+ System.out.println();
+ }
+}
diff --git a/regex/src/main/java/com/ankurm/regex/FixesDemo.java b/regex/src/main/java/com/ankurm/regex/FixesDemo.java
new file mode 100644
index 0000000..7bea93d
--- /dev/null
+++ b/regex/src/main/java/com/ankurm/regex/FixesDemo.java
@@ -0,0 +1,36 @@
+package com.ankurm.regex;
+
+import java.util.regex.Pattern;
+
+/** The same hostile input against the vulnerable pattern and each fix. */
+public class FixesDemo {
+ static final int N = 40;
+ static final String HOSTILE = "a".repeat(N) + "!";
+
+ public static void main(String[] args) {
+ System.out.println("input: " + N + " x 'a' + '!' budget 1000 ms");
+ run("vulnerable (?:(?:a+)+)+b ", "(?:(?:a+)+)+b", HOSTILE);
+ run("rewritten a+b ", "a+b", HOSTILE);
+ run("possessive (?:a++)++b ", "(?:a++)++b", HOSTILE);
+ run("atomic (?>(?:a+)+)b ", "(?>(?:a+)+)b", HOSTILE);
+ System.out.println();
+ // find() tries every start position, so even a safe pattern is quadratic on this input
+ // find() tries every start position, so even a safe pattern is quadratic on this input.
+ // Plain String input here (no deadline wrapper) and a warm-up, so the numbers are the regex's own.
+ for (int i = 0; i < 20; i++) Pattern.compile("a+b").matcher("a".repeat(2_000) + "!").find();
+ for (String re : new String[] {"a+b", "(? " + alt.matcher(s).matches());
+ } catch (StackOverflowError e) {
+ System.out.println("(?:a|b)*c length " + n + " -> StackOverflowError");
+ }
+ }
+ Pattern safe = Pattern.compile("[ab]*c");
+ System.out.println("[ab]*c length 100000 -> " + safe.matcher("ab".repeat(50_000) + "c").matches());
+ Pattern poss = Pattern.compile("(?:a|b)*+c");
+ try {
+ System.out.println("(?:a|b)*+c length 100000 -> " + poss.matcher("ab".repeat(50_000) + "c").matches());
+ } catch (StackOverflowError e) {
+ System.out.println("(?:a|b)*+c length 100000 -> StackOverflowError");
+ }
+ }
+}
diff --git a/regex/src/main/java/com/ankurm/regex/LookaroundDemo.java b/regex/src/main/java/com/ankurm/regex/LookaroundDemo.java
new file mode 100644
index 0000000..663bfb5
--- /dev/null
+++ b/regex/src/main/java/com/ankurm/regex/LookaroundDemo.java
@@ -0,0 +1,44 @@
+package com.ankurm.regex;
+
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Zero-width assertions: they test the text around a position but consume nothing. */
+public class LookaroundDemo {
+ public static void main(String[] args) {
+ // positive lookahead: a digit run that is followed by "px", without including "px"
+ show("\\d+(?=px)", "width:120px; z-index:7; height:48px");
+ // negative lookahead: "foo" not followed by "bar"
+ show("foo(?!bar)", "foobar foobaz foo");
+ // positive lookbehind: digits preceded by a dollar sign
+ show("(?<=\\$)\\d+(?:\\.\\d\\d)?", "cost $19.99, tax 3, total $23");
+ // negative lookbehind: digits NOT preceded by a dollar sign
+ show("(? %s%n", pw, policy.matcher(pw).matches());
+
+ // thousands separators: insert a comma before every group of 3 digits that ends the number
+ System.out.println(Pattern.compile("(?<=\\d)(?=(?:\\d{3})+$)").matcher("1234567").replaceAll(","));
+
+ // lookbehind needs a computable maximum length; probe which shapes this JDK accepts
+ for (String re : new String[] {"(?<=a+)b", "(?<=a{1,9})b", "(?<=(?:ab)+)c"}) {
+ try {
+ Pattern.compile(re);
+ System.out.println("compiled " + re);
+ } catch (java.util.regex.PatternSyntaxException e) {
+ System.out.println("rejected " + re + " : " + e.getDescription());
+ }
+ }
+ }
+
+ static void show(String regex, String text) {
+ Matcher m = Pattern.compile(regex).matcher(text);
+ StringBuilder sb = new StringBuilder();
+ while (m.find()) sb.append(sb.isEmpty() ? "" : ", ").append('"').append(m.group()).append('"');
+ System.out.printf("%-28s -> %s%n", regex, sb);
+ }
+}
diff --git a/regex/src/main/java/com/ankurm/regex/RegexTimeout.java b/regex/src/main/java/com/ankurm/regex/RegexTimeout.java
new file mode 100644
index 0000000..e09ad7f
--- /dev/null
+++ b/regex/src/main/java/com/ankurm/regex/RegexTimeout.java
@@ -0,0 +1,63 @@
+package com.ankurm.regex;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * java.util.regex has no timeout API. The matcher reads its input only through CharSequence.charAt,
+ * so a CharSequence that checks a deadline can abort a runaway match by throwing.
+ */
+public final class RegexTimeout implements CharSequence {
+
+ /** Unchecked, so it can cross charAt(). Carries how long the match had run. */
+ public static final class RegexTimeoutException extends RuntimeException {
+ public RegexTimeoutException(long budgetMillis) {
+ super("regex exceeded " + budgetMillis + " ms");
+ }
+ }
+
+ private final CharSequence inner;
+ private final long deadlineNanos;
+ private final long budgetMillis;
+ private int calls;
+
+ public RegexTimeout(CharSequence inner, long budgetMillis) {
+ this.inner = inner;
+ this.budgetMillis = budgetMillis;
+ this.deadlineNanos = System.nanoTime() + budgetMillis * 1_000_000L;
+ }
+
+ @Override public char charAt(int index) {
+ // nanoTime() is cheap but not free; look at the clock once per 1024 reads
+ if ((++calls & 1023) == 0 && System.nanoTime() > deadlineNanos)
+ throw new RegexTimeoutException(budgetMillis);
+ return inner.charAt(index);
+ }
+
+ @Override public int length() { return inner.length(); }
+
+ @Override public CharSequence subSequence(int start, int end) {
+ return new RegexTimeout(inner.subSequence(start, end), budgetMillis);
+ }
+
+ @Override public String toString() { return inner.toString(); }
+
+ /** matches() with a budget; returns null when the budget ran out. */
+ public static Boolean matchesWithin(Pattern p, CharSequence input, long budgetMillis) {
+ try {
+ return p.matcher(new RegexTimeout(input, budgetMillis)).matches();
+ } catch (RegexTimeoutException e) {
+ return null;
+ }
+ }
+
+ /** find() with a budget; returns null when the budget ran out. */
+ public static Boolean findWithin(Pattern p, CharSequence input, long budgetMillis) {
+ try {
+ Matcher m = p.matcher(new RegexTimeout(input, budgetMillis));
+ return m.find();
+ } catch (RegexTimeoutException e) {
+ return null;
+ }
+ }
+}
diff --git a/regex/src/main/java/com/ankurm/regex/ReplaceDemo.java b/regex/src/main/java/com/ankurm/regex/ReplaceDemo.java
new file mode 100644
index 0000000..a8a55dc
--- /dev/null
+++ b/regex/src/main/java/com/ankurm/regex/ReplaceDemo.java
@@ -0,0 +1,51 @@
+package com.ankurm.regex;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** replaceAll with a replacement string, with a lambda, and the $ and \ traps. */
+public class ReplaceDemo {
+ public static void main(String[] args) {
+ String text = "price: 5 USD, fee: 12 USD";
+
+ // 1. replacement string: $1 is group 1, ${name} is a named group
+ System.out.println(Pattern.compile("(\\d+) USD").matcher(text).replaceAll("$1 dollars"));
+ System.out.println(Pattern.compile("(?\\d+) USD").matcher(text).replaceAll("USD ${n}"));
+
+ // 2. the trap: a literal dollar sign in the replacement is an error
+ try {
+ Pattern.compile("USD").matcher(text).replaceAll("$");
+ } catch (IllegalArgumentException e) {
+ System.out.println("replaceAll(\"$\") -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ System.out.println(Pattern.compile("USD").matcher(text).replaceAll(Matcher.quoteReplacement("$")));
+
+ // 3. lambda: Matcher.replaceAll(Function) computes each replacement
+ System.out.println(Pattern.compile("\\d+").matcher(text)
+ .replaceAll(r -> String.valueOf(Integer.parseInt(r.group()) * 2)));
+
+ // 4. the lambda's return value is still parsed for $ and \ -- quote it if it is data
+ Map vars = Map.of("user", "ankur", "cost", "$5");
+ Pattern tpl = Pattern.compile("\\$\\{(\\w+)}");
+ String tplText = "hello ${user}, you owe ${cost}";
+ try {
+ System.out.println(tpl.matcher(tplText).replaceAll(r -> vars.get(r.group(1))));
+ } catch (RuntimeException e) {
+ System.out.println("unquoted lambda -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ System.out.println(tpl.matcher(tplText)
+ .replaceAll(r -> Matcher.quoteReplacement(vars.get(r.group(1)))));
+
+ // 5. a missing key: decide explicitly instead of letting null through
+ System.out.println(tpl.matcher("hi ${nobody}")
+ .replaceAll(r -> Matcher.quoteReplacement(vars.getOrDefault(r.group(1), r.group()))));
+
+ // 6. before Java 9: appendReplacement / appendTail (still the way to write to a StringBuilder)
+ Matcher m = Pattern.compile("[aeiou]").matcher("regular expressions");
+ StringBuilder sb = new StringBuilder();
+ while (m.find()) m.appendReplacement(sb, m.group().toUpperCase());
+ m.appendTail(sb);
+ System.out.println(sb);
+ }
+}
diff --git a/regex/src/main/java/com/ankurm/regex/Safe.java b/regex/src/main/java/com/ankurm/regex/Safe.java
new file mode 100644
index 0000000..e4093f5
--- /dev/null
+++ b/regex/src/main/java/com/ankurm/regex/Safe.java
@@ -0,0 +1,16 @@
+package com.ankurm.regex;
+
+import java.util.regex.Pattern;
+
+/** The defensive wrapper: a length limit first, then a deadline. */
+public final class Safe {
+ private Safe() {}
+
+ public static boolean matches(Pattern p, String input, int maxLength, long budgetMillis) {
+ if (input.length() > maxLength)
+ throw new IllegalArgumentException("input longer than " + maxLength + " characters");
+ Boolean r = RegexTimeout.matchesWithin(p, input, budgetMillis);
+ if (r == null) throw new RegexTimeout.RegexTimeoutException(budgetMillis);
+ return r;
+ }
+}
diff --git a/regex/src/test/java/com/ankurm/regex/RegexClaimsTest.java b/regex/src/test/java/com/ankurm/regex/RegexClaimsTest.java
new file mode 100644
index 0000000..15dcf0a
--- /dev/null
+++ b/regex/src/test/java/com/ankurm/regex/RegexClaimsTest.java
@@ -0,0 +1,88 @@
+package com.ankurm.regex;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+
+class RegexClaimsTest {
+
+ @Test void namedGroupsAreNumberedInOrder() {
+ Matcher m = BasicsDemo.LOG.matcher("2026-03-14 ERROR disk full");
+ assertTrue(m.matches());
+ assertEquals("ERROR", m.group("level"));
+ assertEquals("ERROR", m.group(2));
+ assertEquals(2, m.namedGroups().get("level"));
+ }
+
+ @Test void matchesLookingAtFindDiffer() {
+ Pattern d = Pattern.compile("\\d+");
+ assertFalse(d.matcher("order-42").matches());
+ assertFalse(d.matcher("order-42").lookingAt());
+ assertTrue(d.matcher("order-42").find());
+ }
+
+ @Test void lookaroundsConsumeNothing() {
+ assertEquals("1,234,567", Pattern.compile("(?<=\\d)(?=(?:\\d{3})+$)").matcher("1234567").replaceAll(","));
+ Matcher m = Pattern.compile("\\d+(?=px)").matcher("120px");
+ assertTrue(m.find());
+ assertEquals("120", m.group());
+ }
+
+ @Test void lambdaReplacementIsStillParsedForDollar() {
+ Pattern tpl = Pattern.compile("\\$\\{(\\w+)}");
+ assertThrows(IndexOutOfBoundsException.class, () -> tpl.matcher("${c}").replaceAll(r -> "$5"));
+ assertEquals("$5", tpl.matcher("${c}").replaceAll(r -> Matcher.quoteReplacement("$5")));
+ assertEquals("10 USD", Pattern.compile("\\d+").matcher("5 USD")
+ .replaceAll(r -> String.valueOf(Integer.parseInt(r.group()) * 2)));
+ }
+
+ @Test void noTimeoutApiExistsOnPatternOrMatcher() {
+ for (Class> c : List.of(Pattern.class, Matcher.class))
+ for (Method m : c.getMethods())
+ assertFalse(m.getName().toLowerCase().contains("timeout"), c.getSimpleName() + "." + m.getName());
+ }
+
+ @Test void deadlineAbortsExponentialPattern() {
+ Pattern p = Pattern.compile("(?:(?:a+)+)+b");
+ long t0 = System.nanoTime();
+ assertNull(RegexTimeout.matchesWithin(p, "a".repeat(40) + "!", 300));
+ assertTrue((System.nanoTime() - t0) / 1_000_000 < 3000, "abort should come soon after the 300 ms budget");
+ }
+
+ @Test void backreferenceDisablesTheJdkOptimisation() {
+ // same nested quantifier; 40 chars is hopeless with a backreference, instant without
+ assertEquals(Boolean.FALSE, RegexTimeout.matchesWithin(Pattern.compile("(a+)+b"), "a".repeat(40) + "!", 1000));
+ assertNull(RegexTimeout.matchesWithin(Pattern.compile("(a+)+\\1?b"), "a".repeat(40) + "!", 300));
+ }
+
+ @Test void fixesAcceptTheSameLanguageOnShortInputs() {
+ String[] res = {"(?:(?:a+)+)+b", "a+b", "(?:a++)++b", "(?>(?:a+)+)b"};
+ for (String in : List.of("b", "ab", "aaab", "aaa", "aabb", "ba", ""))
+ for (String re : res)
+ assertEquals(Pattern.matches(res[0], in), Pattern.matches(re, in), re + " on '" + in + "'");
+ }
+
+ @Test void fixesFinishInstantlyOnHostileInput() {
+ String hostile = "a".repeat(40) + "!";
+ for (String re : new String[] {"a+b", "(?:a++)++b", "(?>(?:a+)+)b"})
+ assertEquals(Boolean.FALSE, RegexTimeout.matchesWithin(Pattern.compile(re), hostile, 1000), re);
+ }
+
+ @Test void lengthLimitRejectsBeforeMatching() {
+ assertThrows(IllegalArgumentException.class, () -> Safe.matches(Pattern.compile("a+b"), "a".repeat(101), 100, 500));
+ assertTrue(Safe.matches(Pattern.compile("a+b"), "aab", 100, 500));
+ assertThrows(RegexTimeout.RegexTimeoutException.class,
+ () -> Safe.matches(Pattern.compile("(?:(?:a+)+)+b"), "a".repeat(40) + "!", 100, 200));
+ }
+
+ @Test void alternationInLoopOverflowsTheStackButClassAndPossessiveDoNot() {
+ String s = "ab".repeat(10_000) + "c";
+ assertThrows(StackOverflowError.class, () -> Pattern.compile("(?:a|b)*c").matcher(s).matches());
+ assertTrue(Pattern.compile("[ab]*c").matcher(s).matches());
+ assertTrue(Pattern.compile("(?:a|b)*+c").matcher(s).matches());
+ }
+}