diff --git a/Dummies.UnitTests/AnyPatternTests.cs b/Dummies.UnitTests/AnyPatternTests.cs index f2f1bdd3..6eef52b2 100644 --- a/Dummies.UnitTests/AnyPatternTests.cs +++ b/Dummies.UnitTests/AnyPatternTests.cs @@ -45,6 +45,10 @@ private static string Display(string value) { [InlineData(@"(?:foo|bar)-\d+")] [InlineData(@"(?\d{4})-(?\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(@"(?xy)")] // a named group whose name merely contains digits stays valid [InlineData(@"^a$|^b$")] [InlineData(@"[\d]{3}")] [InlineData(@"[-a-z]{2}")] @@ -222,6 +226,27 @@ public void NotGeneratableConstructsAreRefused() { Check.ThatCode(() => Any.WithSeed(1).StringMatching(new Regex("^A B$", RegexOptions.IgnorePatternWhitespace))).Throws(); } + [Fact(DisplayName = "A balancing group is refused as unsupported — both syntaxes, target defined or not.")] + public void BalancingGroupsAreRefused() { + // A balancing group '(?<-name>…)' / '(?…)' pops the capture stack — the backreference family, + // which is non-regular. Its language is not that of a plain named group: '(?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(@"(?y)?(?<-a>x)")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"(?y)?(?'-a'x)")).Throws(); // 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(); + Check.ThatCode(() => Any.StringMatching(@"(?'-a'x)")).Throws(); // quote form + Check.ThatCode(() => Any.StringMatching(@"(?z)")).Throws(); // name1-name2 form + + UnsupportedRegexException caught = Assert.Throws(() => Any.StringMatching(@"(?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(); @@ -248,6 +273,22 @@ public void RealEngineRejectionsAreMirrored() { Check.ThatCode(() => Any.StringMatching(@"(?<>a)")).Throws(); // 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(); // digit then letter + Check.ThatCode(() => Any.StringMatching(@"(?<0>x)")).Throws(); // group 0 is reserved + Check.ThatCode(() => Any.StringMatching(@"(?<01>x)")).Throws(); // leading zero + Check.ThatCode(() => Any.StringMatching(@"(?'0'x)")).Throws(); // 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(@"(?x)")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"(?'a b'x)")).Throws(); // quote form + Check.ThatCode(() => Any.StringMatching(@"(?x)")).Throws(); + } + [Fact(DisplayName = "Escape sequences generate the real characters, not their letter.")] public void EscapesGenerateTheRealCharacters() { Check.That(Any.StringMatching(@"\a").Generate()).IsEqualTo("\a"); diff --git a/Dummies/RegexParser.cs b/Dummies/RegexParser.cs index 3b7b5522..b76b5991 100644 --- a/Dummies/RegexParser.cs +++ b/Dummies/RegexParser.cs @@ -13,13 +13,15 @@ namespace Dummies; /// \d \D \w \W \s \S; character classes with ranges and negation; the quantifiers /// ? * + {n} {n,} {n,m} (a lazy ? 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 ^ $ at the start +/// (capturing, non-capturing and named — the name is validated as the real engine would, then ignored); the dot; +/// and the anchors ^ $ 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 /// rather than silently mis-generated; a malformed pattern (including an /// escape the real engine rejects) raises an . /// @@ -272,12 +274,12 @@ private RegexNode ParseGroup() { case '#': throw Unsupported("an inline comment '(?#…)'", position); case '<': if (PeekAt(1) is '=' or '!') { throw Unsupported("a lookbehind '(?<=…)' or '(?'); // 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); } @@ -292,11 +294,60 @@ private RegexNode ParseGroup() { return inner; } - private void SkipGroupName(char terminator) { + /// + /// Consumes a named-group name up to its (> for + /// (?<name>…), a quote for (?'name'…)) 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: + /// + /// + /// a balancing group (?<name1-name2>…) / (?<-name2>…) — the + /// - pops the capture stack (the same family as a backreference), so it is non-regular and + /// refused with an . This fires even when the target group + /// is undefined — 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 kind: both reject the pattern, neither mis-generates. + /// + /// + /// an invalid name — a name opening with a digit is an explicit capture number, valid + /// only as a positive integer with no leading zero (1, 10; not 0, 01 or + /// 1a); any other name must be word characters. Anything else raises an + /// , mirroring the real engine. + /// + /// + /// + 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 '(?…)'", 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) { + 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) { + if (!char.IsLetterOrDigit(character) && character != '_') { throw Malformed($"the group name '{name}' contains '{character}', which is not a letter, digit or underscore"); } + } } private RegexNode ParseEscape() { diff --git a/Dummies/UnsupportedRegexException.cs b/Dummies/UnsupportedRegexException.cs index e4cc960c..bf1127d3 100644 --- a/Dummies/UnsupportedRegexException.cs +++ b/Dummies/UnsupportedRegexException.cs @@ -3,7 +3,7 @@ namespace Dummies; /// /// Thrown when a pattern passed to is well-formed but uses a construct /// outside the regular 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 /// instead.