Skip to content

Commit 31cc23f

Browse files
committed
feat(core): enhance Developer Experience with fail-fast guards and convenience methods
- Prevented meaningless regex generation by throwing IllegalArgumentException on between(0,0) and atMost(0). - Added anyOf(List) overload to support dynamic regex generation, optimizing single-element lists. - Exposed andNothingElse() to VariableConnectorStep and VariableCharacterClassConnectorStep interfaces to avoid spurious .then() calls. - Introduced a zero-cost default matches(CharSequence) method in SiftPattern for immediate validation without verbose matcher chaining.
1 parent f43702c commit 31cc23f

File tree

7 files changed

+160
-1
lines changed

7 files changed

+160
-1
lines changed

sift-core/src/main/java/com/mirkoddd/sift/core/SiftPatterns.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,35 @@ public static SiftPattern anyOf(SiftPattern option1, SiftPattern option2, SiftPa
7373
});
7474
}
7575

76+
/**
77+
* Creates a non-capturing group matching any of the provided patterns dynamically.
78+
*
79+
* @param patterns A list of SiftPatterns.
80+
* @return A SiftPattern combining the provided options.
81+
* @throws IllegalArgumentException if the list is null or empty.
82+
*/
83+
public static SiftPattern anyOf(java.util.List<? extends SiftPattern> patterns) {
84+
if (patterns == null || patterns.isEmpty()) {
85+
throw new IllegalArgumentException("anyOf() requires at least one pattern in the list.");
86+
}
87+
if (patterns.size() == 1) {
88+
return patterns.get(0); // QoL Optimization: no need to wrap a single element
89+
}
90+
91+
return () -> {
92+
StringBuilder sb = new StringBuilder();
93+
sb.append(RegexSyntax.NON_CAPTURING_GROUP_OPEN);
94+
for (int i = 0; i < patterns.size(); i++) {
95+
sb.append(patterns.get(i).shake());
96+
if (i < patterns.size() - 1) {
97+
sb.append(RegexSyntax.OR);
98+
}
99+
}
100+
sb.append(RegexSyntax.GROUP_CLOSE);
101+
return sb.toString();
102+
};
103+
}
104+
76105
/**
77106
* Wraps a pattern in a <b>Capturing Group</b> {@code (...)}.
78107
* <p>

sift-core/src/main/java/com/mirkoddd/sift/core/SiftQuantifier.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,16 @@ public TypeStep<VariableConnectorStep, VariableCharacterClassConnectorStep> opti
6969

7070
@Override
7171
public TypeStep<VariableConnectorStep, VariableCharacterClassConnectorStep> atMost(int max) {
72-
if (max <= 0) throw new IllegalArgumentException("Max quantity must be strictly positive: " + max);
72+
if (max == 0) throw new IllegalArgumentException("atMost(0) is invalid as it always matches an empty string.");
73+
if (max < 0) throw new IllegalArgumentException("Max quantity cannot be negative: " + max);
7374
PatternAssembler next = assembler.copy();
7475
next.setQuantifier(RegexSyntax.QUANTIFIER_OPEN + "0" + RegexSyntax.COMMA + max + RegexSyntax.QUANTIFIER_CLOSE);
7576
return new SiftVariableType(next);
7677
}
7778

7879
@Override
7980
public TypeStep<VariableConnectorStep, VariableCharacterClassConnectorStep> between(int min, int max) {
81+
if (min == 0 && max == 0) throw new IllegalArgumentException("between(0, 0) is invalid as it always matches an empty string.");
8082
if (min < 0) throw new IllegalArgumentException("Min quantity cannot be negative: " + min);
8183
if (max <= 0) throw new IllegalArgumentException("Max quantity must be strictly positive: " + max);
8284
if (min > max) throw new IllegalArgumentException("Min cannot be greater than max");

sift-core/src/main/java/com/mirkoddd/sift/core/dsl/SiftPattern.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,18 @@ public Pattern sieve() {
100100
default Pattern sieve() {
101101
return Pattern.compile(this.shake());
102102
}
103+
104+
/**
105+
* Convenience method to quickly test if a given input matches this pattern completely.
106+
* Under the hood, this compiles the regex (or uses the cached one) and evaluates it.
107+
*
108+
* @param input The text to evaluate.
109+
* @return true if the entire input matches the pattern, false otherwise or if input is null.
110+
*/
111+
default boolean matches(CharSequence input) {
112+
if (input == null) {
113+
return false;
114+
}
115+
return sieve().matcher(input).matches();
116+
}
103117
}

