Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion Dummies.UnitTests/AnyPatternTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ private static string Display(string value) {
return "\"" + value.Replace("\t", "\\t").Replace("\n", "\\n").Replace("\r", "\\r") + "\"";
}

// The oracle again, as a predicate: does the real .NET engine compile this pattern at all? Used to assert the
// rejection taxonomy — a pattern the engine accepts must never be reported as malformed (ArgumentException).
private static bool IsCompiledByTheRealEngine(string pattern) {
try {
_ = new Regex(pattern);

return true;
} catch (ArgumentException) {
return false;
}
}

#endregion

[Theory(DisplayName = "Every generated value is fully matched by the real .NET regex engine.")]
Expand All @@ -50,6 +62,14 @@ private static string Display(string value) {
[InlineData(@"(?<10>ab)")] // a multi-digit group number stays valid
[InlineData(@"(?<a1>xy)")] // a named group whose name merely contains digits stays valid
[InlineData(@"^a$|^b$")]
[InlineData(@"^^abc")] // a run of boundary anchors is a no-op, exactly as in the real engine
[InlineData(@"abc$$")]
[InlineData(@"^*abc")] // a quantifier on a zero-width anchor is a no-op too
[InlineData(@"abc$*")]
[InlineData(@"^{2}xy")]
[InlineData(@"^?abc$?")]
[InlineData(@"[-[x]]")] // a leading '-' is an ordinary hyphen, not a subtraction operator
[InlineData(@"[-[abc]]")]
[InlineData(@"[\d]{3}")]
[InlineData(@"[-a-z]{2}")]
[InlineData(@"[a-z-]{2}")]
Expand Down Expand Up @@ -157,6 +177,73 @@ public void AnchorsAreNoOps() {
}
}

[Fact(DisplayName = "Repeated and quantified boundary anchors are no-ops, consistently for '^' and '$'.")]
public void RepeatedAndQuantifiedAnchorsAreNoOps() {
// '^^abc' was already accepted while the symmetric 'abc$$' was refused — an avoidable asymmetry, now closed.
// Repeating or quantifying a zero-width boundary assertion never changes which strings match, so all of
// these are no-ops, exactly as the real engine treats them.
Check.That(Any.StringMatching(@"^^abc$$").Generate()).IsEqualTo("abc");
Check.That(Any.StringMatching(@"^*abc$*").Generate()).IsEqualTo("abc");
Check.That(Any.StringMatching(@"^{2}abc").Generate()).IsEqualTo("abc");
Check.That(Any.StringMatching(@"^?abc$?").Generate()).IsEqualTo("abc");
}

[Fact(DisplayName = "A pattern that overruns the generation ceiling fails with an honest message, naming no false cause.")]
public void OverLimitPatternFailsWithoutAFalseCause() {
// '(a{1000}){1000}' deterministically asks for 1,000,000 characters — every quantifier is bounded, there is
// no unbounded quantifier at all. The pattern parses fine (a resource ceiling is not a satisfiability
// conflict); generation is what overruns the limit, and the message must not blame a quantifier that is absent.
AnyPattern generator = Any.StringMatching(@"(a{1000}){1000}");

AnyGenerationException caught = Assert.Throws<AnyGenerationException>(() => generator.Generate());
Check.That(caught.Message).Contains("generation limit");
Check.That(caught.Message).Contains("bounded quantifiers"); // the real cause is offered
Check.That(caught.Message).Not.Contains("is expanding without bound"); // the old, false assertion is gone
}

