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);
+ }
+ }
+}