sift-core/src/main/java/com/mirkoddd/sift/core/dsl/VariableCharacterClassConnectorStep.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,16 @@ public interface VariableCharacterClassConnectorStep extends VariableConnectorSt
3030

3131
@Override
3232
VariableCharacterClassConnectorStep excluding(char excluded, char... additionalExcluded);
33+
34+
/**
35+
* Anchors the pattern to the end of the input string using {@code $}.
36+
* <p>
37+
* Use this terminal operation when you want to ensure that there are no trailing
38+
* characters after the matched sequence. Calling this method immediately concludes
39+
* the fluent chain and returns the final executable pattern.
40+
*
41+
* @return The final {@link SiftPattern} ready for evaluation.
42+
*/
43+
@Override
44+
SiftPattern andNothingElse();
3345
}

sift-core/src/main/java/com/mirkoddd/sift/core/dsl/VariableConnectorStep.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,16 @@ public interface VariableConnectorStep extends ConnectorStep {
5151
* @return A standard {@link ConnectorStep}, as lazy modifiers cannot be stacked.
5252
*/
5353
ConnectorStep asFewAsPossible();
54+
55+
/**
56+
* Anchors the pattern to the end of the input string using {@code $}.
57+
* <p>
58+
* Use this terminal operation when you want to ensure that there are no trailing
59+
* characters after the matched sequence. Calling this method immediately concludes
60+
* the fluent chain and returns the final executable pattern.
61+
*
62+
* @return The final {@link SiftPattern} ready for evaluation.
63+
*/
64+
@Override
65+
SiftPattern andNothingElse();
5466
}

