From 4a26f98bb05130e4f610f1f963561b459bf51c4e Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 20 Sep 2026 17:23:39 +0500 Subject: [PATCH 1/4] parsers: stop repetitions that do not consume input --- src/Ramstack.Parsing/Parser.Fold.cs | 12 +- src/Ramstack.Parsing/Parser.Separated.cs | 16 ++ .../ParsersTests.RepetitionSafety.cs | 235 ++++++++++++++++++ 3 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 tests/Ramstack.Parsing.Tests/ParsersTests.RepetitionSafety.cs diff --git a/src/Ramstack.Parsing/Parser.Fold.cs b/src/Ramstack.Parsing/Parser.Fold.cs index c60e806..a8b47ff 100644 --- a/src/Ramstack.Parsing/Parser.Fold.cs +++ b/src/Ramstack.Parsing/Parser.Fold.cs @@ -11,6 +11,8 @@ partial class Parser /// // 1 + 2 + 3 + 4 => (((1 + 2) + 3) + 4) /// var sum = number.Fold(OneOf("+-"), (l, r, op) => op == '+' ? l + r : l - r); /// + /// If an operator-operand pair consumes no input, it is discarded and parsing stops + /// without calling the reduction function for that pair. /// /// The type of the value produced by the main parser. /// The type of the operator token produced by the parser. @@ -34,6 +36,8 @@ public static Parser Fold(this Parser parser, Parser Math.Pow(l, r)); /// + /// If an operator-operand pair consumes no input, it is discarded and parsing stops + /// without calling the reduction function for that pair. /// /// The type of value produced by the main parser. /// The type of the operator token produced by the parser. @@ -71,7 +75,9 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out { var rollback = context.BookmarkPosition(); - if (op.TryParse(ref context, out var o) && parser.TryParse(ref context, out v)) + if (op.TryParse(ref context, out var o) + && parser.TryParse(ref context, out v) + && context.Position != rollback.Position) { result = reduce(result, v, o); continue; @@ -126,7 +132,9 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out { var rollback = context.BookmarkPosition(); - if (op.TryParse(ref context, out var o) && parser.TryParse(ref context, out v)) + if (op.TryParse(ref context, out var o) + && parser.TryParse(ref context, out v) + && context.Position != rollback.Position) { list.Add((o, v)); continue; diff --git a/src/Ramstack.Parsing/Parser.Separated.cs b/src/Ramstack.Parsing/Parser.Separated.cs index f827ac4..34e39cd 100644 --- a/src/Ramstack.Parsing/Parser.Separated.cs +++ b/src/Ramstack.Parsing/Parser.Separated.cs @@ -5,6 +5,10 @@ partial class Parser /// /// Creates a parser that repeatedly applies the main parser, interleaved with a separator specified by another parser. /// + /// + /// If an item and its following separator both succeed without consuming input, repetition stops + /// once the minimum number of items has been matched. The successfully parsed item is included in the result. + /// /// The type of the value produced by the main parser. /// The type of the value produced by the separator parser. /// The main parser. @@ -40,6 +44,8 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out do { + var position = context.Position; + if (!parser.TryParse(ref context, out var result)) break; @@ -48,6 +54,10 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out separatorBookmark = context.BookmarkPosition(); if (!separator.TryParse(ref context, out _)) break; + + // Stop empty matches once the minimum count has been reached. + if (list.Count >= min && context.Position == position) + break; } while (list.Count < max); @@ -113,6 +123,8 @@ public override bool TryParse(ref ParseContext context, out Unit value) do { + var position = context.Position; + if (!_parser.TryParse(ref context, out value)) break; @@ -121,6 +133,10 @@ public override bool TryParse(ref ParseContext context, out Unit value) separatorBookmark = context.BookmarkPosition(); if (!_separator.TryParse(ref context, out value)) break; + + // Stop empty matches once the minimum count has been reached. + if (count >= _min && context.Position == position) + break; } while (count < _max); diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.RepetitionSafety.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.RepetitionSafety.cs new file mode 100644 index 0000000..9832a68 --- /dev/null +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.RepetitionSafety.cs @@ -0,0 +1,235 @@ +using static Ramstack.Parsing.Parser; + +namespace Ramstack.Parsing; + +partial class ParsersTests +{ + [Test] + public void Fold_NoInputConsumed_StopsWithoutReducing([Values] bool rightAssociative, [Values] bool discardResult) + { + var operand = new BoundedParser(Return(2)); + var op = Return('-'); + + var reductions = 0; + var reduce = (int l, int r, char _) => + { + reductions++; + return l - r; + }; + + var parser = rightAssociative + ? operand.FoldR(op, reduce) + : operand.Fold(op, reduce); + + var context = new ParseContext("!tail"); + context.Advance(1); + + if (discardResult) + { + Assert.That(parser.Void().TryParse(ref context, out _), Is.True); + } + else + { + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value, Is.EqualTo(2)); + } + + Assert.That(reductions, Is.Zero); + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); + } + + [TestCase(false, 6)] + [TestCase(true, 8)] + public void Fold_EmptyPairAfterProgress_SkipsReduction(bool rightAssociative, int expected) + { + var operand = new BoundedParser( + Literal.Number().DefaultOnFail(2) + ); + + var op = L('-').DefaultOnFail('-'); + + var reductions = 0; + var reduce = (int l, int r, char _) => + { + reductions++; + return l - r; + }; + + var parser = rightAssociative + ? operand.FoldR(op, reduce) + : operand.Fold(op, reduce); + + var result = parser.Parse("10-3-1!"); + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(expected)); + Assert.That(result.Length, Is.EqualTo(6)); + Assert.That(reductions, Is.EqualTo(2)); + + var discarded = parser.Void().Parse("10-3-1!"); + Assert.That(discarded.Success, Is.True); + Assert.That(discarded.Length, Is.EqualTo(6)); + Assert.That(reductions, Is.EqualTo(2)); + } + + [TestCase(true, false, "123!", -4)] + [TestCase(true, true, "123!", 2)] + [TestCase(false, false, "--!", -2)] + [TestCase(false, true, "--!", 2)] + public void Fold_OnlyOneParserConsumesInput_Continues(bool operandConsumes, bool rightAssociative, string source, int expected) + { + var operand = operandConsumes + ? Set('0', '9').Do(c => c - '0') + : Return(2); + + var op = operandConsumes + ? Return('-') + : L('-'); + + var parser = rightAssociative + ? operand.FoldR(op, (l, r, _) => l - r) + : operand.Fold(op, (l, r, _) => l - r); + + var result = parser.Parse(source); + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(expected)); + Assert.That(result.Length, Is.EqualTo(source.Length - 1)); + + var discarded = parser.Void().Parse(source); + Assert.That(discarded.Success, Is.True); + Assert.That(discarded.Length, Is.EqualTo(result.Length)); + } + + [TestCase(0, 1, false, false)] + [TestCase(0, 1, false, true)] + [TestCase(0, 1, true, false)] + [TestCase(0, 1, true, true)] + [TestCase(1, 1, false, false)] + [TestCase(1, 1, false, true)] + [TestCase(1, 1, true, false)] + [TestCase(1, 1, true, true)] + [TestCase(3, 3, false, false)] + [TestCase(3, 3, false, true)] + [TestCase(3, 3, true, false)] + [TestCase(3, 3, true, true)] + public void Separated_NoInputConsumed_StopsAfterMinimumReached(int min, int expectedCount, bool allowTrailing, bool discardResult) + { + var item = new BoundedParser(Return(7)); + var parser = item.Separated(Return(','), allowTrailing, min); + var context = new ParseContext("!tail"); + context.Advance(1); + + if (discardResult) + { + Assert.That(parser.Void().TryParse(ref context, out _), Is.True); + } + else + { + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value, Is.EqualTo(Enumerable.Repeat(7, expectedCount))); + } + + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); + } + + [TestCase(true, "abc!", "abc", false)] + [TestCase(true, "abc!", "abc", true)] + [TestCase(false, ",,!", "xxx", false)] + [TestCase(false, ",,!", "xxx", true)] + public void Separated_OnlyOneParserConsumesInput_Continues(bool itemConsumes, string source, string expected, bool allowTrailing) + { + var item = itemConsumes ? Set('a', 'z') : Return('x'); + var separator = itemConsumes ? Return(',') : L(','); + var parser = item.Separated(separator, allowTrailing); + + var result = parser.Parse(source); + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(expected.ToCharArray())); + Assert.That(result.Length, Is.EqualTo(source.Length - 1)); + + var discarded = parser.Void().Parse(source); + Assert.That(discarded.Success, Is.True); + Assert.That(discarded.Length, Is.EqualTo(result.Length)); + } + + [TestCase(false, 1, false)] + [TestCase(false, 1, true)] + [TestCase(true, 2, false)] + [TestCase(true, 2, true)] + public void Separated_MaximumReached_RespectsTrailingSeparator(bool allowTrailing, int expectedLength, bool discardResult) + { + var parser = L('a').Separated(L(','), allowTrailing, min: 1, max: 1); + var context = new ParseContext("a,a"); + + if (discardResult) + { + Assert.That(parser.Void().TryParse(ref context, out _), Is.True); + } + else + { + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value, Is.EqualTo(new[] { 'a' })); + } + + Assert.That(context.Position, Is.EqualTo(expectedLength)); + Assert.That(context.MatchedSegment.Index, Is.Zero); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(expectedLength)); + } + + [Test] + public void Separated_MinimumNotReached_RestoresPosition([Values] bool allowTrailing, [Values] bool discardResult) + { + var parser = L('a').Separated(L(','), allowTrailing, min: 2); + var context = new ParseContext("!a,?"); + context.Advance(1); + + if (discardResult) + { + Assert.That(parser.Void().TryParse(ref context, out _), Is.False); + } + else + { + Assert.That(parser.TryParse(ref context, out var value), Is.False); + Assert.That(value, Is.Null); + } + + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("a,?")); + Assert.That(context.ToString(), Is.EqualTo("(1:4) Expected 'a'")); + } + + [TestCase(-1, 1, "min")] + [TestCase(0, -1, "max")] + [TestCase(0, 0, "max")] + [TestCase(2, 1, "min")] + public void Separated_InvalidBounds_ThrowsArgumentOutOfRangeException(int min, int max, string parameter) + { + var exception = Assert.Throws(() => L('a').Separated(L(','), min: min, max: max)); + Assert.That(exception!.ParamName, Is.EqualTo(parameter)); + } + + #region Inner type: BoundedParser + + // Bound parser invocations so a missing progress check fails instead of hanging the test run. + private sealed class BoundedParser(Parser parser) : Parser + { + private int _attempts; + + public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out T? value) + { + if (++_attempts > 16) + throw new InvalidOperationException( + "The repetition did not stop after matching empty input."); + + return parser.TryParse(ref context, out value); + } + } + + #endregion +} From 366998e226bb3c75a75557b417c4926cf1df56c6 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 20 Sep 2026 17:25:42 +0500 Subject: [PATCH 2/4] parsers: validate Separated arguments --- src/Ramstack.Parsing/Parser.Separated.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Ramstack.Parsing/Parser.Separated.cs b/src/Ramstack.Parsing/Parser.Separated.cs index 34e39cd..8960a8a 100644 --- a/src/Ramstack.Parsing/Parser.Separated.cs +++ b/src/Ramstack.Parsing/Parser.Separated.cs @@ -14,13 +14,19 @@ partial class Parser /// The main parser. /// The parser that identifies the separators placed between the elements parsed by the main parser. /// if a trailing separator is allowed; otherwise, . - /// The minimum number of repetitions. - /// The maximum number of repetitions. + /// The minimum number of repetitions. Must be non-negative and no greater than . + /// The maximum number of repetitions. Must be greater than zero. /// /// A parser that repeatedly applies the main parser, interleaved with the specified separator. /// - public static Parser> Separated(this Parser parser, Parser separator, bool allowTrailing = false, int min = 0, int max = int.MaxValue) => - new SeparatedParser(parser, separator.Void(), allowTrailing, min, max); + public static Parser> Separated(this Parser parser, Parser separator, bool allowTrailing = false, int min = 0, int max = int.MaxValue) + { + Argument.ThrowIfNegative(min); + Argument.ThrowIfNegativeOrZero(max); + Argument.ThrowIfGreaterThan(min, max); + + return new SeparatedParser(parser, separator.Void(), allowTrailing, min, max); + } #region Inner type: SeparatedParser From 22345dd3b3bbc14a67b9a4746962a135f999f4df Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 20 Sep 2026 19:32:37 +0500 Subject: [PATCH 3/4] parsers: simplify repetition tests --- .../ParsersTests.Fold.cs | 187 ++++++++++++++ .../ParsersTests.RepetitionSafety.cs | 235 ------------------ .../ParsersTests.Separated.cs | 217 ++++++++++++++++ tests/Ramstack.Parsing.Tests/ParsersTests.cs | 18 +- 4 files changed, 421 insertions(+), 236 deletions(-) delete mode 100644 tests/Ramstack.Parsing.Tests/ParsersTests.RepetitionSafety.cs diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.Fold.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Fold.cs index 2983342..f158e5d 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.Fold.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Fold.cs @@ -37,4 +37,191 @@ public void FoldRTest(string expr, string result, int length) Assert.That(parser.Parse(expr).Value, Is.EqualTo(BigInteger.Parse(result))); Assert.That(parser.Map(m => (m.Index, m.Length)).Parse(expr).Value, Is.EqualTo((0, length))); } + + [Test] + public void Fold_NoInputConsumed_StopsWithoutReducing([Values] bool rightAssociative) + { + var operand = new BoundedParser(Return(2)); + var op = Return('-'); + + var reductions = 0; + var reduce = (int l, int r, char _) => + { + reductions++; + return l - r; + }; + + var parser = rightAssociative + ? operand.FoldR(op, reduce) + : operand.Fold(op, reduce); + + var context = new ParseContext("!tail"); + context.Advance(1); + + var success = parser.TryParse(ref context, out var value); + + Assert.That(success, Is.True); + Assert.That(value, Is.EqualTo(2)); + Assert.That(reductions, Is.Zero); + + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); + } + + [Test] + public void FoldVoid_NoInputConsumed_StopsWithoutReducing([Values] bool rightAssociative) + { + var operand = new BoundedParser(Return(2)); + var op = Return('-'); + + var reductions = 0; + var reduce = (int l, int r, char _) => + { + reductions++; + return l - r; + }; + + var parser = rightAssociative + ? operand.FoldR(op, reduce) + : operand.Fold(op, reduce); + + var context = new ParseContext("!tail"); + context.Advance(1); + + var success = parser.Void().TryParse(ref context, out _); + + Assert.That(success, Is.True); + Assert.That(reductions, Is.Zero); + + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); + } + + [TestCase(false, 6)] + [TestCase(true, 8)] + public void Fold_TrailingEmptyPair_StopsWithoutReducing(bool rightAssociative, int expected) + { + var operand = new BoundedParser( + Literal.Number().DefaultOnFail(2) + ); + + var op = L('-').DefaultOnFail('-'); + + var reductions = 0; + var reduce = (int l, int r, char _) => + { + reductions++; + return l - r; + }; + + var parser = rightAssociative + ? operand.FoldR(op, reduce) + : operand.Fold(op, reduce); + + var result = parser.Parse("10-3-1!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(expected)); + Assert.That(result.Length, Is.EqualTo(6)); + Assert.That(reductions, Is.EqualTo(2)); + } + + [Test] + public void FoldVoid_TrailingEmptyPair_StopsWithoutReducing([Values] bool rightAssociative) + { + var operand = new BoundedParser( + Literal.Number().DefaultOnFail(2) + ); + + var op = L('-').DefaultOnFail('-'); + + var reductions = 0; + var reduce = (int l, int r, char _) => + { + reductions++; + return l - r; + }; + + var parser = rightAssociative + ? operand.FoldR(op, reduce) + : operand.Fold(op, reduce); + + var result = parser.Void().Parse("10-3-1!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Length, Is.EqualTo(6)); + Assert.That(reductions, Is.Zero); + } + + [TestCase(false, -4)] + [TestCase(true, 2)] + public void Fold_OnlyOperandConsumesInput_ContinuesParsing(bool rightAssociative, int expected) + { + var operand = Set('0', '9').Do(c => c - '0'); + var op = Return('-'); + + var parser = rightAssociative + ? operand.FoldR(op, (l, r, _) => l - r) + : operand.Fold(op, (l, r, _) => l - r); + + var result = parser.Parse("123!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(expected)); + Assert.That(result.Length, Is.EqualTo(3)); + } + + [Test] + public void FoldVoid_OnlyOperandConsumesInput_ContinuesParsing([Values] bool rightAssociative) + { + var operand = Set('0', '9').Do(c => c - '0'); + var op = Return('-'); + + var parser = rightAssociative + ? operand.FoldR(op, (l, r, _) => l - r) + : operand.Fold(op, (l, r, _) => l - r); + + var result = parser.Void().Parse("123!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Length, Is.EqualTo(3)); + } + + [TestCase(false, -2)] + [TestCase(true, 2)] + public void Fold_OnlyOperatorConsumesInput_ContinuesParsing(bool rightAssociative, int expected) + { + var operand = Return(2); + var op = L('-'); + + var parser = rightAssociative + ? operand.FoldR(op, (l, r, _) => l - r) + : operand.Fold(op, (l, r, _) => l - r); + + var result = parser.Parse("--!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(expected)); + Assert.That(result.Length, Is.EqualTo(2)); + } + + [Test] + public void FoldVoid_OnlyOperatorConsumesInput_ContinuesParsing([Values] bool rightAssociative) + { + var operand = Return(2); + var op = L('-'); + + var parser = rightAssociative + ? operand.FoldR(op, (l, r, _) => l - r) + : operand.Fold(op, (l, r, _) => l - r); + + var result = parser.Void().Parse("--!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Length, Is.EqualTo(2)); + } } diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.RepetitionSafety.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.RepetitionSafety.cs deleted file mode 100644 index 9832a68..0000000 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.RepetitionSafety.cs +++ /dev/null @@ -1,235 +0,0 @@ -using static Ramstack.Parsing.Parser; - -namespace Ramstack.Parsing; - -partial class ParsersTests -{ - [Test] - public void Fold_NoInputConsumed_StopsWithoutReducing([Values] bool rightAssociative, [Values] bool discardResult) - { - var operand = new BoundedParser(Return(2)); - var op = Return('-'); - - var reductions = 0; - var reduce = (int l, int r, char _) => - { - reductions++; - return l - r; - }; - - var parser = rightAssociative - ? operand.FoldR(op, reduce) - : operand.Fold(op, reduce); - - var context = new ParseContext("!tail"); - context.Advance(1); - - if (discardResult) - { - Assert.That(parser.Void().TryParse(ref context, out _), Is.True); - } - else - { - Assert.That(parser.TryParse(ref context, out var value), Is.True); - Assert.That(value, Is.EqualTo(2)); - } - - Assert.That(reductions, Is.Zero); - Assert.That(context.Position, Is.EqualTo(1)); - Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); - Assert.That(context.MatchedSegment.Length, Is.Zero); - Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); - } - - [TestCase(false, 6)] - [TestCase(true, 8)] - public void Fold_EmptyPairAfterProgress_SkipsReduction(bool rightAssociative, int expected) - { - var operand = new BoundedParser( - Literal.Number().DefaultOnFail(2) - ); - - var op = L('-').DefaultOnFail('-'); - - var reductions = 0; - var reduce = (int l, int r, char _) => - { - reductions++; - return l - r; - }; - - var parser = rightAssociative - ? operand.FoldR(op, reduce) - : operand.Fold(op, reduce); - - var result = parser.Parse("10-3-1!"); - Assert.That(result.Success, Is.True); - Assert.That(result.Value, Is.EqualTo(expected)); - Assert.That(result.Length, Is.EqualTo(6)); - Assert.That(reductions, Is.EqualTo(2)); - - var discarded = parser.Void().Parse("10-3-1!"); - Assert.That(discarded.Success, Is.True); - Assert.That(discarded.Length, Is.EqualTo(6)); - Assert.That(reductions, Is.EqualTo(2)); - } - - [TestCase(true, false, "123!", -4)] - [TestCase(true, true, "123!", 2)] - [TestCase(false, false, "--!", -2)] - [TestCase(false, true, "--!", 2)] - public void Fold_OnlyOneParserConsumesInput_Continues(bool operandConsumes, bool rightAssociative, string source, int expected) - { - var operand = operandConsumes - ? Set('0', '9').Do(c => c - '0') - : Return(2); - - var op = operandConsumes - ? Return('-') - : L('-'); - - var parser = rightAssociative - ? operand.FoldR(op, (l, r, _) => l - r) - : operand.Fold(op, (l, r, _) => l - r); - - var result = parser.Parse(source); - Assert.That(result.Success, Is.True); - Assert.That(result.Value, Is.EqualTo(expected)); - Assert.That(result.Length, Is.EqualTo(source.Length - 1)); - - var discarded = parser.Void().Parse(source); - Assert.That(discarded.Success, Is.True); - Assert.That(discarded.Length, Is.EqualTo(result.Length)); - } - - [TestCase(0, 1, false, false)] - [TestCase(0, 1, false, true)] - [TestCase(0, 1, true, false)] - [TestCase(0, 1, true, true)] - [TestCase(1, 1, false, false)] - [TestCase(1, 1, false, true)] - [TestCase(1, 1, true, false)] - [TestCase(1, 1, true, true)] - [TestCase(3, 3, false, false)] - [TestCase(3, 3, false, true)] - [TestCase(3, 3, true, false)] - [TestCase(3, 3, true, true)] - public void Separated_NoInputConsumed_StopsAfterMinimumReached(int min, int expectedCount, bool allowTrailing, bool discardResult) - { - var item = new BoundedParser(Return(7)); - var parser = item.Separated(Return(','), allowTrailing, min); - var context = new ParseContext("!tail"); - context.Advance(1); - - if (discardResult) - { - Assert.That(parser.Void().TryParse(ref context, out _), Is.True); - } - else - { - Assert.That(parser.TryParse(ref context, out var value), Is.True); - Assert.That(value, Is.EqualTo(Enumerable.Repeat(7, expectedCount))); - } - - Assert.That(context.Position, Is.EqualTo(1)); - Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); - Assert.That(context.MatchedSegment.Length, Is.Zero); - Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); - } - - [TestCase(true, "abc!", "abc", false)] - [TestCase(true, "abc!", "abc", true)] - [TestCase(false, ",,!", "xxx", false)] - [TestCase(false, ",,!", "xxx", true)] - public void Separated_OnlyOneParserConsumesInput_Continues(bool itemConsumes, string source, string expected, bool allowTrailing) - { - var item = itemConsumes ? Set('a', 'z') : Return('x'); - var separator = itemConsumes ? Return(',') : L(','); - var parser = item.Separated(separator, allowTrailing); - - var result = parser.Parse(source); - Assert.That(result.Success, Is.True); - Assert.That(result.Value, Is.EqualTo(expected.ToCharArray())); - Assert.That(result.Length, Is.EqualTo(source.Length - 1)); - - var discarded = parser.Void().Parse(source); - Assert.That(discarded.Success, Is.True); - Assert.That(discarded.Length, Is.EqualTo(result.Length)); - } - - [TestCase(false, 1, false)] - [TestCase(false, 1, true)] - [TestCase(true, 2, false)] - [TestCase(true, 2, true)] - public void Separated_MaximumReached_RespectsTrailingSeparator(bool allowTrailing, int expectedLength, bool discardResult) - { - var parser = L('a').Separated(L(','), allowTrailing, min: 1, max: 1); - var context = new ParseContext("a,a"); - - if (discardResult) - { - Assert.That(parser.Void().TryParse(ref context, out _), Is.True); - } - else - { - Assert.That(parser.TryParse(ref context, out var value), Is.True); - Assert.That(value, Is.EqualTo(new[] { 'a' })); - } - - Assert.That(context.Position, Is.EqualTo(expectedLength)); - Assert.That(context.MatchedSegment.Index, Is.Zero); - Assert.That(context.MatchedSegment.Length, Is.EqualTo(expectedLength)); - } - - [Test] - public void Separated_MinimumNotReached_RestoresPosition([Values] bool allowTrailing, [Values] bool discardResult) - { - var parser = L('a').Separated(L(','), allowTrailing, min: 2); - var context = new ParseContext("!a,?"); - context.Advance(1); - - if (discardResult) - { - Assert.That(parser.Void().TryParse(ref context, out _), Is.False); - } - else - { - Assert.That(parser.TryParse(ref context, out var value), Is.False); - Assert.That(value, Is.Null); - } - - Assert.That(context.Position, Is.EqualTo(1)); - Assert.That(context.MatchedSegment.Length, Is.Zero); - Assert.That(context.Remaining.ToString(), Is.EqualTo("a,?")); - Assert.That(context.ToString(), Is.EqualTo("(1:4) Expected 'a'")); - } - - [TestCase(-1, 1, "min")] - [TestCase(0, -1, "max")] - [TestCase(0, 0, "max")] - [TestCase(2, 1, "min")] - public void Separated_InvalidBounds_ThrowsArgumentOutOfRangeException(int min, int max, string parameter) - { - var exception = Assert.Throws(() => L('a').Separated(L(','), min: min, max: max)); - Assert.That(exception!.ParamName, Is.EqualTo(parameter)); - } - - #region Inner type: BoundedParser - - // Bound parser invocations so a missing progress check fails instead of hanging the test run. - private sealed class BoundedParser(Parser parser) : Parser - { - private int _attempts; - - public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out T? value) - { - if (++_attempts > 16) - throw new InvalidOperationException( - "The repetition did not stop after matching empty input."); - - return parser.TryParse(ref context, out value); - } - } - - #endregion -} diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs index 030f01b..c47b3fd 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs @@ -101,4 +101,221 @@ public void Separated_AllowTrailing() Assert.That(parser.Map(m => (m.Index, m.Length)).Parse(s).Value, Is.EqualTo((0, s.Length))); } } + + [TestCase(0, 1, false)] + [TestCase(0, 1, true)] + [TestCase(1, 1, false)] + [TestCase(1, 1, true)] + [TestCase(3, 3, false)] + [TestCase(3, 3, true)] + public void Separated_NoInputConsumed_StopsAfterMinimumReached(int min, int expectedCount, bool allowTrailing) + { + var item = new BoundedParser(Return(7)); + var parser = item.Separated(Return(','), allowTrailing, min); + var context = new ParseContext("!tail"); + context.Advance(1); + + var success = parser.TryParse(ref context, out var value); + + Assert.That(success, Is.True); + Assert.That(value, Is.EqualTo(Enumerable.Repeat(7, expectedCount))); + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); + } + + [TestCase(0, 1, false)] + [TestCase(0, 1, true)] + [TestCase(1, 1, false)] + [TestCase(1, 1, true)] + [TestCase(3, 3, false)] + [TestCase(3, 3, true)] + public void SeparatedVoid_NoInputConsumed_StopsAfterMinimumReached(int min, int expectedCount, bool allowTrailing) + { + var item = new BoundedParser(Return(7)); + var parser = item.Separated(Return(','), allowTrailing, min).Void(); + var context = new ParseContext("!tail"); + context.Advance(1); + + var success = parser.TryParse(ref context, out _); + + Assert.That(success, Is.True); + Assert.That(item.Attempts, Is.EqualTo(expectedCount)); + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); + } + + [Test] + public void Separated_TrailingEmptyPair_StopsAfterEmptyItem([Values] bool allowTrailing) + { + var item = new BoundedParser( + Literal.Number().DefaultOnFail(7) + ); + + var parser = item.Separated(Return(','), allowTrailing); + var context = new ParseContext("!12?"); + context.Advance(1); + + var success = parser.TryParse(ref context, out var value); + + Assert.That(success, Is.True); + Assert.That(value, Is.EqualTo(new[] { 12, 7 })); + Assert.That(item.Attempts, Is.EqualTo(2)); + Assert.That(context.Position, Is.EqualTo(3)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(2)); + Assert.That(context.Remaining.ToString(), Is.EqualTo("?")); + } + + [Test] + public void SeparatedVoid_TrailingEmptyPair_StopsAfterEmptyItem([Values] bool allowTrailing) + { + var item = new BoundedParser( + Literal.Number().DefaultOnFail(7) + ); + + var parser = item.Separated(Return(','), allowTrailing).Void(); + var context = new ParseContext("!12?"); + context.Advance(1); + + var success = parser.TryParse(ref context, out _); + + Assert.That(success, Is.True); + Assert.That(item.Attempts, Is.EqualTo(2)); + Assert.That(context.Position, Is.EqualTo(3)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(2)); + Assert.That(context.Remaining.ToString(), Is.EqualTo("?")); + } + + [Test] + public void Separated_OnlyItemConsumesInput_ContinuesParsing([Values] bool allowTrailing) + { + var item = Set('a', 'z'); + var separator = Return(','); + var parser = item.Separated(separator, allowTrailing); + + var result = parser.Parse("abc!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(new[] { 'a', 'b', 'c' })); + Assert.That(result.Length, Is.EqualTo(3)); + } + + [Test] + public void SeparatedVoid_OnlyItemConsumesInput_ContinuesParsing([Values] bool allowTrailing) + { + var item = Set('a', 'z'); + var separator = Return(','); + var parser = item.Separated(separator, allowTrailing).Void(); + + var result = parser.Parse("abc!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Length, Is.EqualTo(3)); + } + + [Test] + public void Separated_OnlySeparatorConsumesInput_ContinuesParsing([Values] bool allowTrailing) + { + var item = Return('x'); + var separator = L(','); + var parser = item.Separated(separator, allowTrailing); + + var result = parser.Parse(",,!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(new[] { 'x', 'x', 'x' })); + Assert.That(result.Length, Is.EqualTo(2)); + } + + [Test] + public void SeparatedVoid_OnlySeparatorConsumesInput_ContinuesParsing([Values] bool allowTrailing) + { + var item = Return('x'); + var separator = L(','); + var parser = item.Separated(separator, allowTrailing).Void(); + + var result = parser.Parse(",,!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Length, Is.EqualTo(2)); + } + + [TestCase(false, 1)] + [TestCase(true, 2)] + public void Separated_MaximumReached_ConsumesTrailingSeparatorOnlyWhenAllowed(bool allowTrailing, int expectedLength) + { + var parser = L('a').Separated(L(','), allowTrailing, min: 1, max: 1); + var context = new ParseContext("a,a"); + + var success = parser.TryParse(ref context, out var value); + + Assert.That(success, Is.True); + Assert.That(value, Is.EqualTo(new[] { 'a' })); + Assert.That(context.Position, Is.EqualTo(expectedLength)); + Assert.That(context.MatchedSegment.Index, Is.Zero); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(expectedLength)); + } + + [TestCase(false, 1)] + [TestCase(true, 2)] + public void SeparatedVoid_MaximumReached_ConsumesTrailingSeparatorOnlyWhenAllowed(bool allowTrailing, int expectedLength) + { + var parser = L('a').Separated(L(','), allowTrailing, min: 1, max: 1).Void(); + var context = new ParseContext("a,a"); + + var success = parser.TryParse(ref context, out _); + + Assert.That(success, Is.True); + Assert.That(context.Position, Is.EqualTo(expectedLength)); + Assert.That(context.MatchedSegment.Index, Is.Zero); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(expectedLength)); + } + + [Test] + public void Separated_MinimumNotReached_RestoresPosition([Values] bool allowTrailing) + { + var parser = L('a').Separated(L(','), allowTrailing, min: 2); + var context = new ParseContext("!a,?"); + context.Advance(1); + + var success = parser.TryParse(ref context, out var value); + + Assert.That(success, Is.False); + Assert.That(value, Is.Null); + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("a,?")); + Assert.That(context.ToString(), Is.EqualTo("(1:4) Expected 'a'")); + } + + [Test] + public void SeparatedVoid_MinimumNotReached_RestoresPosition([Values] bool allowTrailing) + { + var parser = L('a').Separated(L(','), allowTrailing, min: 2).Void(); + var context = new ParseContext("!a,?"); + context.Advance(1); + + var success = parser.TryParse(ref context, out _); + + Assert.That(success, Is.False); + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo("a,?")); + Assert.That(context.ToString(), Is.EqualTo("(1:4) Expected 'a'")); + } + + [TestCase(-1, 1, "min")] + [TestCase(0, -1, "max")] + [TestCase(0, 0, "max")] + [TestCase(2, 1, "min")] + public void Separated_InvalidBounds_ThrowsArgumentOutOfRangeException(int min, int max, string parameter) + { + var exception = Assert.Throws(() => L('a').Separated(L(','), min: min, max: max)); + Assert.That(exception!.ParamName, Is.EqualTo(parameter)); + } } diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.cs index cf16c87..2130949 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.cs @@ -1,4 +1,20 @@ namespace Ramstack.Parsing; [TestFixture] -public partial class ParsersTests; +public partial class ParsersTests +{ + // Bound parser invocations so a missing progress check fails instead of hanging the test run. + private sealed class BoundedParser(Parser parser) : Parser + { + public int Attempts { get; private set; } + + public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out T? value) + { + if (++Attempts > 16) + throw new InvalidOperationException( + "The repetition did not stop after matching empty input."); + + return parser.TryParse(ref context, out value); + } + } +} From d0015561702c600f1b4205fbd5f0445e9bdc9261 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 20 Sep 2026 21:08:33 +0500 Subject: [PATCH 4/4] parsers: inline Separated argument validation tests --- .../ParsersTests.Separated.cs | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs index c47b3fd..928f399 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs @@ -119,6 +119,8 @@ public void Separated_NoInputConsumed_StopsAfterMinimumReached(int min, int expe Assert.That(success, Is.True); Assert.That(value, Is.EqualTo(Enumerable.Repeat(7, expectedCount))); + Assert.That(item.Attempts, Is.EqualTo(expectedCount)); + Assert.That(context.Position, Is.EqualTo(1)); Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); Assert.That(context.MatchedSegment.Length, Is.Zero); @@ -142,6 +144,7 @@ public void SeparatedVoid_NoInputConsumed_StopsAfterMinimumReached(int min, int Assert.That(success, Is.True); Assert.That(item.Attempts, Is.EqualTo(expectedCount)); + Assert.That(context.Position, Is.EqualTo(1)); Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); Assert.That(context.MatchedSegment.Length, Is.Zero); @@ -164,6 +167,7 @@ public void Separated_TrailingEmptyPair_StopsAfterEmptyItem([Values] bool allowT Assert.That(success, Is.True); Assert.That(value, Is.EqualTo(new[] { 12, 7 })); Assert.That(item.Attempts, Is.EqualTo(2)); + Assert.That(context.Position, Is.EqualTo(3)); Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); Assert.That(context.MatchedSegment.Length, Is.EqualTo(2)); @@ -185,6 +189,7 @@ public void SeparatedVoid_TrailingEmptyPair_StopsAfterEmptyItem([Values] bool al Assert.That(success, Is.True); Assert.That(item.Attempts, Is.EqualTo(2)); + Assert.That(context.Position, Is.EqualTo(3)); Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); Assert.That(context.MatchedSegment.Length, Is.EqualTo(2)); @@ -309,13 +314,23 @@ public void SeparatedVoid_MinimumNotReached_RestoresPosition([Values] bool allow Assert.That(context.ToString(), Is.EqualTo("(1:4) Expected 'a'")); } - [TestCase(-1, 1, "min")] - [TestCase(0, -1, "max")] - [TestCase(0, 0, "max")] - [TestCase(2, 1, "min")] - public void Separated_InvalidBounds_ThrowsArgumentOutOfRangeException(int min, int max, string parameter) + [Test] + public void Separated_InvalidBounds_ThrowsArgumentOutOfRangeException() { - var exception = Assert.Throws(() => L('a').Separated(L(','), min: min, max: max)); - Assert.That(exception!.ParamName, Is.EqualTo(parameter)); + Assert.That( + () => L('a').Separated(L(','), min: -1, max: 1), + Throws.TypeOf().With.Property(nameof(ArgumentException.ParamName)).EqualTo("min")); + + Assert.That( + () => L('a').Separated(L(','), min: 0, max: -1), + Throws.TypeOf().With.Property(nameof(ArgumentException.ParamName)).EqualTo("max")); + + Assert.That( + () => L('a').Separated(L(','), min: 0, max: 0), + Throws.TypeOf().With.Property(nameof(ArgumentException.ParamName)).EqualTo("max")); + + Assert.That( + () => L('a').Separated(L(','), min: 2, max: 1), + Throws.TypeOf().With.Property(nameof(ArgumentException.ParamName)).EqualTo("min")); } }