[Fact(DisplayName = "A negated class that excludes the whole printable universe is refused as unsupported, not malformed.")]
public void NegatedClassExcludingTheUniverseIsUnsupported() {
// Well-formed and regular — the real engine accepts both — but no printable-ASCII character survives the
// negation, so Dummies cannot draw a value. That is a universe limit (unsupported), not a caller mistake
// (malformed): the pattern is not broken, Dummies simply does not reach outside printable ASCII.
Check.ThatCode(() => Any.StringMatching(@"[^\x20-\x7E]")).Throws<UnsupportedRegexException>();
Check.ThatCode(() => Any.StringMatching(@"[^\s\S]")).Throws<UnsupportedRegexException>();

UnsupportedRegexException caught = Assert.Throws<UnsupportedRegexException>(() => Any.StringMatching(@"[^\x20-\x7E]"));
Check.That(caught.Message).Contains("printable ASCII");
}

[Theory(DisplayName = "A pattern the real .NET engine accepts is never rejected as malformed — it generates, or is refused as unsupported.")]
[InlineData(@"^^abc")]
[InlineData(@"abc$$")]
[InlineData(@"^*abc")]
[InlineData(@"abc$*")]
[InlineData(@"^{2}xy")]
[InlineData(@"^?a$?")]
[InlineData(@"[-[x]]")]
[InlineData(@"[a-[x]]")] // subtraction (member precedes): .NET-valid, refused as UNSUPPORTED, not malformed
[InlineData(@"[ab-[b]]")]
[InlineData(@"[^\x20-\x7E]")] // negated-empty: .NET-valid, refused as UNSUPPORTED
[InlineData(@"[^\s\S]")]
[InlineData(@"(a{1000}){1000}")] // over-limit: .NET-valid, fails at generation time, never as malformed
public void PatternsAcceptedByTheRealEngineAreNeverMalformed(string pattern) {
// The advertised taxonomy (see RegexParser's summary): ArgumentException == "the real engine rejects this
// pattern as malformed". So a pattern the real engine COMPILES must never surface here as ArgumentException —
// Dummies must either generate a matching value, or refuse it as UnsupportedRegexException, or fail the
// generation itself. This guards the whole channel, not just the individual edges #210 corrected.
Assert.True(IsCompiledByTheRealEngine(pattern), $"test precondition: /{pattern}/ must be accepted by .NET");

try {
Any.WithSeed(1).StringMatching(pattern).Generate();
} catch (ArgumentException e) {
Assert.Fail($"/{pattern}/ is accepted by the real engine but Dummies rejected it as malformed: {e.Message}");
} catch (UnsupportedRegexException) {
// acceptable: refused as unsupported (a construct or universe Dummies declines), not as malformed
} catch (AnyGenerationException) {
// acceptable: a resource-limit overrun, not a verdict that the pattern is malformed
}
}

