diff --git a/Dummies.UnitTests/AnyPatternTests.cs b/Dummies.UnitTests/AnyPatternTests.cs new file mode 100644 index 00000000..f9c5383f --- /dev/null +++ b/Dummies.UnitTests/AnyPatternTests.cs @@ -0,0 +1,277 @@ +#region Usings declarations + +using System.Text.RegularExpressions; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +public sealed class AnyPatternTests { + + #region Statics members declarations + + private const int SampleCount = 200; + + // The oracle: a generated value is correct iff the REAL .NET regex engine fully matches it. Anchoring with + // ^(?:...)$ turns the partial-match IsMatch into a whole-string test, so it catches both under-generation + // (too few characters) and over-generation (trailing junk), and handles top-level alternation correctly. + private static void AssertMatches(string value, string pattern, RegexOptions options = RegexOptions.None) { + Assert.True(Regex.IsMatch(value, "^(?:" + pattern + ")$", options), + $"generated value {Display(value)} is not matched by /{pattern}/"); + } + + private static string Display(string value) { + return "\"" + value.Replace("\t", "\\t").Replace("\n", "\\n").Replace("\r", "\\r") + "\""; + } + + #endregion + + [Theory(DisplayName = "Every generated value is fully matched by the real .NET regex engine.")] + [InlineData(@"\d{8}")] + [InlineData(@"^ORD-\d{8}$")] + [InlineData(@"[A-Z]{3}")] + [InlineData(@"[a-z]{2,5}")] + [InlineData(@"(EUR|USD|GBP)")] + [InlineData(@"[A-Za-z0-9_]+")] + [InlineData(@"\w{4}\d{2}")] + [InlineData(@"[^0-9]{3}")] + [InlineData(@"colou?r")] + [InlineData(@"a{2,4}b*c+")] + [InlineData(@"(ab|cd){2,3}")] + [InlineData(@"\d+\.\d{2}")] + [InlineData(@"[A-F0-9]{6}")] + [InlineData(@"(?:foo|bar)-\d+")] + [InlineData(@"(?\d{4})-(?\d{2})")] + [InlineData(@"(?'tag'\d{2})")] + [InlineData(@"^a$|^b$")] + [InlineData(@"[\d]{3}")] + [InlineData(@"[-a-z]{2}")] + [InlineData(@"[a-z-]{2}")] + [InlineData(@"[a-b-z]{4}")] + [InlineData(@"[]a]{3}")] + [InlineData(@"[^]]{3}")] + [InlineData(@"[\b]")] + [InlineData(@"[\1]")] + [InlineData(@"[\x30-\x39]{3}")] + [InlineData(@"(a|aa|aaa)")] + [InlineData(@"a+?b*?")] + [InlineData(@"\x41\x2DB")] + [InlineData(@"\a\t")] + [InlineData(@"\e")] + [InlineData(@"\cM")] + [InlineData(@"\0")] + [InlineData(@"\07")] + [InlineData(@"a{x}")] + [InlineData(@"{abc}")] + [InlineData(@"a{2,")] + [InlineData(@"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")] + [InlineData(@"[A-Z]{2}\d{2}[A-Z0-9]{10}")] + [InlineData(@"([01]\d|2[0-3]):[0-5]\d")] + [InlineData(@"\d+\.\d+\.\d+(-[a-z]+(\.\d+)?)?")] + [InlineData(@"\s")] + [InlineData(@".")] + [InlineData(@"")] + public void GeneratedValuesMatchTheRealEngine(string pattern) { + AnyContext context = Any.WithSeed(20260718); + AnyPattern generator = context.StringMatching(pattern); + + for (int i = 0; i < SampleCount; i++) { + AssertMatches(generator.Generate(), pattern); + } + } + + [Fact(DisplayName = "Generated values vary from draw to draw whenever the pattern leaves room.")] + public void GeneratedValuesVary() { + foreach (string pattern in new[] { @"\d{8}", @"[A-Z]{3}", @"(EUR|USD|GBP)", @"[A-Za-z0-9_]+", @"a{2,4}b*c+" }) { + AnyPattern generator = Any.WithSeed(4242).StringMatching(pattern); + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(generator.Generate()); } + Check.That(seen.Count).IsStrictlyGreaterThan(1); + } + } + + [Fact(DisplayName = "A fixed-shape pattern yields exactly that shape.")] + public void FixedShape() { + for (int i = 0; i < SampleCount; i++) { + string reference = Any.StringMatching(@"^ORD-\d{8}$").Generate(); + Check.That(reference.Length).IsEqualTo(12); + Check.That(reference).StartsWith("ORD-"); + Check.That(reference.Substring(4)).Matches("^[0-9]{8}$"); + } + } + + [Fact(DisplayName = "Alternation draws each branch and only declared branches.")] + public void Alternation() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { + string value = Any.StringMatching("(EUR|USD|GBP)").Generate(); + Check.That(value == "EUR" || value == "USD" || value == "GBP").IsTrue(); + seen.Add(value); + } + + Check.That(seen).Contains("EUR", "USD", "GBP"); + } + + [Fact(DisplayName = "Character classes, ranges and negation stay within their set.")] + public void CharacterClasses() { + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.StringMatching("[A-Z]{3}").Generate()).Matches("^[A-Z]{3}$"); + Check.That(Any.StringMatching("[^0-9]{4}").Generate()).Matches("^[^0-9]{4}$"); + Check.That(Any.StringMatching(@"[\d]{5}").Generate()).Matches("^[0-9]{5}$"); + } + } + + [Fact(DisplayName = "Bounded quantifiers stay within their bounds; unbounded ones draw the minimum plus 0 to 8.")] + public void QuantifierBounds() { + HashSet starLengths = new(); + HashSet plusLengths = new(); + HashSet openLengths = new(); + + for (int i = 0; i < SampleCount; i++) { + int bounded = (Any.StringMatching("a{2,4}").Generate()).Length; + Check.That(bounded is >= 2 and <= 4).IsTrue(); + + starLengths.Add((Any.StringMatching("a*").Generate()).Length); + plusLengths.Add((Any.StringMatching("a+").Generate()).Length); + openLengths.Add((Any.StringMatching("a{2,}").Generate()).Length); + } + + Check.That(starLengths.Min()).IsEqualTo(0); + Check.That(starLengths.Max()).IsEqualTo(8); // 0 + 0..8 + Check.That(plusLengths.Min()).IsEqualTo(1); + Check.That(plusLengths.Max()).IsEqualTo(9); // 1 + 0..8 + Check.That(openLengths.Min()).IsEqualTo(2); + Check.That(openLengths.Max()).IsEqualTo(10); // 2 + 0..8 + } + + [Fact(DisplayName = "Anchors are no-ops: the whole generated string is the match.")] + public void AnchorsAreNoOps() { + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.StringMatching("^abc$").Generate()).IsEqualTo("abc"); + } + } + + [Fact(DisplayName = "A Regex with IgnoreCase generates either case.")] + public void IgnoreCaseHonoured() { + Regex pattern = new("^[a-z]{5}$", RegexOptions.IgnoreCase); + bool sawUpper = false; + AnyContext context = Any.WithSeed(99); + AnyPattern generator = context.StringMatching(pattern); + + for (int i = 0; i < SampleCount; i++) { + string value = generator.Generate(); + AssertMatches(value, "[a-z]{5}", RegexOptions.IgnoreCase); + if (value.Any(char.IsUpper)) { sawUpper = true; } + } + + Check.That(sawUpper).IsTrue(); + } + + [Fact(DisplayName = "A matching generator composes into a value object through As.")] + public void ComposesThroughAs() { + IAny generator = Any.StringMatching(@"^ORD-\d{8}$").As(OrderReference.Create); + + for (int i = 0; i < 50; i++) { + OrderReference reference = generator.Generate(); + Check.That(reference.Value).StartsWith("ORD-"); + Check.That(reference.Value.Length).IsEqualTo(12); + } + } + + [Fact(DisplayName = "Matching is reproducible under a seed.")] + public void ReproducibleUnderASeed() { + string first = string.Join("|", Enumerable.Range(0, 20).Select(_ => Any.WithSeed(7).StringMatching(@"[A-Z]{3}-\d{4}").Generate())); + string second = string.Join("|", Enumerable.Range(0, 20).Select(_ => Any.WithSeed(7).StringMatching(@"[A-Z]{3}-\d{4}").Generate())); + + Check.That(second).IsEqualTo(first); + } + + [Fact(DisplayName = "Non-regular constructs are refused eagerly with UnsupportedRegexException.")] + public void UnsupportedConstructsAreRefused() { + Check.ThatCode(() => Any.StringMatching(@"foo(?=bar)")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"foo(?!bar)")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"(?<=x)y")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"\bword\b")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"(\w+)\s\1")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"\p{L}+")).Throws(); + + UnsupportedRegexException caught = Assert.Throws(() => Any.StringMatching(@"a(?=b)")); + Check.That(caught.Message).Contains("lookahead"); + } + + [Fact(DisplayName = "Constructs whose language a plain walk cannot honour are refused, never mis-generated.")] + public void NotGeneratableConstructsAreRefused() { + // An atomic group commits to its first matching branch: (?>ab|a)b matches only "abb", so lowering it to + // a plain alternation could emit "ab" — refused instead. + Check.ThatCode(() => Any.StringMatching(@"(?>ab|a)b")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"(?>a)")).Throws(); + + // A misplaced anchor makes the pattern unmatchable by any whole string. + Check.ThatCode(() => Any.StringMatching(@"a^")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"$a")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"x(^a)")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"(a$)x")).Throws(); + + // .NET class subtraction removes a nested class; parsing '-[' as members would generate outside the set. + Check.ThatCode(() => Any.StringMatching(@"[a-z-[aeiou]]")).Throws(); + + // 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(); + Check.ThatCode(() => Any.WithSeed(1).StringMatching(new Regex("^A B$", RegexOptions.IgnorePatternWhitespace))).Throws(); + } + + [Fact(DisplayName = "Malformed patterns raise ArgumentException; a null pattern raises ArgumentNullException.")] + public void MalformedPatternsAreRejected() { + Check.ThatCode(() => Any.StringMatching(@"[a-")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"(abc")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"a{3,1}")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"*abc")).Throws(); + Check.ThatCode(() => Any.StringMatching(@"a\")).Throws(); + Check.ThatCode(() => Any.StringMatching((string)null!)).Throws(); + Check.ThatCode(() => Any.StringMatching((Regex)null!)).Throws(); + } + + [Fact(DisplayName = "Patterns the real engine rejects are rejected here too, never silently re-interpreted.")] + public void RealEngineRejectionsAreMirrored() { + // Each of these is refused by System.Text.RegularExpressions; accepting them would make the generator + // produce values for patterns no production code could ever carry. + Check.ThatCode(() => Any.StringMatching(@"a*+")).Throws(); // possessive: not a .NET construct + Check.ThatCode(() => Any.StringMatching(@"a**")).Throws(); // nested quantifier + Check.ThatCode(() => Any.StringMatching(@"a*??")).Throws(); // nested quantifier + Check.ThatCode(() => Any.StringMatching(@"[]")).Throws(); // unterminated class + Check.ThatCode(() => Any.StringMatching(@"\q")).Throws(); // unrecognized escape + Check.ThatCode(() => Any.StringMatching(@"\x4")).Throws(); // \x expects 2 hex digits + Check.ThatCode(() => Any.StringMatching(@"\c1")).Throws(); // \c expects a letter + Check.ThatCode(() => Any.StringMatching(@"{2}")).Throws(); // quantifier following nothing + Check.ThatCode(() => Any.StringMatching(@"(?<>a)")).Throws(); // empty group name + } + + [Fact(DisplayName = "Escape sequences generate the real characters, not their letter.")] + public void EscapesGenerateTheRealCharacters() { + Check.That(Any.StringMatching(@"\a").Generate()).IsEqualTo("\a"); + Check.That(Any.StringMatching(@"\e").Generate()).IsEqualTo("\u001B"); + Check.That(Any.StringMatching(@"\x41").Generate()).IsEqualTo("A"); + Check.That(Any.StringMatching(@"\u0042").Generate()).IsEqualTo("B"); + Check.That(Any.StringMatching(@"\cA").Generate()).IsEqualTo("\u0001"); + Check.That(Any.StringMatching(@"\07").Generate()).IsEqualTo("\a"); + Check.That(Any.StringMatching(@"\0").Generate()).IsEqualTo("\0"); + } + + [Fact(DisplayName = "A brace that is not a well-formed quantifier is a literal, exactly as in the real engine.")] + public void BraceLiteralsGenerate() { + Check.That(Any.StringMatching(@"a{x}").Generate()).IsEqualTo("a{x}"); + Check.That(Any.StringMatching(@"{abc}").Generate()).IsEqualTo("{abc}"); + Check.That(Any.StringMatching(@"a{2,").Generate()).IsEqualTo("a{2,"); + } + + [Fact(DisplayName = "Nesting groups beyond the parser's depth ceiling fails cleanly instead of overflowing the stack.")] + public void DeepNestingFailsCleanly() { + string deep = new string('(', 300) + "a" + new string(')', 300); + + ArgumentException caught = Assert.Throws(() => Any.StringMatching(deep)); + Check.That(caught.Message).Contains("nested"); + } + +} diff --git a/Dummies.UnitTests/SeedReproducibilityTests.cs b/Dummies.UnitTests/SeedReproducibilityTests.cs index f60535f0..d4fc559a 100644 --- a/Dummies.UnitTests/SeedReproducibilityTests.cs +++ b/Dummies.UnitTests/SeedReproducibilityTests.cs @@ -35,12 +35,13 @@ private static string Batch() { List list = Any.ListOf(Any.Int32().Between(0, 9)).WithCount(4).Generate(); HashSet set = Any.SetOf(Any.Int32().Between(0, 99)).WithCount(3).Generate(); int? maybe = Any.Int32().Between(0, 9).OrNull().Generate(); + string coded = Any.StringMatching(@"[A-Z]{3}-\d{4}").Generate(); return string.Join("|", full, bounded, free, capped, shaped, wide, unsigned, real, exact, flag, id, letter, span.Ticks, instant.Ticks, huge, tiny, string.Join("-", list), string.Join("-", set.OrderBy(value => value)), - maybe?.ToString() ?? "null"); + maybe?.ToString() ?? "null", coded); } #endregion diff --git a/Dummies/Any.cs b/Dummies/Any.cs index b4d4a61c..5c570531 100644 --- a/Dummies/Any.cs +++ b/Dummies/Any.cs @@ -1,3 +1,9 @@ +#region Usings declarations + +using System.Text.RegularExpressions; + +#endregion + namespace Dummies; /// @@ -49,6 +55,54 @@ public static AnyString String() { return new AnyString(AmbientRandomSource.Instance, StringSpec.Unconstrained); } + /// + /// Starts a generator of arbitrary strings that match , drawing from the + /// ambient random context. The pattern is the whole specification — the returned generator carries no further + /// shape or length constraints; express those inside the pattern. It still composes through As(...), + /// OrNull(), Combine(...) and the collection generators. + /// + /// + /// Supported is the regular subset of the pattern language: literals and escapes (metacharacters, + /// control characters, \xHH, \uHHHH), the shorthands \d \D \w \W \s \S, character classes + /// (ranges, negation), the quantifiers ? * + {n} {n,} {n,m} (an unbounded quantifier draws its minimum + /// plus 0 to 8 repetitions), alternation, grouping (capturing, non-capturing and named), 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). Values are drawn from printable ASCII. A well-formed but + /// non-regular or not-generatable construct — a lookaround, a backreference, a word boundary, a Unicode + /// category, an atomic group, a class subtraction, an anchor placed where it could never match — raises an + /// ; a malformed pattern raises an , + /// mirroring what the real engine rejects. + /// + /// The regular expression the generated strings must match. + /// A generator of strings matching the pattern. + /// Thrown when is null. + /// Thrown when is not a well-formed pattern. + /// Thrown when uses a construct outside the supported regular subset. + public static AnyPattern StringMatching(string pattern) { + if (pattern is null) { throw new ArgumentNullException(nameof(pattern)); } + + return AnyPattern.FromPattern(AmbientRandomSource.Instance, pattern, ignoreCase: false); + } + + /// + /// Starts a generator of arbitrary strings matching — the same contract as + /// , taking a compiled so a test can reuse the very + /// object its production code validates with. is honoured. + /// changes how the pattern text itself is read and is + /// rejected; the remaining options do not change which strings the pattern matches and are ignored. + /// + /// The regular expression the generated strings must match. + /// A generator of strings matching the pattern. + /// Thrown when is null. + /// Thrown when is not a well-formed pattern, or carries . + /// Thrown when uses a construct outside the supported regular subset. + public static AnyPattern StringMatching(Regex pattern) { + if (pattern is null) { throw new ArgumentNullException(nameof(pattern)); } + if ((pattern.Options & RegexOptions.IgnorePatternWhitespace) != 0) { throw new ArgumentException("RegexOptions.IgnorePatternWhitespace changes how the pattern text is read; pass the pattern without it (or with its whitespace and comments removed).", nameof(pattern)); } + + return AnyPattern.FromPattern(AmbientRandomSource.Instance, pattern.ToString(), (pattern.Options & RegexOptions.IgnoreCase) != 0); + } + /// /// Starts an arbitrary generator drawing from the ambient random context. Unconstrained, it /// draws from the full range; chain constraints to express what the surrounding code diff --git a/Dummies/AnyContext.cs b/Dummies/AnyContext.cs index dddb9bae..dec01cd2 100644 --- a/Dummies/AnyContext.cs +++ b/Dummies/AnyContext.cs @@ -1,3 +1,9 @@ +#region Usings declarations + +using System.Text.RegularExpressions; + +#endregion + namespace Dummies; /// @@ -43,6 +49,39 @@ public AnyString String() { return new AnyString(_source, StringSpec.Unconstrained); } + /// + /// Starts a generator of arbitrary strings matching drawing from this context — + /// same fluent surface as , deterministic under this context's seed. + /// + /// The regular expression the generated strings must match. + /// A generator of strings matching the pattern. + /// Thrown when is null. + /// Thrown when is not a well-formed pattern. + /// Thrown when uses a construct outside the supported regular subset. + public AnyPattern StringMatching(string pattern) { + if (pattern is null) { throw new ArgumentNullException(nameof(pattern)); } + + return AnyPattern.FromPattern(_source, pattern, ignoreCase: false); + } + + /// + /// Starts a generator of arbitrary strings matching drawing from this context — + /// same fluent surface as , deterministic under this context's seed. + /// is honoured; is + /// rejected; the remaining options are ignored. + /// + /// The regular expression the generated strings must match. + /// A generator of strings matching the pattern. + /// Thrown when is null. + /// Thrown when is not a well-formed pattern, or carries . + /// Thrown when uses a construct outside the supported regular subset. + public AnyPattern StringMatching(Regex pattern) { + if (pattern is null) { throw new ArgumentNullException(nameof(pattern)); } + if ((pattern.Options & RegexOptions.IgnorePatternWhitespace) != 0) { throw new ArgumentException("RegexOptions.IgnorePatternWhitespace changes how the pattern text is read; pass the pattern without it (or with its whitespace and comments removed).", nameof(pattern)); } + + return AnyPattern.FromPattern(_source, pattern.ToString(), (pattern.Options & RegexOptions.IgnoreCase) != 0); + } + /// /// Starts an arbitrary generator drawing from this context — same fluent surface as /// , deterministic under this context's seed. diff --git a/Dummies/AnyPattern.cs b/Dummies/AnyPattern.cs new file mode 100644 index 00000000..05bfcfee --- /dev/null +++ b/Dummies/AnyPattern.cs @@ -0,0 +1,66 @@ +namespace Dummies; + +/// +/// A generator of arbitrary strings that match a regular expression — the dummy for a value whose format is +/// defined by a pattern (an order reference, a SKU, a currency code). The pattern is the whole specification, so +/// this is a terminal generator: unlike it exposes no further shape or length +/// constraints — express those inside the pattern instead. It still composes like any other generator: pipe it +/// through As(...) into a value object, make it optional with OrNull(), or fold it into +/// Combine(...) and the collection generators. +/// +/// +/// +/// The pattern is parsed once, when the generator is created; each then walks the +/// parsed tree, drawing every choice and repetition count from the generator's random context — so a run is +/// reproducible under a seed, exactly like every other generator. Values are drawn from printable ASCII +/// and built directly, never generated then filtered. +/// +/// +/// Only the regular subset of the pattern language is supported (see ); +/// a non-regular construct is refused eagerly with an rather than +/// silently mis-generated. +/// +/// +/// +/// string reference = Any.StringMatching(@"^ORD-\d{8}$").Generate(); +/// IAny<OrderReference> any = Any.StringMatching(@"^ORD-\d{8}$").As(OrderReference.Create); +/// +/// +/// +public sealed class AnyPattern : IAny, IHasRandomSource { + + #region Statics members declarations + + // A nested unbounded quantifier can, in principle, expand super-linearly; this ceiling turns that into a clear + // AnyGenerationException instead of an out-of-memory. It is far above any realistic format-validation pattern. + private const int GenerationLimit = 65536; + + internal static AnyPattern FromPattern(RandomSource source, string pattern, bool ignoreCase) { + return new AnyPattern(source, RegexParser.Parse(pattern, ignoreCase)); + } + + #endregion + + #region Fields declarations + + private readonly RegexNode _root; + private readonly RandomSource _source; + + #endregion + + internal AnyPattern(RandomSource source, RegexNode root) { + _source = source; + _root = root; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// + public string Generate() { + RegexGenerationContext context = new(_source.Current.Random, GenerationLimit); + _root.Append(context); + + return context.Result(); + } + +} diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 8d3d25c1..271b6080 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -30,6 +30,11 @@ matter — and that is the point. and `DateTimeOffset`. On modern targets (`net8.0`) the surface extends to `DateOnly`, `TimeOnly`, `Int128`, `UInt128` and `Half`; the package also targets `netstandard2.0` for the widest reach. +- **Strings from a regex**: `Any.StringMatching(pattern)` generates arbitrary strings + that match a regular expression — the dummy for a format-validated value object. + Home-grown (zero dependencies) over the regular subset of the pattern language; a + non-regular construct (a lookaround, a backreference) is refused with a clear error + rather than a silently non-matching value. - **Domain vocabulary where it belongs**: dates constrain with `After`/`Before`/`Between`, quantities with `Positive`/`Between`/`NonZero`, identities with `NonEmpty`/`DifferentFrom` — and deliberately no clock-relative diff --git a/Dummies/RegexAlphabet.cs b/Dummies/RegexAlphabet.cs new file mode 100644 index 00000000..2d0f2db5 --- /dev/null +++ b/Dummies/RegexAlphabet.cs @@ -0,0 +1,84 @@ +namespace Dummies; + +/// +/// The bounded, readable character universe the regex generator draws from. Every terminal — a literal, a class, +/// a shorthand (\d \w \s and their negations), the dot — resolves to a set of printable ASCII +/// characters (0x20–0x7E). Restricting the universe keeps generated dummies legible instead of scattering +/// arbitrary Unicode, and it keeps every generated character a genuine member of the class it stands for, so the +/// output always matches the source pattern. +/// +internal static class RegexAlphabet { + + #region Statics members declarations + + internal const char MinPrintable = ' '; // 0x20 + internal const char MaxPrintable = '~'; // 0x7E + + /// Every printable ASCII character — the universe negated classes and the dot draw from. + internal static readonly char[] Printable = Range(MinPrintable, MaxPrintable); + + /// \d. + internal static readonly char[] Digit = Range('0', '9'); + + /// \D — printable non-digits. + internal static readonly char[] NonDigit = Where(character => !IsDigit(character)); + + /// \w. + internal static readonly char[] Word = Where(IsWord); + + /// \W — printable non-word characters. + internal static readonly char[] NonWord = Where(character => !IsWord(character)); + + /// \s — a readable pair; both are genuine whitespace, so either matches the source pattern. + internal static readonly char[] Whitespace = { ' ', '\t' }; + + /// \S — printable non-whitespace (space is the only printable whitespace, so this is 0x21–0x7E). + internal static readonly char[] NonWhitespace = Where(character => character != ' '); + + /// . — any character except a newline; every printable ASCII character qualifies. + internal static readonly char[] Dot = Printable; + + private static bool IsDigit(char character) { + return character is >= '0' and <= '9'; + } + + private static bool IsWord(char character) { + return character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_'; + } + + /// The printable characters none of covers — the universe of a negated class [^…]. + internal static char[] Negate(ISet excluded) { + return Where(character => !excluded.Contains(character)); + } + + /// + /// together with its opposite-case twin when it is an ASCII letter — the + /// expansion applied under so a literal + /// or class member matches either case. + /// + internal static IEnumerable WithBothCases(char character) { + if (character is >= 'A' and <= 'Z') { return new[] { character, (char)(character + 32) }; } + if (character is >= 'a' and <= 'z') { return new[] { character, (char)(character - 32) }; } + + return new[] { character }; + } + + private static char[] Range(char low, char high) { + List characters = new(high - low + 1); + for (char character = low; character <= high; character++) { characters.Add(character); } + + return characters.ToArray(); + } + + private static char[] Where(Func keep) { + List characters = new(Printable.Length); + foreach (char character in Printable) { + if (keep(character)) { characters.Add(character); } + } + + return characters.ToArray(); + } + + #endregion + +} diff --git a/Dummies/RegexNode.cs b/Dummies/RegexNode.cs new file mode 100644 index 00000000..d6a55c78 --- /dev/null +++ b/Dummies/RegexNode.cs @@ -0,0 +1,152 @@ +#region Usings declarations + +using System.Text; + +#endregion + +namespace Dummies; + +/// +/// The carrier of a single generation: the seeded random generator to draw from, and the buffer the nodes write +/// into. enforces a hard length ceiling so a nested unbounded quantifier +/// ((a+)+ and the like) can never expand without bound — the value is built directly, never generated then +/// retried, but the buffer is still guarded. +/// +internal sealed class RegexGenerationContext { + + #region Fields declarations + + private readonly StringBuilder _builder = new(); + private readonly int _limit; + + #endregion + + internal RegexGenerationContext(Random random, int limit) { + Random = random; + _limit = limit; + } + + internal Random Random { get; } + + 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."); + } + + _builder.Append(character); + } + + internal string Result() { + return _builder.ToString(); + } + +} + +/// +/// A node of the parsed pattern tree. Generation is a direct recursive descent: each node writes the characters it +/// stands for into the , drawing counts and choices from the seeded random +/// generator — so the whole tree yields exactly one string that matches the pattern, in one pass. +/// +internal abstract class RegexNode { + + internal abstract void Append(RegexGenerationContext context); + +} + +/// A terminal: one character drawn uniformly from a fixed set (a literal is the singleton case). +internal sealed class RegexCharacters : RegexNode { + + #region Fields declarations + + private readonly char[] _choices; + + #endregion + + internal RegexCharacters(char[] choices) { + _choices = choices; + } + + /// The characters this terminal can emit — empty when a class excludes the whole universe. + internal int Count => _choices.Length; + + internal override void Append(RegexGenerationContext context) { + context.Append(_choices[context.Random.Next(_choices.Length)]); + } + +} + +/// A concatenation: its children in order. +internal sealed class RegexSequence : RegexNode { + + #region Fields declarations + + private readonly RegexNode[] _parts; + + #endregion + + internal RegexSequence(RegexNode[] parts) { + _parts = parts; + } + + internal override void Append(RegexGenerationContext context) { + foreach (RegexNode part in _parts) { part.Append(context); } + } + +} + +/// An alternation: one branch, chosen uniformly. +internal sealed class RegexAlternation : RegexNode { + + #region Fields declarations + + private readonly RegexNode[] _branches; + + #endregion + + internal RegexAlternation(RegexNode[] branches) { + _branches = branches; + } + + internal override void Append(RegexGenerationContext context) { + _branches[context.Random.Next(_branches.Length)].Append(context); + } + +} + +/// +/// A quantifier: the child repeated between min and max times. An unbounded quantifier +/// (*, +, {n,}) has no max; generation then draws min plus 0 to +/// extra repetitions, the same bounded-spread default the rest of the library uses. +/// +internal sealed class RegexRepeat : RegexNode { + + #region Statics members declarations + + /// How many repetitions above the minimum an unbounded quantifier may add. + internal const int UnboundedExtra = 8; + + #endregion + + #region Fields declarations + + private readonly RegexNode _child; + private readonly int? _max; + private readonly int _min; + + #endregion + + internal RegexRepeat(RegexNode child, int min, int? max) { + _child = child; + _min = min; + _max = max; + } + + internal override void Append(RegexGenerationContext context) { + int count = _max is int max + ? context.Random.NextInt32Inclusive(_min, max) + : _min + context.Random.Next(0, UnboundedExtra + 1); + + for (int repetition = 0; repetition < count; repetition++) { _child.Append(context); } + } + +} diff --git a/Dummies/RegexParser.cs b/Dummies/RegexParser.cs new file mode 100644 index 00000000..8eee5764 --- /dev/null +++ b/Dummies/RegexParser.cs @@ -0,0 +1,457 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// A recursive-descent parser turning the regular subset of a regex pattern into a +/// tree the generator can walk. Supported: literals; the escapes \t \n \r \f \v \a \e, hexadecimal +/// \xHH and \uHHHH, control \cX, octal \0nn and escaped punctuation; the shorthands +/// \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 +/// 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 +/// rather than silently mis-generated; a malformed pattern (including an +/// escape the real engine rejects) raises an . +/// +internal sealed class RegexParser { + + #region Statics members declarations + + private const int MaxGroupDepth = 256; + + internal static RegexNode Parse(string pattern, bool ignoreCase) { + RegexParser parser = new(pattern, ignoreCase); + RegexNode root = parser.ParseAlternation(); + if (!parser.AtEnd) { + // The only character ParseSequence stops on without consuming is a ')' with no opener. + throw parser.Malformed(parser.Peek() == ')' ? "unbalanced closing parenthesis ')'" : $"unexpected character '{parser.Peek()}'"); + } + + return root; + } + + private static bool IsClassShorthand(char character) { + return character is 'd' or 'D' or 'w' or 'W' or 's' or 'S'; + } + + private static void AddClassShorthand(HashSet set, char shorthand) { + char[] members = shorthand switch { + 'd' => RegexAlphabet.Digit, + 'D' => RegexAlphabet.NonDigit, + 'w' => RegexAlphabet.Word, + 'W' => RegexAlphabet.NonWord, + 's' => RegexAlphabet.Whitespace, + _ => RegexAlphabet.NonWhitespace + }; + foreach (char member in members) { set.Add(member); } + } + + private static HashSet ExpandCase(HashSet set) { + HashSet expanded = new(); + foreach (char character in set) { + foreach (char variant in RegexAlphabet.WithBothCases(character)) { expanded.Add(variant); } + } + + return expanded; + } + + private static bool IsHexDigit(char character) { + return character is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'); + } + + private static int HexValue(char character) { + return character <= '9' ? character - '0' : char.ToUpperInvariant(character) - 'A' + 10; + } + + #endregion + + #region Fields declarations + + private readonly bool _ignoreCase; + private readonly string _pattern; + private int _depth; + private int _index; + + #endregion + + private RegexParser(string pattern, bool ignoreCase) { + _pattern = pattern; + _ignoreCase = ignoreCase; + } + + private bool AtEnd => _index >= _pattern.Length; + + private char Peek() { + return _pattern[_index]; + } + + private char Next() { + return _pattern[_index++]; + } + + private char PeekAt(int offset) { + int at = _index + offset; + + return at < _pattern.Length ? _pattern[at] : '\0'; + } + + private bool Eat(char character) { + if (!AtEnd && _pattern[_index] == character) { + _index++; + + return true; + } + + return false; + } + + private RegexNode ParseAlternation() { + List branches = new() { ParseSequence() }; + while (Eat('|')) { branches.Add(ParseSequence()); } + + return branches.Count == 1 ? branches[0] : new RegexAlternation(branches.ToArray()); + } + + private RegexNode ParseSequence() { + List 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. + 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++; + + 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); } + + continue; + } + + parts.Add(ParseQuantified()); + } + + return parts.Count == 1 ? parts[0] : new RegexSequence(parts.ToArray()); + } + + private RegexNode ParseQuantified() { + RegexNode atom = ParseAtom(); + if (AtEnd) { return atom; } + + (int Min, int? Max)? quantifier = TryReadQuantifier(); + if (quantifier is null) { return atom; } + + // A trailing '?' makes the quantifier lazy — it changes which match is preferred, never which strings + // match, so it is accepted and ignored. Possessive quantifiers (a*+) do not exist in .NET; the '+' falls + // through to the nothing-to-repeat error below, mirroring the real engine's rejection. + if (!AtEnd && Peek() == '?') { _index++; } + + return new RegexRepeat(atom, quantifier.Value.Min, quantifier.Value.Max); + } + + private (int Min, int? Max)? TryReadQuantifier() { + switch (Peek()) { + case '*': _index++; return (0, null); + case '+': _index++; return (1, null); + case '?': _index++; return (0, 1); + case '{': return ReadBraceQuantifier(); + default: return null; + } + } + + /// + /// Reads a {n}, {n,} or {n,m} quantifier. A brace whose content is not one of those + /// forms is a literal in the real engine, so the position is restored and null returned — the + /// caller then reads the '{' as an ordinary character. An out-of-order {3,1} is rejected, as the real + /// engine rejects it. + /// + private (int Min, int? Max)? ReadBraceQuantifier() { + int start = _index; + _index++; // consume '{' + if (AtEnd || !char.IsDigit(Peek())) { + _index = start; + + return null; + } + + int min = ReadInteger(); + int? max; + if (Eat('}')) { + max = min; + } else if (Eat(',')) { + if (Eat('}')) { + max = null; + } else if (!AtEnd && char.IsDigit(Peek())) { + max = ReadInteger(); + if (!Eat('}')) { + _index = start; + + return null; + } + } else { + _index = start; + + return null; + } + } else { + _index = start; + + return null; + } + + if (max is int upper && upper < min) { throw Malformed($"quantifier {{{min},{upper}}} is out of order (the maximum is below the minimum)"); } + + return (min, max); + } + + private int ReadInteger() { + int start = _index; + while (!AtEnd && char.IsDigit(Peek())) { _index++; } + string digits = _pattern.Substring(start, _index - start); + if (!int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out int value)) { + throw Malformed($"quantifier bound '{digits}' is too large"); + } + + return value; + } + + private RegexNode ParseAtom() { + char character = Peek(); + switch (character) { + case '(': return ParseGroup(); + case '[': return ParseClass(); + case '\\': return ParseEscape(); + case '.': _index++; return new RegexCharacters(RegexAlphabet.Dot); + case '*': + case '+': + case '?': throw Malformed($"quantifier '{character}' has nothing to repeat"); + case '{': { + // A well-formed brace quantifier with no atom before it is an error, exactly as in the real + // engine; any other brace is a literal. + if (ReadBraceQuantifier() is not null) { throw Malformed("quantifier '{' has nothing to repeat"); } + _index++; + + return Literal('{'); + } + default: _index++; return Literal(character); + } + } + + private RegexNode ParseGroup() { + int position = _index; + _index++; // consume '(' + if (!AtEnd && Peek() == '?') { + _index++; // consume '?' + if (AtEnd) { throw Malformed("unterminated group '(?'"); } + + switch (Peek()) { + case ':': _index++; break; // non-capturing group + // An atomic group commits to the first branch that matches, so its language is NOT that of the + // plain alternation ('(?>ab|a)b' matches only "abb"); generating from it as if it were would + // yield non-matching values, so it is refused. + case '>': throw Unsupported("an atomic group '(?>…)'", position); + case '=': throw Unsupported("a lookahead '(?=…)'", position); + case '!': throw Unsupported("a negative lookahead '(?!…)'", position); + case '(': throw Unsupported("a conditional group '(?(…)…)'", position); + 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 + break; + case '\'': + _index++; // consume '\'' + SkipGroupName('\''); + break; + default: throw Unsupported($"a group option '(?{Peek()}…)'", position); + } + } + + _depth++; + if (_depth > MaxGroupDepth) { throw Malformed($"groups are nested deeper than {MaxGroupDepth} levels"); } + RegexNode inner = ParseAlternation(); + _depth--; + if (!Eat(')')) { throw Malformed("unbalanced opening parenthesis '('"); } + + return inner; + } + + private void SkipGroupName(char terminator) { + int start = _index; + while (!AtEnd && Peek() != terminator) { _index++; } + if (_index == start) { throw Malformed("a group name must not be empty"); } + if (!Eat(terminator)) { throw Malformed($"unterminated group name (expected '{terminator}')"); } + } + + private RegexNode ParseEscape() { + int position = _index; + _index++; // consume '\' + if (AtEnd) { throw Malformed("a trailing '\\' escapes nothing"); } + + char escaped = Next(); + switch (escaped) { + case 'd': return new RegexCharacters(RegexAlphabet.Digit); + case 'D': return new RegexCharacters(RegexAlphabet.NonDigit); + case 'w': return new RegexCharacters(RegexAlphabet.Word); + case 'W': return new RegexCharacters(RegexAlphabet.NonWord); + case 's': return new RegexCharacters(RegexAlphabet.Whitespace); + case 'S': return new RegexCharacters(RegexAlphabet.NonWhitespace); + case 't': return Literal('\t'); + case 'n': return Literal('\n'); + case 'r': return Literal('\r'); + case 'f': return Literal('\f'); + case 'v': return Literal('\v'); + case 'a': return Literal('\a'); + case 'e': return Literal('\u001B'); + case 'x': return Literal(ReadHexEscape(2)); + case 'u': return Literal(ReadHexEscape(4)); + case 'c': return Literal(ReadControlEscape()); + case '0': return Literal(ReadOctalTail(0)); + case 'b': throw Unsupported("a word-boundary '\\b'", position); + case 'B': throw Unsupported("a non-word-boundary '\\B'", position); + case 'A': throw Unsupported("a start-of-string anchor '\\A'", position); + case 'G': throw Unsupported("a contiguous-match anchor '\\G'", position); + case 'Z': + case 'z': throw Unsupported("an end-of-string anchor '\\" + escaped + "'", position); + case 'p': + case 'P': throw Unsupported("a Unicode category '\\" + escaped + "{…}'", position); + 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; + // mirroring it keeps "accepted by Dummies" aligned with "accepted by .NET". + if (char.IsLetterOrDigit(escaped)) { throw Malformed($"unrecognized escape sequence '\\{escaped}'"); } + + return Literal(escaped); // an escaped metacharacter (\. \* \( \\ …) or escaped punctuation + } + } + + private char ReadHexEscape(int digits) { + int value = 0; + for (int i = 0; i < digits; i++) { + if (AtEnd || !IsHexDigit(Peek())) { throw Malformed($"a '\\{(digits == 2 ? 'x' : 'u')}' escape expects exactly {digits} hexadecimal digits"); } + value = value * 16 + HexValue(Next()); + } + + return (char)value; + } + + private char ReadControlEscape() { + if (AtEnd || Peek() is not ((>= 'A' and <= 'Z') or (>= 'a' and <= 'z'))) { throw Malformed("a '\\c' escape expects a letter (\\cA through \\cZ)"); } + + return (char)(char.ToUpperInvariant(Next()) - 'A' + 1); + } + + private char ReadOctalTail(int firstDigit) { + int value = firstDigit; + for (int i = 0; i < 2 && !AtEnd && Peek() is >= '0' and <= '7'; i++) { value = value * 8 + (Next() - '0'); } + + return (char)value; + } + + private RegexNode ParseClass() { + _index++; // consume '[' + bool negated = Eat('^'); + HashSet set = new(); + bool first = true; + + while (true) { + if (AtEnd) { throw Malformed("unterminated character class '['"); } + if (Peek() == ']' && !first) { + _index++; + + break; + } + + 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); } + if (Peek() == '\\' && IsClassShorthand(PeekAt(1))) { + _index++; // consume '\' + AddClassShorthand(set, Next()); + + continue; + } + + char low = ReadClassChar(); + if (!AtEnd && Peek() == '-' && PeekAt(1) != ']' && PeekAt(1) != '\0') { + _index++; // consume '-' + char high = ReadClassChar(); + if (high < low) { throw Malformed($"character class range '{low}-{high}' is out of order"); } + for (char character = low; character <= high; character++) { set.Add(character); } + } else { + set.Add(low); + } + } + + 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"); } + + return new RegexCharacters(choices); + } + + private char ReadClassChar() { + if (AtEnd) { throw Malformed("unterminated character class '['"); } + + char character = Next(); + if (character != '\\') { return character; } + if (AtEnd) { throw Malformed("a trailing '\\' escapes nothing"); } + + char escaped = Next(); + switch (escaped) { + case 't': return '\t'; + case 'n': return '\n'; + case 'r': return '\r'; + case 'f': return '\f'; + case 'v': return '\v'; + case 'a': return '\a'; + case 'e': return '\u001B'; + case 'b': return '\b'; // inside a class, \b is the backspace character, never a word boundary + case 'x': return ReadHexEscape(2); + case 'u': return ReadHexEscape(4); + case 'c': return ReadControlEscape(); + case '0': return ReadOctalTail(0); + default: + if (IsClassShorthand(escaped)) { throw Malformed($"a shorthand '\\{escaped}' cannot be an endpoint of a character range"); } + // Inside a class a backslash-digit is an octal escape — backreferences cannot occur here. + if (escaped is >= '1' and <= '7') { return ReadOctalTail(escaped - '0'); } + if (escaped is 'p' or 'P') { throw Unsupported($"a Unicode category '\\{escaped}{{…}}'", _index - 2); } + if (char.IsLetterOrDigit(escaped)) { throw Malformed($"unrecognized escape sequence '\\{escaped}' in a character class"); } + + return escaped; // an escaped metacharacter or escaped punctuation + } + } + + private RegexNode Literal(char character) { + if (!_ignoreCase) { return new RegexCharacters(new[] { character }); } + + return new RegexCharacters(RegexAlphabet.WithBothCases(character).Distinct().ToArray()); + } + + private ArgumentException Malformed(string reason) { + return new ArgumentException($"The regular expression pattern \"{_pattern}\" is invalid: {reason} (at position {_index}).", "pattern"); + } + + private UnsupportedRegexException Unsupported(string construct, int position) { + return new UnsupportedRegexException($"The regular expression pattern \"{_pattern}\" uses {construct} at position {position}, which Dummies cannot generate from. It builds values from the regular subset of the pattern language; lookarounds, backreferences, word boundaries and Unicode categories are outside it. Express the requirement with the supported subset, or generate the value another way."); + } + +} diff --git a/Dummies/UnsupportedRegexException.cs b/Dummies/UnsupportedRegexException.cs new file mode 100644 index 00000000..e4cc960c --- /dev/null +++ b/Dummies/UnsupportedRegexException.cs @@ -0,0 +1,19 @@ +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 +/// 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. +/// +public sealed class UnsupportedRegexException : AnyException { + + /// + /// Initializes a new instance of the class. + /// + /// A description naming the unsupported construct and where it occurs. + public UnsupportedRegexException(string message) : base(message) { } + +} diff --git a/doc/handwritten/for-maintainers/adr/0025-generate-strings-from-a-home-grown-regular-subset.fr.md b/doc/handwritten/for-maintainers/adr/0025-generate-strings-from-a-home-grown-regular-subset.fr.md new file mode 100644 index 00000000..10f102f6 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0025-generate-strings-from-a-home-grown-regular-subset.fr.md @@ -0,0 +1,120 @@ +# ADR-0025 | Générer les chaînes qui matchent depuis un sous-ensemble régulier maison + +🌍 🇬🇧 [English](0025-generate-strings-from-a-home-grown-regular-subset.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-18 +**Décideurs :** Reefact + +## Contexte + +`Dummies` permet à un test de fournir des valeurs arbitraires mais valides. Une règle de validité très courante est +une **expression régulière** de format : un objet-valeur valide son entrée contre un motif (une référence de +commande, un SKU, un code devise), et un test a besoin d'une valeur qui passe cette validation sans réécrire le +format à la main. `Any.StringMatching(motif)` répond à ce besoin — générer une chaîne que le motif matche. + +Trois faits cadrent sa construction : + +* La bibliothèque est livrée **sans aucune dépendance runtime** et en fait un élément de son identité ; la + frontière est vérifiée par un test d'architecture (ADR-0011). Ajouter une référence de paquet est donc un choix + délibéré et visible, pas un détail. +* Générer une chaîne depuis un motif revient à parcourir le motif comme un automate fini. Une bibliothèque .NET + existe (Fare, le port xeger/brics). C'est une dépendance, peu maintenue, et — comme tout générateur à base + d'automate — elle ne peut honorer les constructs **non réguliers** (lookaround, backreferences) ; elle tend à les + ignorer ou les mal traiter silencieusement. +* Les constructs non réguliers ne sont pas un sous-ensemble qu'on choisit d'écarter par confort : un lookahead, une + backreference, une limite de mot ne sont **pas réguliers**, donc aucun générateur fini ne peut produire de chaînes + les honorant. Ils sont absents de toute approche à base d'automate, maison ou non. + +Le générateur est terminal : le motif est toute la spécification, il n'y a donc rien à réconcilier avec les autres +contraintes de chaîne, et les seules questions de forme ouvertes sont l'univers de caractères et jusqu'où étendre un +quantifieur non borné. + +## Décision + +`Any.StringMatching` analyse le **sous-ensemble régulier** du langage de motifs avec le parseur propre à la +bibliothèque et génère à partir de lui — en refusant par une `UnsupportedRegexException` first-class un construct +bien formé mais non régulier ou hors périmètre — plutôt que de dépendre d'une bibliothèque d'automates de regex. + +## Justification + +* **Elle préserve l'identité zéro-dépendance.** Une dépendance d'automate de regex serait la première dépendance + runtime de la bibliothèque, apparaîtrait dans l'arbre et le SBOM de chaque consommateur, et contredirait une + propriété que la bibliothèque annonce et garde. Le parseur maison couvre les formats qui comptent sans ce coût. +* **Elle rend la frontière honnête et first-class.** Les constructs écartés sont les non-réguliers qu'un générateur + ne peut de toute façon pas honorer ; les refuser à la déclaration, en nommant le construct, est la signature de la + bibliothèque — une erreur claire vaut mieux qu'une dépendance qui émet en silence une valeur qui ne matche pas + réellement. +* **Le sous-ensemble régulier est toute la surface utile pour la validation de format.** Littéraux, classes, + raccourcis courants, quantifieurs, alternation, groupes et ancres expriment les formats que les objets-valeurs + valident réellement ; les constructs exclus servent à l'analyse, pas aux formats à forme fixe que vise cette + fonctionnalité. +* **Les choix de forme restants suivent le reste de la bibliothèque.** Les terminaux puisent dans l'ASCII imprimable + pour qu'un dummy reste lisible et que chaque caractère émis soit un vrai membre de sa classe ; un quantifieur non + borné tire son minimum plus un petit intervalle borné, le même défaut « 0 à une poignée » qu'utilisent déjà les + générateurs de chaînes et de collections. + +## Alternatives considérées + +### Dépendre de Fare (ou d'une autre bibliothèque d'automates de regex) + +Considérée parce qu'elle est éprouvée, large, et livrerait la fonctionnalité plus vite. Rejetée parce qu'elle +introduit la première dépendance runtime de la bibliothèque — contredisant l'identité zéro-dépendance que garde le +test d'architecture — pour un dialecte qui n'est lui-même que le sous-ensemble régulier, et parce qu'elle renonce au +refus first-class des constructs non supportés en les traitant silencieusement. Le niveau de maintenance de la +bibliothèque est une préoccupation secondaire. + +### Maison, mais visant le dialecte .NET complet + +Considérée par souci d'exhaustivité. Rejetée parce que les constructs exclus (lookaround, backreferences) ne sont +pas réguliers et ne peuvent être générés par aucun moyen fini : le « dialecte complet » est donc inatteignable par +principe ; poursuivre les catégories Unicode et le reste serait un travail sans fin, pour des constructs hors de la +validation de format. + +### Garder le générateur chaînable avec les autres contraintes de chaîne + +Considérée pour qu'un motif puisse se combiner avec `WithLength`, `Numeric`, etc. Rejetée parce que le motif est +déjà toute la spécification : la longueur et la forme des caractères s'expriment dedans. Un générateur terminal +supprime d'emblée une classe de combinaisons contradictoires et garde la surface petite, tandis que la composition +via `As`, `OrNull`, `Combine` et les générateurs de collections — tous définis sur `IAny` — reste disponible. + +## Conséquences + +### Positives + +* La regex de format d'un objet-valeur devient une source de dummies valides en une ligne, sans nouvelle + dépendance. +* Un construct non supporté échoue à la déclaration avec un message le nommant, jamais sous forme d'une valeur qui + ne matche pas en silence. +* Le générateur se compose comme tous les autres et reste reproductible sous une graine. + +### Négatives + +* La bibliothèque porte et doit maintenir son propre parseur et générateur de regex — le plus gros bloc de logique + qu'elle contienne — et sa correction repose sur la suite de tests (un property test vérifie les valeurs générées + contre le vrai moteur .NET). +* Le dialecte supporté est un **contrat** : l'élargir ou le restreindre plus tard est un changement pertinent pour + la compatibilité, et l'univers ASCII imprimable comme l'intervalle du quantifieur non borné sont des comportements + sur lesquels des consommateurs peuvent finir par compter. + +### Risques + +* **Dérive du dialecte** — un motif que l'utilisateur attend voir fonctionner peut tomber hors du sous-ensemble. + Atténué par l'erreur first-class explicite, qui nomme le construct au lieu de mal générer. +* **Bugs du parseur** — un parseur écrit à la main peut mal gérer un cas limite. Atténué par le property test contre + le vrai moteur ; une défaillance apparaît comme une valeur que le moteur .NET rejette, attrapée en CI plutôt que + dans le test d'un consommateur. + +## Actions de suivi + +* N'élargir le sous-ensemble supporté qu'en réponse à des motifs réels, en gardant le refus first-class comme filet + de sécurité. +* Documenter le dialecte supporté dans la documentation utilisateur une fois la surface stabilisée. +* Si une génération adossée à un automate et réconciliant la longueur devient nécessaire, réexaminer — l'API + terminale laisse cette voie ouverte sans casser les appelants. + +## Références + +* ADR-0011 — Héberger Dummies comme un paquet autonome dans ce dépôt (la frontière zéro-dépendance). +* Le parseur de regex, l'arbre de nœuds et le générateur, ainsi que le property test contre + `System.Text.RegularExpressions`, dans le projet `Dummies` et ses tests. diff --git a/doc/handwritten/for-maintainers/adr/0025-generate-strings-from-a-home-grown-regular-subset.md b/doc/handwritten/for-maintainers/adr/0025-generate-strings-from-a-home-grown-regular-subset.md new file mode 100644 index 00000000..6f220f85 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0025-generate-strings-from-a-home-grown-regular-subset.md @@ -0,0 +1,111 @@ +# ADR-0025 | Generate matching strings from a home-grown regular subset + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0025-generate-strings-from-a-home-grown-regular-subset.fr.md) + +**Status:** Proposed +**Date:** 2026-07-18 +**Decision Makers:** Reefact + +## Context + +`Dummies` lets a test supply arbitrary yet valid values. A very common validity rule is a format +**regular expression**: a value object validates its input against a pattern (an order reference, a SKU, a +currency code), and a test needs a value that passes that validation without duplicating the format by hand. +`Any.StringMatching(pattern)` fills that need — generate a string the pattern matches. + +Three facts frame how it is built: + +* The library ships with **zero runtime dependencies** and treats that as part of its identity; the boundary is + machine-checked by an architecture test (ADR-0011). Adding a package reference is therefore a deliberate, + visible change, not a detail. +* Generating a string from a pattern means walking the pattern as a finite automaton. A .NET library exists for + this (Fare, the xeger/brics port). It is a dependency, is lightly maintained, and — like every automaton-based + generator — cannot honour **non-regular** constructs (lookaround, backreferences); it tends to drop or mishandle + them silently. +* Non-regular constructs are not a subset the library is choosing to skip for convenience: a lookahead, a + backreference, a word boundary are **not regular**, so no finite generator can produce strings honouring them. + They are absent from any automaton-based approach, home-grown or not. + +The generator is a terminal: the pattern is the whole specification, so there is nothing to reconcile with the +other string constraints, and the only open shaping questions are the character universe and how far to expand an +unbounded quantifier. + +## Decision + +`Any.StringMatching` parses the **regular subset** of the pattern language with the library's own parser and +generates from it — refusing a well-formed but non-regular or out-of-scope construct with a first-class +`UnsupportedRegexException` — rather than taking a dependency on a regex-automaton library. + +## Rationale + +* **It keeps the zero-dependency identity intact.** A regex-automaton dependency would be the library's first + runtime dependency, would show in every consumer's tree and SBOM, and would contradict a property the library + advertises and guards. The home-grown parser covers the formats that matter without that cost. +* **It makes the boundary honest and first-class.** The constructs left out are the non-regular ones a generator + fundamentally cannot honour anyway; refusing them at declaration time, naming the construct, is the library's + signature — a clear error beats a dependency that silently emits a value which does not actually match. +* **The regular subset is the whole useful surface for format validation.** Literals, classes, the common + shorthands, quantifiers, alternation, grouping and anchors express the formats value objects actually validate; + the excluded constructs are used for parsing, not for the fixed-shape formats this feature targets. +* **The remaining shaping choices follow the rest of the library.** Terminals draw from printable ASCII so a dummy + stays legible and every emitted character is a genuine class member; an unbounded quantifier draws its minimum + plus a small bounded spread, the same "0 to a handful" default the string and collection generators already use. + +## Alternatives Considered + +### Depend on Fare (or another regex-automaton library) + +Considered because it is battle-tested, broad, and would ship the feature faster. Rejected because it introduces +the library's first runtime dependency — contradicting the zero-dependency identity the architecture test guards — +for a dialect that is itself only the regular subset, and because it forfeits the first-class rejection of +unsupported constructs by handling them silently. The maintenance status of the library is a secondary concern. + +### Home-grown, but targeting the full .NET regex dialect + +Considered for completeness. Rejected because the excluded constructs (lookaround, backreferences) are not regular +and cannot be generated by any finite means, so "full dialect" is unreachable in principle; chasing Unicode +categories and the rest would be unbounded work for constructs outside format validation. + +### Keep the generator chainable with the other string constraints + +Considered so a pattern could combine with `WithLength`, `Numeric`, and the like. Rejected because the pattern is +already the whole specification: length and character shape belong inside it. A terminal generator removes a class +of contradictory combinations entirely and keeps the surface small, while composition through `As`, `OrNull`, +`Combine` and the collection generators — all defined over `IAny` — stays available. + +## Consequences + +### Positive + +* A value object's format regex becomes a one-line source of valid dummies, with no new dependency. +* An unsupported construct fails at declaration time with a message naming it, never as a silently non-matching + value. +* The generator composes like every other one and stays reproducible under a seed. + +### Negative + +* The library carries and must maintain its own regex parser and generator — the largest single piece of logic in + it — and its correctness rests on the test suite (a property test checks generated values against the real .NET + engine). +* The supported dialect is a **contract**: widening or narrowing it later is a compatibility-relevant change, and + the printable-ASCII universe and the unbounded-quantifier spread are behaviours consumers may come to rely on. + +### Risks + +* **Dialect drift** — a pattern a user expects to work may fall outside the subset. Mitigated by the explicit + first-class error, which names the construct rather than mis-generating. +* **Parser bugs** — a hand-written parser can mis-handle an edge case. Mitigated by the real-engine property test; + a failure surfaces as a value the .NET engine rejects, caught in CI rather than in a consumer's test. + +## Follow-up Actions + +* Grow the supported subset only in response to real patterns, keeping the first-class rejection as the safety net. +* Document the supported dialect in the user documentation once the surface stabilizes. +* Should an automaton-backed, length-reconciling generation become necessary, revisit — the terminal API leaves + that path open without breaking callers. + +## References + +* ADR-0011 — Host Dummies as a standalone package in this repository (the zero-dependency boundary). +* The regex parser, node tree and generator, and the property test against `System.Text.RegularExpressions`, in + the `Dummies` project and its tests. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index cb6e8c13..991a808f 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -207,3 +207,4 @@ Optional supporting material: | [ADR-0022](0022-floor-the-library-on-net-framework-4-7-2.md) | Floor the library's .NET Framework support at 4.7.2 | Accepted | | [ADR-0023](0023-keep-expression-tree-selectors-for-the-v1-binder-api.md) | Keep expression-tree selectors for the v1 binder API | Accepted | | [ADR-0024](0024-allow-a-one-time-editorial-refactoring-of-accepted-adrs.md) | Allow a one-time editorial refactoring of accepted ADRs | Accepted | +| [ADR-0025](0025-generate-strings-from-a-home-grown-regular-subset.md) | Generate matching strings from a home-grown regular subset | Proposed |