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
41 changes: 41 additions & 0 deletions Dummies.UnitTests/AnyPatternTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ private static string Display(string value) {
[InlineData(@"(?:foo|bar)-\d+")]
[InlineData(@"(?<year>\d{4})-(?<month>\d{2})")]
[InlineData(@"(?'tag'\d{2})")]
[InlineData(@"(?<1>x)")] // explicitly-numbered group: a valid capture NUMBER, not an invalid name
[InlineData(@"(?'2'y)")] // ...same, quote form
[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(@"[\d]{3}")]
[InlineData(@"[-a-z]{2}")]
Expand Down Expand Up @@ -222,6 +226,27 @@ public void NotGeneratableConstructsAreRefused() {
Check.ThatCode(() => Any.WithSeed(1).StringMatching(new Regex("^A B$", RegexOptions.IgnorePatternWhitespace))).Throws<ArgumentException>();
}

[Fact(DisplayName = "A balancing group is refused as unsupported — both syntaxes, target defined or not.")]
public void BalancingGroupsAreRefused() {
// A balancing group '(?<-name>…)' / '(?<name1-name2>…)' pops the capture stack — the backreference family,
// which is non-regular. Its language is not that of a plain named group: '(?<a>y)?(?<-a>x)' matches only
// "yx" (the '-a' pop forces the optional 'a' group to have fired), yet lowering '(?<-a>x)' to an ordinary
// named group would emit "x". It is refused instead of mis-generated. .NET accepts these two target-defined
// patterns, so the refusal is a genuine "we decline what a plain walk cannot honour", not an echo of .NET.
Check.ThatCode(() => Any.StringMatching(@"(?<a>y)?(?<-a>x)")).Throws<UnsupportedRegexException>();
Check.ThatCode(() => Any.StringMatching(@"(?<a>y)?(?'-a'x)")).Throws<UnsupportedRegexException>(); // quote form

// The '-' is refused even when the target group is undefined — where the real engine instead reports a
// malformed pattern. Distinguishing the two would need a table of captured groups the generator does not
// keep; the divergence is only in the error kind (both reject, neither mis-generates) and is accepted.
Check.ThatCode(() => Any.StringMatching(@"(?<-a>x)")).Throws<UnsupportedRegexException>();
Check.ThatCode(() => Any.StringMatching(@"(?'-a'x)")).Throws<UnsupportedRegexException>(); // quote form
Check.ThatCode(() => Any.StringMatching(@"(?<x-y>z)")).Throws<UnsupportedRegexException>(); // name1-name2 form

UnsupportedRegexException caught = Assert.Throws<UnsupportedRegexException>(() => Any.StringMatching(@"(?<a>y)?(?<-a>x)"));
Check.That(caught.Message).Contains("balancing group");
}

[Fact(DisplayName = "Malformed patterns raise ArgumentException; a null pattern raises ArgumentNullException.")]
public void MalformedPatternsAreRejected() {
Check.ThatCode(() => Any.StringMatching(@"[a-")).Throws<ArgumentException>();
Expand All @@ -248,6 +273,22 @@ public void RealEngineRejectionsAreMirrored() {
Check.ThatCode(() => Any.StringMatching(@"(?<>a)")).Throws<ArgumentException>(); // empty group name
}

[Fact(DisplayName = "An invalid group name is rejected as malformed, matching the real engine — both syntaxes.")]
public void InvalidGroupNamesAreRejected() {
// A name opening with a digit is an explicit capture NUMBER, which the real engine accepts only as a positive
// integer with no leading zero. '0' (reserved for the whole match), a leading zero, and a digit-then-letter
// name are all rejected — here as they are there.
Check.ThatCode(() => Any.StringMatching(@"(?<1a>x)")).Throws<ArgumentException>(); // digit then letter
Check.ThatCode(() => Any.StringMatching(@"(?<0>x)")).Throws<ArgumentException>(); // group 0 is reserved
Check.ThatCode(() => Any.StringMatching(@"(?<01>x)")).Throws<ArgumentException>(); // leading zero
Check.ThatCode(() => Any.StringMatching(@"(?'0'x)")).Throws<ArgumentException>(); // quote form, reserved

// A non-numeric name must be word characters (letter, digit or underscore); a space or a dot is malformed.
Check.ThatCode(() => Any.StringMatching(@"(?<a b>x)")).Throws<ArgumentException>();
Check.ThatCode(() => Any.StringMatching(@"(?'a b'x)")).Throws<ArgumentException>(); // quote form
Check.ThatCode(() => Any.StringMatching(@"(?<a.b>x)")).Throws<ArgumentException>();
}

[Fact(DisplayName = "Escape sequences generate the real characters, not their letter.")]
public void EscapesGenerateTheRealCharacters() {
Check.That(Any.StringMatching(@"\a").Generate()).IsEqualTo("\a");
Expand Down
65 changes: 58 additions & 7 deletions Dummies/RegexParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
/// <c>\d \D \w \W \s \S</c>; character classes with ranges and negation; the quantifiers
/// <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 ignored); the dot; and the anchors <c>^ $</c> at the start
/// (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 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
/// 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
/// <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 @@ -122,7 +124,7 @@
return branches.Count == 1 ? branches[0] : new RegexAlternation(branches.ToArray());
}

private RegexNode ParseSequence() {

Check warning on line 127 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.

Check warning on line 127 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:
Expand Down Expand Up @@ -272,12 +274,12 @@
case '#': throw Unsupported("an inline comment '(?#…)'", position);
case '<':
if (PeekAt(1) is '=' or '!') { throw Unsupported("a lookbehind '(?<=…)' or '(?<!…)'", position); }
_index++; // consume '<'
SkipGroupName('>'); // named group: the name is irrelevant to generation
_index++; // consume '<'
SkipGroupName('>', position); // named group: the name never shapes generation, but it is validated
break;
case '\'':
_index++; // consume '\''
SkipGroupName('\'');
SkipGroupName('\'', position);
break;
default: throw Unsupported($"a group option '(?{Peek()}…)'", position);
}
Expand All @@ -292,11 +294,60 @@
return inner;
}

private void SkipGroupName(char terminator) {
/// <summary>
/// Consumes a named-group name up to its <paramref name="terminator" /> (<c>&gt;</c> for
/// <c>(?&lt;name&gt;…)</c>, a quote for <c>(?'name'…)</c>) and validates it as the real engine would, so
/// "accepted by Dummies" stays aligned with "accepted by .NET". The name never shapes generation, but two
/// well-formed .NET constructs must not slip through as ordinary named groups:
/// <list type="bullet">
/// <item>
/// a <b>balancing group</b> <c>(?&lt;name1-name2&gt;…)</c> / <c>(?&lt;-name2&gt;…)</c> — the
/// <c>-</c> pops the capture stack (the same family as a backreference), so it is non-regular and
/// refused with an <see cref="UnsupportedRegexException" />. This fires <b>even when the target group
/// is undefined</b> — where the real engine instead reports a malformed pattern — because telling the
/// two apart would need a table of captured groups, which this generator deliberately does not keep.
/// The divergence is only in the error <i>kind</i>: both reject the pattern, neither mis-generates.
/// </item>
/// <item>
/// an <b>invalid name</b> — a name opening with a digit is an explicit capture <i>number</i>, valid
/// only as a positive integer with no leading zero (<c>1</c>, <c>10</c>; not <c>0</c>, <c>01</c> or
/// <c>1a</c>); any other name must be word characters. Anything else raises an
/// <see cref="ArgumentException" />, mirroring the real engine.
/// </item>
/// </list>
/// </summary>
private void SkipGroupName(char terminator, int position) {
int start = _index;
while (!AtEnd && Peek() != terminator) { _index++; }
if (_index == start) { throw Malformed("a group name must not be empty"); }
string name = _pattern.Substring(start, _index - start);
if (!Eat(terminator)) { throw Malformed($"unterminated group name (expected '{terminator}')"); }
ValidateGroupName(name, position);
}

private void ValidateGroupName(string name, int position) {
// A '-' marks a balancing group; it manipulates the capture stack (the backreference family), so it is
// non-regular and refused here even when its target is undefined (see SkipGroupName for why the divergence
// from the real engine's malformed-pattern verdict on that case is accepted).
if (name.IndexOf('-') >= 0) { throw Unsupported("a balancing group '(?<name1-name2>…)'", position); }

// A name opening with a digit is an explicit capture number: the real engine accepts it only as a positive
// 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 338 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Loops should be simplified using the "Where" LINQ method

Check warning on line 338 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; }
}

if (!validNumber) { throw Malformed($"a group name starting with a digit must be a group number with no leading zero, but '{name}' is not"); }

return;
}

