Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/Ramstack.Parsing/Parser.Prefixed.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
namespace Ramstack.Parsing;

partial class Parser
{
/// <summary>
/// Creates a parser that matches zero or more prefix operators followed by an operand.
/// </summary>
/// <remarks>
/// <code>
/// // Example: ('-')* Number
/// // - - - 5 => -(-(-5))
/// var unary = number.Prefixed(L('-'), (x, op) => -x);
/// </code>
/// <para>
/// Operators are applied from right to left: the operator closest to the operand is applied first.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <typeparam name="T">The type of the value produced by the main parser.</typeparam>
/// <typeparam name="TOperator">The type of the operator token produced by the parser.</typeparam>
/// <param name="parser">The main parser that matches an operand.</param>
/// <param name="op">The parser that matches a prefix operator token.</param>
/// <param name="reduce">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.</param>
/// <returns>
/// A parser that matches prefix operators followed by an operand.
/// </returns>
public static Parser<T> Prefixed<T, TOperator>(this Parser<T> parser, Parser<TOperator> op, Func<T, TOperator, T> reduce) =>
new PrefixedParser<T, TOperator>(parser, op, reduce);

#region Inner type: PrefixedParser

/// <summary>
/// Represents a parser that matches zero or more prefix operators followed by an operand.
/// </summary>
/// <typeparam name="T">The type of the value produced by the main parser.</typeparam>
/// <typeparam name="TOperator">The type of the operator token produced by the parser.</typeparam>
/// <param name="parser">The main parser that matches an operand.</param>
/// <param name="op">The parser that matches a prefix operator token.</param>
/// <param name="reduce">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.</param>
private sealed class PrefixedParser<T, TOperator>(Parser<T> parser, Parser<TOperator> op, Func<T, TOperator, T> reduce) : Parser<T>
{
/// <inheritdoc />
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<TOperator>();

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

/// <inheritdoc />
protected internal override Parser<Unit> ToVoidParser() =>
Seq(op.Void().ZeroOrMore(), parser.Void());
}

#endregion
}
185 changes: 185 additions & 0 deletions tests/Ramstack.Parsing.Tests/ParsersTests.Prefixed.cs
Original file line number Diff line number Diff line change
@@ -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<char>(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<char>(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<char>(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));
}
}
Loading