From a40ca69a45fb3ce824976bd1be71e02df456b3b5 Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 9 Oct 2026 21:51:13 +0500 Subject: [PATCH] parsers: add Prefixed for prefix operator chains Add Prefixed(op, reduce) to parse zero or more prefix operators followed by an operand. Apply operators from right to left, so the operator closest to the operand is reduced first. Stop collecting operators when op succeeds without consuming input, and discard its result. --- src/Ramstack.Parsing/Parser.Prefixed.cs | 108 ++++++++++ .../ParsersTests.Prefixed.cs | 185 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/Ramstack.Parsing/Parser.Prefixed.cs create mode 100644 tests/Ramstack.Parsing.Tests/ParsersTests.Prefixed.cs 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)); + } +}