// 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 348 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"); }
}
}

private RegexNode ParseEscape() {
Expand Down Expand Up @@ -334,7 +385,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 388 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove this commented out code.

Check warning on line 388 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 @@ -365,7 +416,7 @@
return (char)value;
}

private RegexNode ParseClass() {

Check warning on line 419 in Dummies/RegexParser.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Refactor this method to reduce its Cognitive Complexity from 25 to the 15 allowed.
_index++; // consume '['
bool negated = Eat('^');
HashSet<char> set = new();
Expand Down Expand Up @@ -449,7 +500,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 503 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
2 changes: 1 addition & 1 deletion Dummies/UnsupportedRegexException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace Dummies;
/// <summary>
/// Thrown when a pattern passed to <see cref="Any.StringMatching(string)" /> is well-formed but uses a construct
/// outside the <b>regular</b> subset the library generates from — a lookahead or lookbehind, a backreference, a
/// Unicode category, a word boundary. These constructs are either not regular (so no finite generator can honour
/// balancing group, a Unicode category, a word boundary. These constructs are either not regular (so no finite generator can honour
/// them) or deliberately out of scope; the library refuses to guess rather than silently emit a value that does
/// not actually match. A syntactically malformed pattern is a caller mistake and surfaces as an
/// <see cref="System.ArgumentException" /> instead.
Expand Down
Loading