From 2dafa27aee94dc11e88b359a5196d85ab203474a Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 29 Sep 2026 23:51:59 +0500 Subject: [PATCH 1/5] parsers: add Where for parsed value conditions Add Where(predicate, expected) to reject parsed values with an ordinary parse failure. Restore the input position on predicate rejection and report the expected value at the start of the attempt. --- src/Ramstack.Parsing/Parser.Where.cs | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/Ramstack.Parsing/Parser.Where.cs 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. + // + } +} From 3062477d88478122de5ce7bffaa016ad9dfe9f09 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 30 Sep 2026 19:56:17 +0500 Subject: [PATCH 2/5] diagnostics: return empty text when no parsing diagnostics are recorded While writing tests for Where, discovered that ParseContext.ToString() returned "(1:1) " even when no diagnostics had been recorded. Return an empty string in that case and document the actual behavior. Use "Parsing failed." without coordinates when parsing fails without diagnostics, so the failure is not mistaken for success. --- src/Ramstack.Parsing/ParseContext.cs | 19 +++++- src/Ramstack.Parsing/Parser`1.cs | 5 +- tests/Ramstack.Parsing.Tests/ParsersTests.cs | 71 ++++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) 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`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.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 } From 9b3b4b1bc046ab146155612765e9244d5d171db9 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 30 Sep 2026 20:07:28 +0500 Subject: [PATCH 3/5] parsers: test Where acceptance, rollback, and diagnostics --- .../ParsersTests.Where.cs | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs new file mode 100644 index 0000000..c4fc1a5 --- /dev/null +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs @@ -0,0 +1,343 @@ +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(false, "(1:2) Expected 'c'")] + [TestCase(true, "(1:4) Expected 'c'")] + public void Where_InnerFailure_SkipsPredicateAndPreservesDiagnostics(bool composite, string error) + { + var calls = 0; + var inner = composite + ? L("ab").Then(L('c')) + : L('c'); + + var parser = L('!').Then(inner.Where(_ => + { + calls++; + return true; + }, "validated character")); + + var context = new ParseContext("!abx"); + + 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.Zero); + Assert.That(context.MatchedSegment.Length, Is.Zero); + Assert.That(context.ToString(), Is.EqualTo(error)); + } + + [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_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)); + } + + [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); + } +} From 02bd680e33808ba2baf7dfe180b6d3d117000b64 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 30 Sep 2026 20:12:42 +0500 Subject: [PATCH 4/5] parsers: test As and Eof diagnostics Pin that a successful As preserves later diagnostics and that Eof reports its position after a consumed prefix. Both interactions surfaced while testing Where, which relies on them indirectly; dedicated tests make a failure point at the owning parser instead of showing up inside the Where suite. --- tests/Ramstack.Parsing.Tests/ParsersTests.As.cs | 8 ++++++++ tests/Ramstack.Parsing.Tests/ParsersTests.Eof.cs | 8 ++++++++ 2 files changed, 16 insertions(+) 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")); + } } From 4cc78690f696e0ed764e5c31c8ab416b3045b287 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 30 Sep 2026 23:08:21 +0500 Subject: [PATCH 5/5] parsers: test Where outer rejection, fatal errors, and suppressed diagnostics --- .../ParsersTests.Where.cs | 107 ++++++++++++++++-- 1 file changed, 97 insertions(+), 10 deletions(-) diff --git a/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs b/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs index c4fc1a5..97a6240 100644 --- a/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs +++ b/tests/Ramstack.Parsing.Tests/ParsersTests.Where.cs @@ -52,29 +52,80 @@ public void Where_RejectedValue_RestoresPosition(string input) Assert.That(context.ToString(), Is.EqualTo("(1:1) Expected octet")); } - [TestCase(false, "(1:2) Expected 'c'")] - [TestCase(true, "(1:4) Expected 'c'")] - public void Where_InnerFailure_SkipsPredicateAndPreservesDiagnostics(bool composite, string error) + [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 inner = composite - ? L("ab").Then(L('c')) - : L('c'); + 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'")); + } - var parser = L('!').Then(inner.Where(_ => + [Test] + public void Where_CompositeFailure_PreservesInnerState() + { + var calls = 0; + var parser = L("ab").Then(L('c')).Where(_ => { calls++; return true; - }, "validated character")); + }, "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.Zero); + + Assert.That(context.Position, Is.EqualTo(1)); Assert.That(context.MatchedSegment.Length, Is.Zero); - Assert.That(context.ToString(), Is.EqualTo(error)); + Assert.That(context.ToString(), Is.EqualTo("(1:4) Expected 'c'")); } [Test] @@ -107,6 +158,20 @@ public void Where_RejectedFirstAlternative_ParsesFallback() 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() { @@ -186,6 +251,8 @@ public void Where_VoidAndSuppressedDiagnostics_PreserveRequiredTransformations(s 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)] @@ -340,4 +407,24 @@ public void Where_PredicateThrows_UsesCallbackExceptionContract() 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); + } }