diff --git a/src/Ramstack.Parsing/Parser.Prefixed.cs b/src/Ramstack.Parsing/Parser.Prefixed.cs new file mode 100644 index 0000000..c6c257a --- /dev/null +++ b/src/Ramstack.Parsing/Parser.Prefixed.cs @@ -0,0 +1,108 @@ +namespace Ramstack.Parsing; + +partial class Parser +{ + /// + /// Creates a parser that matches zero or more prefix operators followed by an operand. + /// + /// + /// + /// // Example: ('-')* Number + /// // - - - 5 => -(-(-5)) + /// var unary = number.Prefixed(L('-'), (x, op) => -x); + /// + /// + /// Operators are applied from right to left: the operator closest to the operand is applied first. + /// + /// + /// If the operator parser succeeds without consuming input, its result is discarded + /// and the search for operators stops without calling the reduction function for that result. + /// + /// + /// The type of the value produced by the main parser. + /// The type of the operator token produced by the parser. + /// The main parser that matches an operand. + /// The parser that matches a prefix operator token. + /// A reduction function that receives the operand value as its first argument + /// and the operator token as its second argument, and returns the updated operand value. + /// + /// A parser that matches prefix operators followed by an operand. + /// + public static Parser Prefixed(this Parser parser, Parser op, Func reduce) => + new PrefixedParser(parser, op, reduce); + + #region Inner type: PrefixedParser + + /// + /// Represents a parser that matches zero or more prefix operators followed by an operand. + /// + /// The type of the value produced by the main parser. + /// The type of the operator token produced by the parser. + /// The main parser that matches an operand. + /// The parser that matches a prefix operator token. + /// A reduction function that receives the operand value as its first argument + /// and the operator token as its second argument, and returns the updated operand value. + private sealed class PrefixedParser(Parser parser, Parser op, Func reduce) : Parser + { + /// + public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out T? value) + { + var bookmark = context.BookmarkPosition(); + + // + // Most chains contain zero or one operator. Keep the first operator in a local variable + // so that the buffer is allocated only when a second operator appears. + // + var count = 0; + var first = default(TOperator)!; + var other = new ArrayBuilder(); + + while (true) + { + var last = context.Position; + + if (!op.TryParse(ref context, out var o)) + break; + + // + // Prevent infinite loop + // + if (context.Position == last) + break; + + if (count == 0) + { + count++; + first = o; + } + else + { + other.Add(o); + } + } + + if (parser.TryParse(ref context, out var v)) + { + for (var i = other.Count - 1; i >= 0; i--) + v = reduce(v, other[i]); + + if (count != 0) + v = reduce(v, first); + + context.SetMatched(bookmark); + value = v!; + return true; + } + + context.RestorePosition(bookmark); + value = default; + return false; + } + + /// + protected internal override Parser ToVoidParser() => + Seq(op.Void().ZeroOrMore(), parser.Void()); + } + + #endregion +} diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.Prefixed.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Prefixed.cs new file mode 100644 index 0000000..edf78d6 --- /dev/null +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Prefixed.cs @@ -0,0 +1,185 @@ +using static Ramstack.Parsing.Parser; + +namespace Ramstack.Parsing; + +partial class ParsersTests +{ + [TestCase("x", "x")] + [TestCase("-x", "(-x)")] + [TestCase("!-x", "(!(-x))")] + [TestCase("-+~!x", "(-(+(~(!x))))")] + public void Prefixed_MultipleOperators_AppliesRightToLeft(string source, string expected) + { + var operand = Set('a', 'z').Map(m => m.ToString()); + var parser = operand.Prefixed( + OneOf("-+~!"), + static (x, op) => $"({op}{x})"); + + 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)); + } + + [TestCase("")] + [TestCase("-")] + [TestCase("-x")] + public void Prefixed_MissingOperand_FailsAndRestoresPosition(string source) + { + var operand = Set('0', '9').Do(c => c - '0'); + var parser = operand.Prefixed(L('-'), (x, _) => -x); + + var context = new ParseContext("!" + source); + context.Advance(1); + + Assert.That(parser.TryParse(ref context, out _), Is.False); + + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + } + + [Test] + public void Prefixed_MissingOperand_ReportsErrorAfterOperators() + { + var operand = Set('0', '9').Do(c => c - '0').As("digit"); + var parser = operand.Prefixed(L('-'), (x, _) => -x); + + var result = parser.Parse("--"); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorMessage, Is.EqualTo("(1:3) Expected '-' or digit")); + } + + [Test] + public void Prefixed_Success_SetsMatchedSegment() + { + var digit = Set('0', '9').Do(c => c - '0'); + var parser = digit.Prefixed(L('-'), (x, _) => -x); + + var context = new ParseContext("!--5tail"); + context.Advance(1); + + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value, Is.EqualTo(5)); + + Assert.That(context.MatchedSegment.Index, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(3)); + Assert.That(context.Remaining.ToString(), Is.EqualTo("tail")); + } + + [Test] + public void Prefixed_OperatorConsumesNoInput_StopsWithoutReducing() + { + var op = new BoundedParser(Return('-')); + var digit = Set('0', '9').Do(c => c - '0'); + + var reductions = 0; + var parser = digit.Prefixed(op, (x, _) => + { + reductions++; + return -x; + }); + + var result = parser.Parse("5!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(5)); + Assert.That(result.Length, Is.EqualTo(1)); + + Assert.That(reductions, Is.Zero); + } + + [Test] + public void Prefixed_TrailingEmptyOperator_StopsWithoutReducing() + { + var op = new BoundedParser(L('-').DefaultOnFail('-')); + var digit = Set('0', '9').Do(c => c - '0'); + + var reductions = 0; + var parser = digit.Prefixed(op, (x, _) => + { + reductions++; + return x + 1; + }); + + var result = parser.Parse("--5!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(7)); + Assert.That(result.Length, Is.EqualTo(3)); + + Assert.That(reductions, Is.EqualTo(2)); + } + + [TestCase("5!", 1)] + [TestCase("-5!", 2)] + [TestCase("---5!", 4)] + public void PrefixedVoid_Success_SkipsReduction(string source, int length) + { + var digit = Set('0', '9').Do(c => c - '0'); + + var reductions = 0; + var parser = digit.Prefixed(L('-'), (x, _) => + { + reductions++; + return -x; + }); + + var result = parser.Void().Parse(source); + + Assert.That(result.Success, Is.True); + Assert.That(result.Length, Is.EqualTo(length)); + + Assert.That(reductions, Is.Zero); + } + + [TestCase("")] + [TestCase("-")] + [TestCase("--x")] + public void PrefixedVoid_MissingOperand_FailsAndRestoresPosition(string source) + { + var digit = Set('0', '9').Do(c => c - '0'); + var parser = digit.Prefixed(L('-'), (x, _) => -x).Void(); + + var context = new ParseContext("!" + source); + 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); + } + + [Test] + public void PrefixedVoid_TrailingEmptyOperator_StopsParsing() + { + var op = new BoundedParser(L('-').DefaultOnFail('-')); + var digit = Set('0', '9').Do(c => c - '0'); + var parser = digit.Prefixed(op, (x, _) => -x).Void(); + + var result = parser.Parse("--5!"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Length, Is.EqualTo(3)); + } + + [Test] + public void Prefixed_VeryLongChain_DoesNotOverflowStack() + { + var digit = Set('0', '9').Do(c => c - '0'); + var parser = digit.Prefixed(L('-'), (x, _) => -x); + + var source = new string('-', 1_000_001) + "1"; + var result = parser.Parse(source); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(-1)); + Assert.That(result.Length, Is.EqualTo(source.Length)); + + Assert.That( + parser.Void().Parse(source).Length, + Is.EqualTo(source.Length)); + } +}