[Fact(DisplayName = "A Regex with IgnoreCase generates either case.")]
public void IgnoreCaseHonoured() {
Regex pattern = new("^[a-z]{5}$", RegexOptions.IgnoreCase);
Expand Down Expand Up @@ -218,8 +305,12 @@ public void NotGeneratableConstructsAreRefused() {
Check.ThatCode(() => Any.StringMatching(@"x(^a)")).Throws<UnsupportedRegexException>();
Check.ThatCode(() => Any.StringMatching(@"(a$)x")).Throws<UnsupportedRegexException>();

// .NET class subtraction removes a nested class; parsing '-[' as members would generate outside the set.
// .NET class subtraction removes a nested class; parsing '-[' as members would generate outside the set. It
// is subtraction only when a base member precedes the '-[' — after a range ('[a-z-[aeiou]]') or a single
// member ('[a-[x]]', '[ab-[b]]'). A leading '-[' is an ordinary hyphen and IS accepted (see the oracle theory).
Check.ThatCode(() => Any.StringMatching(@"[a-z-[aeiou]]")).Throws<UnsupportedRegexException>();
Check.ThatCode(() => Any.StringMatching(@"[a-[x]]")).Throws<UnsupportedRegexException>();
Check.ThatCode(() => Any.StringMatching(@"[ab-[b]]")).Throws<UnsupportedRegexException>();

// IgnorePatternWhitespace changes how the pattern text itself is read: "^A B$" matches "AB", not "A B".
Check.ThatCode(() => Any.StringMatching(new Regex("^A B$", RegexOptions.IgnorePatternWhitespace))).Throws<ArgumentException>();
Expand Down
9 changes: 5 additions & 4 deletions Dummies/RegexNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

/// <summary>
/// The carrier of a single generation: the seeded random generator to draw from, and the buffer the nodes write
/// into. <see cref="Append" /> enforces a hard length ceiling so a nested unbounded quantifier
/// (<c>(a+)+</c> and the like) can never expand without bound — the value is built directly, never generated then
/// retried, but the buffer is still guarded.
/// into. <see cref="Append" /> enforces a hard length ceiling so no pattern can expand the buffer without bound —
/// whether through a nested unbounded quantifier (<c>(a+)+</c> and the like) or through bounded quantifiers whose
/// product is very large (<c>(a{1000}){1000}</c>). The value is built directly, never generated then retried, but
/// the buffer is still guarded.
/// </summary>
internal sealed class RegexGenerationContext {

Expand All @@ -30,7 +31,7 @@

internal void Append(char character) {
if (_builder.Length >= _limit) {
throw new AnyGenerationException($"The pattern produced a string longer than the {_limit}-character generation limit; a nested unbounded quantifier is expanding without bound.");
throw new AnyGenerationException($"The pattern produced a string longer than the {_limit}-character generation limit. This ceiling guards against runaway expansion; a pattern can reach it either through a nested unbounded quantifier (such as \"(a+)+\") or through bounded quantifiers whose product is very large (such as \"(a{{1000}}){{1000}}\").");
}

_builder.Append(character);
Expand All @@ -47,7 +48,7 @@
/// stands for into the <see cref="RegexGenerationContext" />, drawing counts and choices from the seeded random
/// generator — so the whole tree yields exactly one string that matches the pattern, in one pass.
/// </summary>
internal abstract class RegexNode {

Check warning on line 51 in Dummies/RegexNode.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Convert this 'abstract' class to an interface.

internal abstract void Append(RegexGenerationContext context);

Expand Down
62 changes: 46 additions & 16 deletions Dummies/RegexParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
/// <c>? * + {n} {n,} {n,m}</c> (a lazy <c>?</c> marker is accepted and ignored — it changes matching order, never
/// which strings match; possessive quantifiers do not exist in .NET and are rejected); alternation; grouping
/// (capturing, non-capturing and named — the name is validated as the real engine would, then ignored); the dot;
/// and the anchors <c>^ $</c> at the start
/// and end of the pattern or of a top-level alternation branch (no-ops there, since a whole matching string is
/// generated; anywhere else they are refused, because the pattern could never be matched by a whole generated
/// string). A brace that does not form a well-formed quantifier is a literal, as in the real engine, and groups
/// may nest at most 256 levels deep. A well-formed but non-regular or out-of-scope construct — a lookaround, a
/// backreference, a balancing group (it pops the capture stack, the backreference family), a Unicode category, a
/// word boundary, an atomic group (its first-branch commit is not language-equivalent to plain alternation), a
/// class subtraction — is refused with an
/// and the anchors <c>^ $</c> at the start and end of the pattern or of a top-level alternation branch — including
/// a run of them (<c>^^</c>, <c>$$</c>) or a quantified one (<c>^*</c>, <c>$?</c>), all no-ops there since a whole
/// matching string is generated; anywhere else they are refused, because the pattern could never be matched by a
/// whole generated string. A brace that does not form a well-formed quantifier is a literal, as in the real engine,
/// and groups may nest at most 256 levels deep. A well-formed but non-regular or out-of-scope construct — a
/// lookaround, a backreference, a balancing group (it pops the capture stack, the backreference family), a Unicode
/// category, a word boundary, an atomic group (its first-branch commit is not language-equivalent to plain
/// alternation), a class subtraction, or a negated class that excludes the whole printable-ASCII universe Dummies
/// draws from — is refused with an
/// <see cref="UnsupportedRegexException" /> rather than silently mis-generated; a malformed pattern (including an
/// escape the real engine rejects) raises an <see cref="ArgumentException" />.
/// </summary>
Expand Down Expand Up @@ -124,24 +125,30 @@
return branches.Count == 1 ? branches[0] : new RegexAlternation(branches.ToArray());
}

private RegexNode ParseSequence() {

Check warning on line 128 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
List<RegexNode> parts = new();
while (!AtEnd && Peek() != '|' && Peek() != ')') {
// Anchors are no-ops for a whole-string generator, but only where they are guaranteed to match:
// '^' at the start and '$' at the end of the pattern or of a top-level alternation branch. Anywhere
// else ('a^', '$a', inside a group) the pattern can never be matched by a whole generated string,
// so it is refused instead of silently mis-generated.
// '^' at the start and '$' at the end of the pattern or of a top-level alternation branch. A run of
// them ('^^', '$$') and a quantified one ('^*', '$?', '^{2}') are equally no-ops there — the real
// engine accepts and matches all of these — so they are consumed and ignored. Anywhere else ('a^',
// '$a', inside a group) the pattern can never be matched by a whole generated string, so it is
// refused instead of silently mis-generated.
if (Peek() == '^') {
if (_depth > 0 || parts.Count > 0) { throw Unsupported("an anchor '^' away from the start of the pattern or of a top-level alternation branch", _index); }
_index++;
SkipAnchorQuantifier();

continue;
}

if (Peek() == '$') {
int position = _index;
_index++;
if (_depth > 0 || (!AtEnd && Peek() != '|')) { throw Unsupported("an anchor '$' away from the end of the pattern or of a top-level alternation branch", position); }
SkipAnchorQuantifier();
// A '$' is a no-op only at the very end: what follows must be end-of-pattern, a branch bar, or
// another end-anchor '$'. Inside a group, or before anything else, it can never match a whole string.
if (_depth > 0 || (!AtEnd && Peek() != '|' && Peek() != '$')) { throw Unsupported("an anchor '$' away from the end of the pattern or of a top-level alternation branch", position); }

continue;
}
Expand All @@ -152,6 +159,17 @@
return parts.Count == 1 ? parts[0] : new RegexSequence(parts.ToArray());
}

/// <summary>
/// Consumes a quantifier applied to a boundary anchor (<c>^*</c>, <c>$?</c>, <c>^{2,3}</c>, a lazy marker
/// included) when one is present. Repeating a zero-width no-op leaves it a no-op — the real engine accepts and
/// matches these — so the quantifier is read and discarded, never turned into a repeat node.
/// </summary>
private void SkipAnchorQuantifier() {
if (AtEnd) { return; } // an anchor at the very end carries no quantifier
if (TryReadQuantifier() is null) { return; }
if (!AtEnd && Peek() == '?') { _index++; } // a lazy marker on a no-op is immaterial
}

private RegexNode ParseQuantified() {
RegexNode atom = ParseAtom();
if (AtEnd) { return atom; }
Expand Down Expand Up @@ -335,7 +353,7 @@
// integer with no leading zero, so '0', '01' and '1a' are refused while '1' and '10' pass.
if (name[0] is >= '0' and <= '9') {
bool validNumber = name[0] != '0';
foreach (char character in name) {

Check warning on line 356 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Loops should be simplified using the "Where" LINQ method
if (character is < '0' or > '9') { validNumber = false; }
}

Expand All @@ -345,7 +363,7 @@
}

// Any other name must be word characters (letter, digit or underscore), exactly as the real engine requires.
foreach (char character in name) {

Check warning on line 366 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Loops should be simplified using the "Where" LINQ method
if (!char.IsLetterOrDigit(character) && character != '_') { throw Malformed($"the group name '{name}' contains '{character}', which is not a letter, digit or underscore"); }
}
}
Expand Down Expand Up @@ -385,7 +403,7 @@
case 'k': throw Unsupported("a named backreference '\\k<…>'", position);
default:
if (escaped is >= '1' and <= '9') { throw Unsupported($"a backreference '\\{escaped}'", position); }
// The real engine rejects unknown word-character escapes rather than reading them as literals;

Check warning on line 406 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove this commented out code.
// mirroring it keeps "accepted by Dummies" aligned with "accepted by .NET".
if (char.IsLetterOrDigit(escaped)) { throw Malformed($"unrecognized escape sequence '\\{escaped}'"); }

Expand Down Expand Up @@ -416,7 +434,8 @@
return (char)value;
}

private RegexNode ParseClass() {

Check warning on line 437 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed.
int position = _index;
_index++; // consume '['
bool negated = Eat('^');
HashSet<char> set = new();
Expand All @@ -431,9 +450,11 @@
}

first = false;
// .NET's class subtraction ([a-z-[aeiou]]) removes a nested class; parsing the '-[' as members
// would close the class early and generate values outside it, so the construct is refused.
if (Peek() == '-' && PeekAt(1) == '[') { throw Unsupported("a character-class subtraction '[…-[…]]'", _index); }
// .NET's class subtraction ([a-z-[aeiou]]) removes a nested class; parsing the '-[' as members would
// close the class early and generate values outside it, so the construct is refused. But '-[' is
// subtraction only when a base member precedes it: the real engine reads a leading '-' (as in '[-[x]]')
// as an ordinary hyphen, so it is refused here only once the set already holds a member.
if (Peek() == '-' && PeekAt(1) == '[' && set.Count > 0) { throw Unsupported("a character-class subtraction '[…-[…]]'", _index); }
if (Peek() == '\\' && IsClassShorthand(PeekAt(1))) {
_index++; // consume '\'
AddClassShorthand(set, Next());
Expand All @@ -442,6 +463,9 @@
}

char low = ReadClassChar();
// A '-[' immediately after a member is subtraction as well ('[a-[x]]'): the real engine never reads it
// as a range whose upper bound is '[', so intercepting it here can never turn away a valid range.
if (!AtEnd && Peek() == '-' && PeekAt(1) == '[') { throw Unsupported("a character-class subtraction '[…-[…]]'", _index); }
if (!AtEnd && Peek() == '-' && PeekAt(1) != ']' && PeekAt(1) != '\0') {
_index++; // consume '-'
char high = ReadClassChar();
Expand All @@ -456,7 +480,13 @@

if (_ignoreCase) { set = ExpandCase(set); }
char[] choices = negated ? RegexAlphabet.Negate(set) : set.ToArray();
if (choices.Length == 0) { throw Malformed("character class matches no printable character"); }
// Only a negated class can end up empty — a plain class always holds the member it just read. Such a class
// is well-formed and regular (the real engine accepts it), but it excludes every character Dummies draws
// from, so it is refused as unsupported (a universe limit), not as malformed. Routing it through Malformed
// would claim the caller wrote a broken pattern for one the real engine compiles.
if (choices.Length == 0) {
throw new UnsupportedRegexException($"The regular expression pattern \"{_pattern}\" uses a negated character class that excludes every character Dummies can generate (printable ASCII U+0020 to U+007E) at position {position}, which Dummies cannot generate from. It draws values from printable ASCII; express the requirement with characters inside that range, or generate the value another way.");
}

return new RegexCharacters(choices);
}
Expand Down Expand Up @@ -500,7 +530,7 @@
}

private ArgumentException Malformed(string reason) {
return new ArgumentException($"The regular expression pattern \"{_pattern}\" is invalid: {reason} (at position {_index}).", "pattern");

Check warning on line 533 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

The parameter name 'pattern' is not declared in the argument list.
}

private UnsupportedRegexException Unsupported(string construct, int position) {
Expand Down
Loading