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