regex: java.util.regex companion code (groups, lookarounds, replaceAll lambdas, ReDoS and timeouts)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KqJyCidz3ZgRyHABv2GVJh
This commit is contained in:
@@ -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(
|
||||
"(?<date>\\d{4}-\\d{2}-\\d{2}) (?<level>INFO|WARN|ERROR) (?<msg>.+)");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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", "(?<!a)a+b"})
|
||||
for (int n : new int[] {5_000, 10_000, 20_000}) {
|
||||
String s = "a".repeat(n) + "!";
|
||||
long t0 = System.nanoTime();
|
||||
boolean found = Pattern.compile(re).matcher(s).find();
|
||||
System.out.printf("find() %-10s n=%-6d %4d ms found=%s%n", re, n, (System.nanoTime() - t0) / 1_000_000, found);
|
||||
}
|
||||
}
|
||||
|
||||
static void run(String label, String regex, String input) {
|
||||
long t0 = System.nanoTime();
|
||||
Boolean r = RegexTimeout.matchesWithin(Pattern.compile(regex), input, 1000);
|
||||
long ms = (System.nanoTime() - t0) / 1_000_000;
|
||||
System.out.printf("%s %s%n", label, r == null ? "ABORTED after " + ms + " ms" : ms + " ms matches=" + r);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.regex;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Two non-exponential ways a regex call can still hurt: deep recursion and oversized input. */
|
||||
public class LimitsDemo {
|
||||
public static void main(String[] args) {
|
||||
Pattern alt = Pattern.compile("(?:a|b)*c");
|
||||
for (int n : new int[] {1_000, 5_000, 20_000, 100_000}) {
|
||||
String s = "ab".repeat(n / 2) + "c";
|
||||
try {
|
||||
System.out.println("(?:a|b)*c length " + n + " -> " + 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("(?<![\\d$.])\\d+", "cost $19.99, tax 3, total $23");
|
||||
|
||||
// several lookaheads at one position = AND of conditions: a password policy
|
||||
Pattern policy = Pattern.compile("^(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$");
|
||||
for (String pw : List.of("abcdefgh", "Abcdefg1", "Ab1", "ABCDEFG1x"))
|
||||
System.out.printf("policy %-10s -> %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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("(?<n>\\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<MatchResult,String>) 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<String, String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user