Skip to content

Commit 1558848

Browse files
committed
fix(core): enforce absolute boundary validation in factories
- Prevented composition of absolute anchored patterns in SiftPatterns. - Sealed nested group assembly to avoid silent logical boundaries collision.
1 parent e46678e commit 1558848

File tree

8 files changed

+189
-11
lines changed

8 files changed

+189
-11
lines changed

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ private enum QuantifierModifier {
4343
private boolean canModifyMain = false;
4444
private final Set<String> registeredGroups = new HashSet<>();
4545
private final Set<String> requiredBackreferences = new HashSet<>();
46-
46+
private boolean containsAbsoluteAnchor = false;
4747
PatternAssembler() {
4848
}
4949

@@ -63,12 +63,21 @@ Set<String> getRequiredBackreferences() {
6363
return requiredBackreferences;
6464
}
6565

66+
public boolean isContainsAbsoluteAnchor() {
67+
return containsAbsoluteAnchor;
68+
}
69+
6670
void setQuantifier(String quantifier) {
6771
this.currentQuantifier = quantifier;
6872
}
6973

7074
void addAnchor(String anchor) {
7175
flush();
76+
77+
if (RegexSyntax.START_OF_LINE.equals(anchor) || RegexSyntax.END_OF_LINE.equals(anchor)){
78+
containsAbsoluteAnchor = true;
79+
}
80+
7281
mainPattern.append(anchor);
7382
}
7483

@@ -145,6 +154,12 @@ void addCharacter(char literal) {
145154
}
146155

147156
void addPattern(SiftPattern pattern) {
157+
if (pattern.hasAbsoluteBoundaries()) {
158+
throw new IllegalStateException(
159+
"Composition Error: Cannot embed a pattern that contains absolute boundaries (like fromStart() or andNothingElse()). " +
160+
"Reusable blocks must be created using fromAnywhere()."
161+
);
162+
}
148163
flush();
149164
extractAndCheckGroupsAndRequirements(pattern, null);
150165

@@ -277,6 +292,7 @@ PatternAssembler copy() {
277292
clone.canModifyMain = this.canModifyMain;
278293
clone.registeredGroups.addAll(this.registeredGroups);
279294
clone.requiredBackreferences.addAll(this.requiredBackreferences);
295+
clone.containsAbsoluteAnchor = this.containsAbsoluteAnchor;
280296
return clone;
281297
}
282298

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ public void preventExternalImplementation(InternalToken token) {
131131
}
132132
}
133133

134+
@Override
135+
public boolean hasAbsoluteBoundaries() {
136+
return assembler.isContainsAbsoluteAnchor();
137+
}
138+
134139
@Override
135140
public String toString() {
136141
return shake();

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,21 @@ private SiftPatterns() {
4646
* @param option2 The second mandatory alternative.
4747
* @param additionalOptions Any further alternative patterns.
4848
* @return A composable {@link SiftPattern} representing the logical OR.
49+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
50+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
51+
* Reusable blocks must be unanchored.
4952
*/
5053
public static SiftPattern anyOf(SiftPattern option1, SiftPattern option2, SiftPattern... additionalOptions) {
5154
Objects.requireNonNull(option1, "First option cannot be null");
5255
Objects.requireNonNull(option2, "Second option cannot be null");
5356
Objects.requireNonNull(additionalOptions, "Additional options array cannot be null");
5457

58+
requireUnanchored(option1);
59+
requireUnanchored(option2);
60+
5561
for (SiftPattern opt : additionalOptions) {
5662
Objects.requireNonNull(opt, "Additional option cannot be null");
63+
requireUnanchored(opt);
5764
}
5865

5966
return memoize(() -> {
@@ -80,6 +87,9 @@ public static SiftPattern anyOf(SiftPattern option1, SiftPattern option2, SiftPa
8087
* @param patterns A list of SiftPatterns.
8188
* @return A SiftPattern combining the provided options.
8289
* @throws IllegalArgumentException if the list is null or empty.
90+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
91+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
92+
* Reusable blocks must be unanchored.
8393
*/
8494
public static SiftPattern anyOf(java.util.List<? extends SiftPattern> patterns) {
8595
if (patterns == null || patterns.isEmpty()) {
@@ -89,6 +99,10 @@ public static SiftPattern anyOf(java.util.List<? extends SiftPattern> patterns)
8999
return patterns.get(0); // QoL Optimization: no need to wrap a single element
90100
}
91101

102+
for (SiftPattern pattern: patterns) {
103+
requireUnanchored(pattern);
104+
}
105+
92106
return memoize(() -> {
93107
StringBuilder sb = new StringBuilder();
94108
sb.append(RegexSyntax.NON_CAPTURING_GROUP_OPEN);
@@ -117,9 +131,13 @@ public static SiftPattern anyOf(java.util.List<? extends SiftPattern> patterns)
117131
*
118132
* @param pattern The pattern to capture.
119133
* @return A SiftPattern wrapped in parentheses.
134+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
135+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
136+
* Reusable blocks must be unanchored.
120137
*/
121138
public static SiftPattern capture(SiftPattern pattern) {
122139
Objects.requireNonNull(pattern, "Pattern to capture cannot be null");
140+
requireUnanchored(pattern);
123141
return memoize(() -> RegexSyntax.GROUP_OPEN + pattern.shake() + RegexSyntax.GROUP_CLOSE);
124142
}
125143

@@ -140,9 +158,13 @@ public static SiftPattern capture(SiftPattern pattern) {
140158
*
141159
* @param pattern The pattern that must follow.
142160
* @return A SiftPattern representing the positive lookahead.
161+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
162+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
163+
* Reusable blocks must be unanchored.
143164
*/
144165
public static SiftPattern positiveLookahead(SiftPattern pattern) {
145166
Objects.requireNonNull(pattern, "Lookahead pattern cannot be null");
167+
requireUnanchored(pattern);
146168
return memoize(() -> RegexSyntax.POSITIVE_LOOKAHEAD_OPEN + pattern.shake() + RegexSyntax.GROUP_CLOSE);
147169
}
148170

@@ -163,9 +185,13 @@ public static SiftPattern positiveLookahead(SiftPattern pattern) {
163185
*
164186
* @param pattern The pattern that must not follow.
165187
* @return A SiftPattern representing the negative lookahead.
188+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
189+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
190+
* Reusable blocks must be unanchored.
166191
*/
167192
public static SiftPattern negativeLookahead(SiftPattern pattern) {
168193
Objects.requireNonNull(pattern, "Lookahead pattern cannot be null");
194+
requireUnanchored(pattern);
169195
return memoize(() -> RegexSyntax.NEGATIVE_LOOKAHEAD_OPEN + pattern.shake() + RegexSyntax.GROUP_CLOSE);
170196
}
171197

@@ -186,9 +212,13 @@ public static SiftPattern negativeLookahead(SiftPattern pattern) {
186212
*
187213
* @param pattern The pattern that must precede.
188214
* @return A SiftPattern representing the positive lookbehind.
215+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
216+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
217+
* Reusable blocks must be unanchored.
189218
*/
190219
public static SiftPattern positiveLookbehind(SiftPattern pattern) {
191220
Objects.requireNonNull(pattern, "Lookbehind pattern cannot be null");
221+
requireUnanchored(pattern);
192222
return memoize(() -> RegexSyntax.POSITIVE_LOOKBEHIND_OPEN + pattern.shake() + RegexSyntax.GROUP_CLOSE);
193223
}
194224

@@ -209,9 +239,13 @@ public static SiftPattern positiveLookbehind(SiftPattern pattern) {
209239
*
210240
* @param pattern The pattern that must not precede.
211241
* @return A SiftPattern representing the negative lookbehind.
242+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
243+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
244+
* Reusable blocks must be unanchored.
212245
*/
213246
public static SiftPattern negativeLookbehind(SiftPattern pattern) {
214247
Objects.requireNonNull(pattern, "Lookbehind pattern cannot be null");
248+
requireUnanchored(pattern);
215249
return memoize(() -> RegexSyntax.NEGATIVE_LOOKBEHIND_OPEN + pattern.shake() + RegexSyntax.GROUP_CLOSE);
216250
}
217251

@@ -225,9 +259,13 @@ public static SiftPattern negativeLookbehind(SiftPattern pattern) {
225259
* @param pattern The pattern to capture within this group.
226260
* @return A NamedCapture definition.
227261
* @throws IllegalArgumentException if {@code groupName} is null, empty, starts with a digit, or contains non-alphanumeric characters (e.g., spaces, underscores, or symbols).
262+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
263+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
264+
* Reusable blocks must be unanchored.
228265
*/
229266
public static NamedCapture capture(String groupName, SiftPattern pattern) {
230267
Objects.requireNonNull(pattern, "Pattern to capture cannot be null");
268+
requireUnanchored(pattern);
231269
GroupName validatedName = GroupName.of(groupName);
232270
return new NamedCapture(validatedName, pattern);
233271
}
@@ -241,13 +279,19 @@ public static NamedCapture capture(String groupName, SiftPattern pattern) {
241279
* @param first The first required pattern.
242280
* @param then Optional additional patterns to include in the same group.
243281
* @return A SiftPattern representing the concatenated non-capturing group.
282+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
283+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
284+
* Reusable blocks must be unanchored.
244285
*/
245286
public static SiftPattern group(SiftPattern first, SiftPattern... then) {
246287
Objects.requireNonNull(first, "First pattern in group cannot be null");
247288
Objects.requireNonNull(then, "Additional patterns array cannot be null");
248289

290+
requireUnanchored(first);
291+
249292
for (SiftPattern opt : then) {
250293
Objects.requireNonNull(opt, "Additional option cannot be null");
294+
requireUnanchored(opt);
251295
}
252296

253297
return memoize(() -> {
@@ -370,6 +414,25 @@ public Pattern sieve() {
370414
public void preventExternalImplementation(InternalToken unused) {
371415
// unused intentionally to prevent external implementations
372416
}
417+
418+
@Override
419+
public boolean hasAbsoluteBoundaries() {
420+
// SiftPatterns components are explicitly validated to be unanchored
421+
// before this memoized instance is created.
422+
return false;
423+
}
373424
};
374425
}
426+
427+
/**
428+
* Internal security check to prevent nesting of anchored patterns.
429+
*/
430+
private static void requireUnanchored(SiftPattern pattern) {
431+
if (pattern.hasAbsoluteBoundaries()) {
432+
throw new IllegalStateException(
433+
"Composition Error: Cannot embed a pattern that contains absolute boundaries " +
434+
"(like fromStart() or andNothingElse()). Reusable blocks must be unanchored."
435+
);
436+
}
437+
}
375438
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ public interface ConnectorStep extends SiftPattern {
5959
* @param pattern The first mandatory sub-pattern to append.
6060
* @param additionalPatterns Any further sub-patterns to append in the specified order.
6161
* @return The current connector step, allowing immediate chaining.
62+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
63+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
64+
* Reusable blocks must be unanchored.
6265
*/
6366
ConnectorStep followedBy(SiftPattern pattern, SiftPattern... additionalPatterns);
6467

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ public void preventExternalImplementation(InternalToken unused) {
8484
// Intentionally left blank. Ensures this anonymous class
8585
// complies with the internal Sift interface contract.
8686
}
87+
88+
@Override
89+
public boolean hasAbsoluteBoundaries() {
90+
return SiftPattern.this.hasAbsoluteBoundaries();
91+
}
8792
};
8893
}
8994

@@ -126,4 +131,12 @@ default boolean matches(CharSequence input) {
126131
* <b>Strictly for internal Sift API use. Do not call or implement.</b>
127132
*/
128133
void preventExternalImplementation(InternalToken token);
134+
135+
/**
136+
* Indicates whether this pattern contains absolute boundaries (^ or $).
137+
* Patterns with absolute boundaries cannot be embedded inside other patterns.
138+
*/
139+
default boolean hasAbsoluteBoundaries() {
140+
return false;
141+
}
129142
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ public interface TypeStep<T extends ConnectorStep, C extends CharacterClassConne
6262
*
6363
* @param pattern The sub-pattern to apply the quantifier to.
6464
* @return The standard connector step to continue building.
65-
*/
65+
* @throws IllegalStateException if the provided pattern contains absolute boundaries
66+
* (e.g., created with {@code fromStart()} or closed with {@code andNothingElse()}).
67+
* Reusable blocks must be unanchored.
68+
* */
6669
T pattern(SiftPattern pattern);
6770

6871
/**

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,4 +393,19 @@ void testPatternMatchesConvenienceMethod() {
393393
assertTrue(pattern.matches(sbInput),
394394
"Should natively support other CharSequence implementations like StringBuilder without allocations");
395395
}
396+
397+
@Test
398+
@DisplayName("Should throw exception when passing anchored pattern to SiftPatterns factories")
399+
void shouldThrowWhenPassingAnchoredPatternToFactories() {
400+
SiftPattern radioActivePattern = Sift.fromStart().exactly(3).digits();
401+
402+
IllegalStateException exception = assertThrows(IllegalStateException.class, () ->
403+
SiftPatterns.positiveLookahead(radioActivePattern));
404+
405+
assertTrue(exception.getMessage().contains("absolute boundaries"),
406+
"Exception message should indicate the boundary violation.");
407+
408+
assertThrows(IllegalStateException.class, () ->
409+
SiftPatterns.capture("invalidGroup", radioActivePattern));
410+
}
396411
}

0 commit comments

Comments
 (0)