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..8960a8a 100644 --- a/src/Ramstack.Parsing/Parser.Separated.cs +++ b/src/Ramstack.Parsing/Parser.Separated.cs @@ -5,18 +5,28 @@ 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. /// 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 @@ -40,6 +50,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 +60,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 +129,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 +139,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.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.Separated.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs index 030f01b..928f399 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Separated.cs @@ -101,4 +101,236 @@ 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(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")); + } + + [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'")); + } + + [Test] + public void Separated_InvalidBounds_ThrowsArgumentOutOfRangeException() + { + 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")); + } } 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); + } + } +}