diff --git a/src/Ramstack.Parsing/ParseContext.cs b/src/Ramstack.Parsing/ParseContext.cs index 23043cf..1d5b68a 100644 --- a/src/Ramstack.Parsing/ParseContext.cs +++ b/src/Ramstack.Parsing/ParseContext.cs @@ -263,9 +263,22 @@ public void RestorePosition(PositionBookmark bookmark) _match.Length = 0; } - /// - public readonly override string ToString() => - GenerateErrorMessage(_source, _diagnostics.Index, _diagnostics.ToString()); + /// + /// Returns the accumulated parsing diagnostics, including line and column information. + /// + /// + /// The formatted diagnostic message, or an empty string if no diagnostics have been recorded. + /// + /// + /// The presence or absence of diagnostics does not indicate whether parsing succeeded. + /// + public readonly override string ToString() + { + var message = _diagnostics.ToString(); + return message.Length != 0 + ? GenerateErrorMessage(_source, _diagnostics.Index, message) + : ""; + } /// /// Generates a formatted error message that includes line and column information. diff --git a/src/Ramstack.Parsing/Parser.Where.cs b/src/Ramstack.Parsing/Parser.Where.cs new file mode 100644 index 0000000..efe312a --- /dev/null +++ b/src/Ramstack.Parsing/Parser.Where.cs @@ -0,0 +1,64 @@ +namespace Ramstack.Parsing; + +partial class Parser +{ + /// + /// Creates a parser that accepts a parsed value only when it satisfies the specified predicate. + /// + /// The type of the value produced by the parser. + /// The parser that produces the value to check. + /// The condition that the parsed value must satisfy. + /// A non-empty description of the expected value for diagnostics. + /// + /// A parser that returns the original value on success, or an ordinary parse failure otherwise. + /// + /// + /// + /// Prefer a side-effect-free predicate: restoring input does not undo changes to external state. + /// + /// + /// Exceptions are not converted into predicate rejections. + /// + /// + public static Parser Where(this Parser parser, Func predicate, string expected) + { + Argument.ThrowIfNull(parser); + Argument.ThrowIfNull(predicate); + Argument.ThrowIfNullOrEmpty(expected); + + return new WhereParser(parser, predicate, expected); + } + + /// + /// Represents a parser that checks a condition on the parsed value. + /// + /// The type of the value produced by the parser. + /// The parser that produces the value to check. + /// The condition that the parsed value must satisfy. + /// A non-empty description of the expected value for diagnostics. + private sealed class WhereParser(Parser parser, Func predicate, string expected) : Parser + { + /// + public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out T? value) + { + var bookmark = context.BookmarkPosition(); + + if (!parser.TryParse(ref context, out value)) + return false; + + if (predicate(value)) + return true; + + context.ReportExpected( + bookmark.Position, expected); + + context.RestorePosition(bookmark); + value = default; + return false; + } + + // + // NOTE: The default void wrapper preserves the value needed by the predicate. + // + } +} diff --git a/src/Ramstack.Parsing/Parser`1.cs b/src/Ramstack.Parsing/Parser`1.cs index 5a4cfd4..d078cd4 100644 --- a/src/Ramstack.Parsing/Parser`1.cs +++ b/src/Ramstack.Parsing/Parser`1.cs @@ -88,10 +88,13 @@ public ParseResult Parse(ReadOnlySpan source) }; } + var message = context.ToString(); return new ParseResult { Length = 0, - ErrorMessage = context.ToString() + ErrorMessage = message.Length == 0 + ? "Parsing failed." + : message }; } catch (FatalErrorException exception) diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.As.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.As.cs index f3b8769..e4e855e 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.As.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.As.cs @@ -11,4 +11,12 @@ public void AsTest() Any.As("character").Parse("").ErrorMessage, Is.EqualTo("(1:1) Expected character")); } + + [Test] + public void As_Success_PreservesLaterDiagnostics() + { + var parser = L('a').As("letter").ThenIgnore(L('b')); + + Assert.That(parser.Parse("ax").ErrorMessage, Is.EqualTo("(1:2) Expected 'b'")); + } } diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.Eof.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Eof.cs index e0fe46e..dffad86 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.Eof.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Eof.cs @@ -17,4 +17,12 @@ public void EofTest(string input) Assert.That(Eol.Map(m => (m.Index, m.Length)).Parse(input).Value, Is.EqualTo((0, 0))); Assert.That(Eol.Map(m => m.ToString()).Parse(input).Value, Is.Empty); } + + [Test] + public void Eof_TrailingInput_ReportsEndOfInput() + { + var parser = L('4').ThenIgnore(Eof); + + Assert.That(parser.Parse("4x").ErrorMessage, Is.EqualTo("(1:2) Expected end of input")); + } } diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs new file mode 100644 index 0000000..97a6240 --- /dev/null +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs @@ -0,0 +1,430 @@ +using static Ramstack.Parsing.Parser; + +namespace Ramstack.Parsing; + +partial class ParsersTests +{ + [TestCase("0", 0)] + [TestCase("49", 49)] + [TestCase("123", 123)] + public void Where_AcceptedValue_ReturnsValue(string input, int expected) + { + var parser = L('!') + .Then( + Literal + .Number() + .ThenIgnore(S)) + .Where( + static n => n is >= 0 and <= 255, + "octet"); + + var context = new ParseContext($"!{input} \t?"); + + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value, Is.EqualTo(expected)); + Assert.That(context.Position, Is.EqualTo(input.Length + 3)); + Assert.That(context.MatchedSegment.ToString(), Is.EqualTo(input)); + Assert.That(context.Remaining.ToString(), Is.EqualTo("?")); + } + + [TestCase("-1")] + [TestCase("256")] + public void Where_RejectedValue_RestoresPosition(string input) + { + var parser = L('!') + .Then(S) + .Then( + Literal + .Number() + .ThenIgnore(S)) + .Where( + static n => n is >= 0 and <= 255, + "octet"); + + var context = new ParseContext($"! \t{input} ?"); + + Assert.That(parser.TryParse(ref context, out var value), Is.False); + Assert.That(value, Is.Zero); + Assert.That(context.Position, Is.Zero); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.MatchedSegment.ToString(), Is.Empty); + Assert.That(context.Remaining.ToString(), Is.EqualTo(context.Source.ToString())); + Assert.That(context.ToString(), Is.EqualTo("(1:1) Expected octet")); + } + + [TestCase("-1")] + [TestCase("256")] + public void Where_RejectedValueAfterPrefix_RestoresPosition(string input) + { + var parser = Literal + .Number() + .ThenIgnore(S) + .Where( + n => n is >= 0 and <= 255, + "octet"); + + var context = new ParseContext($"!{input} ?"); + + Assert.That(L('!').TryParse(ref context, out _), Is.True); + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(0)); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(1)); + + Assert.That(parser.TryParse(ref context, out var value), Is.False); + Assert.That(value, Is.Zero); + + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.Remaining.ToString(), Is.EqualTo($"{input} ?")); + Assert.That(context.ToString(), Is.EqualTo("(1:2) Expected octet")); + } + + [Test] + public void Where_PrimitiveFailure_PreservesInnerState() + { + var calls = 0; + var parser = L('c').Where(_ => + { + calls++; + return true; + }, "validated character"); + + var context = new ParseContext("!abx"); + + Assert.That(L('!').TryParse(ref context, out _), Is.True); + + Assert.That(parser.TryParse(ref context, out var value), Is.False); + + Assert.That(value, Is.EqualTo('\0')); + Assert.That(calls, Is.Zero); + + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Index, Is.EqualTo(0)); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(1)); + Assert.That(context.ToString(), Is.EqualTo("(1:2) Expected 'c'")); + } + + [Test] + public void Where_CompositeFailure_PreservesInnerState() + { + var calls = 0; + var parser = L("ab").Then(L('c')).Where(_ => + { + calls++; + return true; + }, "validated character"); + + var context = new ParseContext("!abx"); + + Assert.That(L('!').TryParse(ref context, out _), Is.True); + + Assert.That(parser.TryParse(ref context, out var value), Is.False); + + Assert.That(value, Is.EqualTo('\0')); + Assert.That(calls, Is.Zero); + + Assert.That(context.Position, Is.EqualTo(1)); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.ToString(), Is.EqualTo("(1:4) Expected 'c'")); + } + + [Test] + public void Where_RejectedAlternative_ParsesFallbackFromOriginalPosition() + { + var number = Literal.Number(); + var parser = L('!') + .Then(number + .Where(n => n <= 100, "small integer") + .Or(number)); + + var context = new ParseContext("!150?"); + + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value, Is.EqualTo(150)); + Assert.That(context.Position, Is.EqualTo(4)); + Assert.That((ValueTuple)context.MatchedSegment, Is.EqualTo((1, 3))); + } + + [Test] + public void Where_RejectedFirstAlternative_ParsesFallback() + { + var parserA = L('a').Do(_ => 1).Where(n => n == 2, "two"); + var parserB = L('a').Do(_ => 2); + + var parser = parserA.Or(parserB); + var result = parser.Parse("a"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo(2)); + } + + [Test] + public void Where_OuterRejection_DoesNotRetryInnerAlternative() + { + var parserA = L('a').Do(_ => 1); + var parserB = L('a').Do(_ => 2); + + var parser = parserA.Or(parserB).Where(n => n == 2, "two"); + var result = parser.Parse("a"); + + Assert.That(result.Success, Is.False); + Assert.That(result.Value, Is.Zero); + Assert.That(result.ErrorMessage, Is.EqualTo("(1:1) Expected two")); + } + + [Test] + public void Where_OptionalRejection_LeavesInputForNextParser() + { + var parser = Seq( + L('a').Where(_ => false, "accepted character").Optional(), + L('a').Optional()); + + var context = new ParseContext("a"); + + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value.Value1.HasValue, Is.False); + Assert.That(value.Value2.HasValue, Is.True); + Assert.That(value.Value2.Value, Is.EqualTo('a')); + } + + [Test] + public void Where_RepetitionRejection_StopsBeforeRejectedValue() + { + var parser = Character + .Digit.Where( + static c => c != '3', + "digit other than three") + .ZeroOrMore(); + + var context = new ParseContext("123"); + + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value, Is.EqualTo(['1', '2'])); + + Assert.That(context.Position, Is.EqualTo(2)); + Assert.That(context.MatchedSegment.ToString(), Is.EqualTo("12")); + Assert.That(context.Remaining.ToString(), Is.EqualTo("3")); + + Assert.That( + parser.Void().Then(L('3')).ThenIgnore(Eof).TryParse("123", out _), + Is.True); + } + + [TestCase("42", true, 42)] + [TestCase("256", false, 256)] + public void Where_VoidAndSuppressedDiagnostics_PreserveRequiredTransformations(string input, bool accepted, int expected) + { + var mapCalls = 0; + var doCalls = 0; + var predicateCalls = 0; + + var parser = Character + .Digit.OneOrMore() + .Map(m => + { + mapCalls++; + return m.ToString(); + }) + .Do(text => + { + doCalls++; + return int.Parse(text, System.Globalization.CultureInfo.InvariantCulture); + }) + .Where(n => + { + predicateCalls++; + Assert.That(n, Is.EqualTo(expected)); + return n is >= 0 and <= 255; + }, "octet") + .Void(); + + var context = new ParseContext(input) + { + DiagnosticState = DiagnosticState.Suppressed + }; + + Assert.That(parser.TryParse(ref context, out _), Is.EqualTo(accepted)); + + Assert.That(mapCalls, Is.EqualTo(1)); + Assert.That(doCalls, Is.EqualTo(1)); + Assert.That(predicateCalls, Is.EqualTo(1)); + + Assert.That(context.Position, Is.EqualTo(accepted ? input.Length : 0)); + Assert.That(context.MatchedSegment.Length, Is.EqualTo(accepted ? input.Length : 0)); + Assert.That(context.DiagnosticState, Is.EqualTo(DiagnosticState.Suppressed)); + Assert.That(context.ToString(), Is.Empty); + } + + [TestCase("42", true, null)] + [TestCase("256", false, "(1:1) Expected octet")] + [TestCase("abc", false, "(1:1) Expected integer")] + [TestCase("42xyz", false, "(1:3) Expected end of input")] + public void Where_CompleteInput_ReportsPredicateInnerOrEofFailure(string input, bool accepted, string? error) + { + var parser = Literal + .Number("integer") + .Where( + n => n is >= 0 and <= 255, + "octet") + .ThenIgnore(Eof); + + var result = parser.Parse(input); + var voidResult = parser.Void().Parse(input); + + Assert.That(result.Success, Is.EqualTo(accepted)); + Assert.That(result.ErrorMessage, Is.EqualTo(error)); + + Assert.That(voidResult.Success, Is.EqualTo(accepted)); + Assert.That(voidResult.ErrorMessage, Is.EqualTo(error)); + + Assert.That(parser.TryParse(input, out _), Is.EqualTo(accepted)); + } + + [Test] + public void Where_AlternativeFailures_MergeExpectationsAtSamePosition() + { + var parser = L('a').Where(_ => false, "accepted character").Or(L('b')); + + Assert.That(parser.Parse("a").ErrorMessage, Is.EqualTo("(1:1) Expected accepted character or 'b'")); + } + + [Test] + public void Where_FartherInnerDiagnostic_TakesPrecedenceOverPredicateRejection() + { + var parser = L('a').ThenIgnore(L('b').Optional()).Where(_ => false, "accepted character"); + + Assert.That(parser.Parse("a?").ErrorMessage, Is.EqualTo("(1:2) Expected 'b'")); + } + + [Test] + public void Where_FartherExistingDiagnostic_IsPreservedAfterRollback() + { + var parser = + L("ab").Then(L('c')) + .Or(L('a').Where(_ => false, "accepted character")); + + Assert.That(parser.Parse("ab?").ErrorMessage, Is.EqualTo("(1:3) Expected 'c'")); + } + + [TestCase("ab")] + [TestCase("ax")] + public void Where_NamedParser_ReplacesInnerAndPredicateExpectations(string input) + { + var parser = L('a') + .Then(L('b')) + .Where(_ => false, "accepted character") + .As("rule"); + + Assert.That(parser.Parse(input).ErrorMessage, Is.EqualTo("(1:1) Expected rule")); + Assert.That(parser.Void().Parse(input).ErrorMessage, Is.EqualTo("(1:1) Expected rule")); + Assert.That(parser.Void().As("renamed").Parse(input).ErrorMessage, Is.EqualTo("(1:1) Expected renamed")); + } + + [TestCase("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "(1:1) Expected identifier of at most 64 characters", 1)] + [TestCase("int", "(1:1) Expected non-reserved identifier", 2)] + [TestCase("foo__bar", "(1:1) Expected identifier without double underscore", 3)] + [TestCase("foo_bar", null, 3)] + public void Where_ChainedChecks_ReportFirstFailingStage(string input, string? error, int executedStages) + { + var stageCalls = new int[3]; + var reserved = new HashSet { "class", "int", "return" }; + + // An unnamed lexeme would leave the trailing expectation of ZeroOrMore + // at the end of input, and by the farthest-error policy it would shadow + // the expected messages of the chain. + var parser = Seq( + Set("A-Za-z_"), + Set("A-Za-z0-9_").ZeroOrMore()) + .Text() + .As("identifier") + .Where(s => + { + stageCalls[0]++; + return s.Length <= 64; + }, "identifier of at most 64 characters") + .Where(s => + { + stageCalls[1]++; + return !reserved.Contains(s); + }, "non-reserved identifier") + .Where(s => + { + stageCalls[2]++; + return !s.Contains("__", StringComparison.Ordinal); + }, "identifier without double underscore"); + + var result = parser.Parse(input); + + Assert.That(result.Success, Is.EqualTo(error is null), result.ErrorMessage); + Assert.That(result.ErrorMessage, Is.EqualTo(error)); + + var expectedCalls = Enumerable + .Range(0, stageCalls.Length) + .Select(i => i < executedStages ? 1 : 0) + .ToArray(); + + Assert.That(stageCalls, Is.EqualTo(expectedCalls)); + } + + [Test] + public void Where_ChainedChecks_KeepBaseValidatorMessages() + { + var octet = Literal + .Number("integer") + .Where(n => n is >= 0 and <= 255, "octet"); + + var nonZeroOctet = octet + .Where(n => n != 0, "non-zero octet"); + + Assert.That(octet.Parse("0").Success, Is.True); + Assert.That(nonZeroOctet.Parse("7").Success, Is.True); + Assert.That(nonZeroOctet.Parse("0").ErrorMessage, Is.EqualTo("(1:1) Expected non-zero octet")); + Assert.That(nonZeroOctet.Parse("256").ErrorMessage, Is.EqualTo("(1:1) Expected octet")); + } + + [Test] + public void Where_PredicateThrows_UsesCallbackExceptionContract() + { + var exception = new InvalidOperationException("predicate failure"); + var fallbackCalls = 0; + + var parser = L('a') + .Where(_ => throw exception, "accepted character") + .Or(L('a').Do(c => + { + fallbackCalls++; + return c; + })); + + var result = parser.Parse("a"); + + Assert.That(result.Success, Is.False); + Assert.That(result.Exception, Is.SameAs(exception)); + Assert.That(result.ErrorMessage, Does.StartWith("(1:2) ").And.Contain("predicate failure")); + + Assert.That(Assert.Throws(() => parser.TryParse("a", out _)), Is.SameAs(exception)); + Assert.That(parser.Void().Parse("a").Exception, Is.SameAs(exception)); + Assert.That(Assert.Throws(() => parser.Void().TryParse("a", out _)), Is.SameAs(exception)); + Assert.That(fallbackCalls, Is.Zero); + } + + [Test] + public void Where_FatalErrorInPredicate_UsesFatalErrorContract() + { + var parser = L('a').Where(c => + { + if (c == 'a') + FatalError("forced failure"); + + return true; + }, "accepted character"); + + Assert.That(parser.TryParse("a", out var value), Is.False); + Assert.That(value, Is.EqualTo('\0')); + + var result = parser.Parse("a"); + + Assert.That(result.ErrorMessage, Is.EqualTo("(1:2) forced failure")); + Assert.That(result.Exception, Is.Null); + } +} diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.cs index 50dbd77..0707516 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.cs @@ -3,6 +3,75 @@ namespace Ramstack.Parsing; [TestFixture] public partial class ParsersTests { + [TestCase("", 0)] + [TestCase("a\nb", 2)] + public void ToString_NoDiagnostics_ReturnsEmptyString(string source, int position) + { + var context = new ParseContext(source); + context.Advance(position); + + Assert.That(context.ToString(), Is.Empty); + } + + [Test] + public void ToString_RecordedDiagnostics_PreservesErrorPositionAfterRollback() + { + var context = new ParseContext("a\nb"); + var bookmark = context.BookmarkPosition(); + + context.Advance(2); + context.ReportExpected("digit"); + context.RestorePosition(bookmark); + + Assert.That(context.Position, Is.Zero); + Assert.That(context.ToString(), Is.EqualTo("(2:1) Expected digit")); + } + + [Test] + public void ToString_SuccessfulOptionalMatch_PreservesDiagnostics() + { + var parser = Parser.L('a').Optional(); + var context = new ParseContext("b"); + + Assert.That(parser.TryParse(ref context, out var value), Is.True); + Assert.That(value.HasValue, Is.False); + Assert.That(context.ToString(), Is.EqualTo("(1:1) Expected 'a'")); + + var result = parser.Parse("b"); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value.HasValue, Is.False); + Assert.That(result.Length, Is.Zero); + Assert.That(result.ErrorMessage, Is.Null); + } + + [Test] + public void Parse_FailedMatchWithoutDiagnostics_ReturnsGenericFailure() + { + var result = new NonReportingParser().Parse("x"); + + Assert.That(result.Success, Is.False); + Assert.That(result.Length, Is.Zero); + Assert.That(result.ErrorMessage, Is.EqualTo("Parsing failed.")); + Assert.That(result.ToString(), Is.EqualTo("Parsing failed.")); + Assert.That(result.Exception, Is.Null); + } + + #region Inner type: NonReportingParser + + private sealed class NonReportingParser : Parser + { + public override bool TryParse(ref ParseContext context, out char value) + { + value = '\0'; + return false; + } + } + + #endregion + + #region Inner type: BoundedParser + // Bound parser invocations so a missing progress check fails instead of hanging the test run. private sealed class BoundedParser(Parser parser) : Parser { @@ -17,4 +86,6 @@ public override bool TryParse(ref ParseContext context, [NotNullWhen(true)] out return parser.TryParse(ref context, out value); } } + + #endregion }