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()); + } +}