sift-core/src/test/java/com/mirkoddd/sift/core/SiftPatternsTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,65 @@ void shouldMemoizeShakeAndSieveResults() {
266266
assertEquals("cache-test", firstSieve.pattern());
267267
assertSame(firstSieve, secondSieve, "sieve() should return the exact same Pattern instance from cache.");
268268
}
269+
270+
@Test
271+
@DisplayName("anyOf(List) should throw IllegalArgumentException for null or empty lists")
272+
void testAnyOfListNullOrEmpty() {
273+
IllegalArgumentException nullEx = assertThrows(IllegalArgumentException.class,
274+
() -> SiftPatterns.anyOf((java.util.List<SiftPattern>) null));
275+
assertTrue(nullEx.getMessage().contains("requires at least one pattern"));
276+
277+
IllegalArgumentException emptyEx = assertThrows(IllegalArgumentException.class,
278+
() -> SiftPatterns.anyOf(java.util.Collections.emptyList()));
279+
assertTrue(emptyEx.getMessage().contains("requires at least one pattern"));
280+
}
281+
282+
@Test
283+
@DisplayName("anyOf(List) should optimize single-element lists by avoiding unnecessary grouping")
284+
void testAnyOfListSingleElementOptimization() {
285+
SiftPattern singlePattern = Sift.fromStart().digits();
286+
287+
SiftPattern result = SiftPatterns.anyOf(java.util.Collections.singletonList(singlePattern));
288+
289+
assertEquals("^[0-9]", result.shake(),
290+
"Should return the exact pattern without the (?:...) wrapper overhead");
291+
}
292+
293+
@Test
294+
@DisplayName("anyOf(List) should wrap multiple elements in a non-capturing OR group")
295+
void testAnyOfListMultipleElements() {
296+
SiftPattern p1 = Sift.fromAnywhere().letters();
297+
SiftPattern p2 = Sift.fromAnywhere().digits();
298+
SiftPattern p3 = Sift.fromAnywhere().character('-');
299+
300+
SiftPattern result = SiftPatterns.anyOf(java.util.Arrays.asList(p1, p2, p3));
301+
302+
assertEquals("(?:[a-zA-Z]|[0-9]|-)", result.shake(),
303+
"Should accurately wrap multiple patterns separated by the OR operator");
304+
}
305+
306+
@Test
307+
@DisplayName("matches(CharSequence) should correctly evaluate inputs, handle nulls, and support StringBuilders")
308+
void testPatternMatchesConvenienceMethod() {
309+
SiftPattern pattern = Sift.fromStart().exactly(3).digits().andNothingElse();
310+
311+
// Branch 1: Null safety
312+
assertFalse(pattern.matches(null),
313+
"Should gracefully return false when the input is null, avoiding NullPointerException");
314+
315+
// Branch 2: Valid match (Standard String)
316+
assertTrue(pattern.matches("123"),
317+
"Should return true for a string that perfectly matches the pattern");
318+
319+
// Branch 3: Invalid match
320+
assertFalse(pattern.matches("12a"),
321+
"Should return false for a string with invalid characters");
322+
assertFalse(pattern.matches("1234"),
323+
"Should return false for a string that exceeds the exact length bounds");
324+
325+
// Branch 4: CharSequence polymorphism (Zero-cost abstraction)
326+
StringBuilder sbInput = new StringBuilder("987");
327+
assertTrue(pattern.matches(sbInput),
328+
"Should natively support other CharSequence implementations like StringBuilder without allocations");
329+
}
269330
}

sift-core/src/test/java/com/mirkoddd/sift/core/SiftTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,35 @@ void testQuantifierExceptions() {
257257
assertThrows(IllegalArgumentException.class, () -> fromAnywhere().between(5, 2)); // min > max
258258
}
259259

260+
@Test
261+
@DisplayName("atMost() and between() should exhaustively validate boundaries and throw IllegalArgumentException")
262+
void testQuantifierBoundaryGuards() {
263+
// --- atMost() exhaustive branches ---
264+
assertThrows(IllegalArgumentException.class, () -> Sift.fromStart().atMost(0),
265+
"Should block atMost(0)");
266+
assertThrows(IllegalArgumentException.class, () -> Sift.fromStart().atMost(-1),
267+
"Should block negative max values in atMost()");
268+
269+
// --- between() exhaustive branches ---
270+
assertThrows(IllegalArgumentException.class, () -> Sift.fromStart().between(0, 0),
271+
"Should block between(0, 0) specifically");
272+
273+
assertThrows(IllegalArgumentException.class, () -> Sift.fromStart().between(-1, 5),
274+
"Should block negative min values");
275+
276+
assertThrows(IllegalArgumentException.class, () -> Sift.fromStart().between(2, 0),
277+
"Should block max <= 0 (e.g., 0 with a valid min)");
278+
279+
assertThrows(IllegalArgumentException.class, () -> Sift.fromStart().between(2, -1),
280+
"Should block max <= 0 (e.g., negative max with a valid min)");
281+
282+
assertThrows(IllegalArgumentException.class, () -> Sift.fromStart().between(5, 2),
283+
"Should block when min is strictly greater than max");
284+
285+
assertDoesNotThrow(() -> Sift.fromStart().between(0, 5),
286+
"Should successfully allow min=0 as long as max > 0 (e.g., {0,5})");
287+
}
288+
260289
@Test
261290
@DisplayName("Should correctly generate regex for wordChars, whitespace, optionally, and andNothingElse")
262291
void testNewTypesAndTerminals() {

0 commit comments

Comments
 (0)