From d2f690b299f823e058fd2f2641746306d3ab9304 Mon Sep 17 00:00:00 2001 From: liam Date: Mon, 23 Dec 2024 11:14:50 -0800 Subject: [PATCH] Ensure combinator parsers rollback even if one of their parsers misbehave --- core/all_test.go | 7 +++++ core/any.go | 2 ++ core/any_test.go | 7 +++++ core/or.go | 2 ++ core/or_test.go | 8 ++++++ core/sequences_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++ core/times_test.go | 7 +++++ test/parser.go | 18 +++++++++--- 8 files changed, 111 insertions(+), 4 deletions(-) diff --git a/core/all_test.go b/core/all_test.go index 66f13f2..07a1c02 100644 --- a/core/all_test.go +++ b/core/all_test.go @@ -30,6 +30,13 @@ func TestAll(t *testing.T) { ExpectedOK: false, RemainingInput: "AC", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "AC", + Parser: core.All(NaughtyParser[string](), core.Rune('C')), + ExpectedOK: false, + RemainingInput: "AC", + }, { Name: "match", Input: "AB", diff --git a/core/any.go b/core/any.go index 5caaa76..ae94913 100644 --- a/core/any.go +++ b/core/any.go @@ -17,6 +17,7 @@ package core // Any looks for matches in the given parsers, returning the first match or rolls back the input if no match is found. func Any[T any](parsers ...Parser[T]) Parser[T] { return func(in Input) (T, bool, error) { + start := in.Checkpoint() for _, parser := range parsers { match, ok, err := parser(in) if err != nil || ok { @@ -24,6 +25,7 @@ func Any[T any](parsers ...Parser[T]) Parser[T] { } } var t T + in.Restore(start) return t, false, nil } } diff --git a/core/any_test.go b/core/any_test.go index 14db620..4f2c95d 100644 --- a/core/any_test.go +++ b/core/any_test.go @@ -30,6 +30,13 @@ func TestAny(t *testing.T) { ExpectedOK: false, RemainingInput: "C", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "C", + Parser: core.Any(NaughtyParser[string](), core.Rune('C')), + ExpectedOK: false, + RemainingInput: "C", + }, { Name: "match", Input: "B", diff --git a/core/or.go b/core/or.go index ffe7538..62d5f73 100644 --- a/core/or.go +++ b/core/or.go @@ -18,6 +18,7 @@ package core // It returns as soon as one of the parsers are successful or rolls back when none are. func Or[A any, B any](a Parser[A], b Parser[B]) Parser[Tuple2[Match[A], Match[B]]] { return func(in Input) (Tuple2[Match[A], Match[B]], bool, error) { + start := in.Checkpoint() var res tuple2[Match[A], Match[B]] matchA, okA, errA := a(in) @@ -38,6 +39,7 @@ func Or[A any, B any](a Parser[A], b Parser[B]) Parser[Tuple2[Match[A], Match[B] return res, true, nil } + in.Restore(start) return res, false, nil } } diff --git a/core/or_test.go b/core/or_test.go index f6eb900..0ee9254 100644 --- a/core/or_test.go +++ b/core/or_test.go @@ -31,6 +31,14 @@ func TestOr(t *testing.T) { ExpectedOK: false, RemainingInput: "C", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "C", + Parser: Or(NaughtyParser[string](), Rune('C')), + ExpectedMatch: NewTuple2(NewMatch("", false), NewMatch("", false)), + ExpectedOK: false, + RemainingInput: "C", + }, { Name: "first match", Input: "A", diff --git a/core/sequences_test.go b/core/sequences_test.go index c408712..7599b57 100644 --- a/core/sequences_test.go +++ b/core/sequences_test.go @@ -31,6 +31,14 @@ func TestSequence2(t *testing.T) { ExpectedOK: false, RemainingInput: "AB", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "AB", + Parser: core.SequenceOf2(NaughtyParser[string](), core.String("B")), + ExpectedMatch: core.NewTuple2("", ""), + ExpectedOK: false, + RemainingInput: "AB", + }, { Name: "partial match", Input: "AB", @@ -60,6 +68,14 @@ func TestSequence3(t *testing.T) { ExpectedOK: false, RemainingInput: "ABC", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "ABC", + Parser: core.SequenceOf3(NaughtyParser[string](), core.String("B"), core.String("C")), + ExpectedMatch: core.NewTuple3("", "", ""), + ExpectedOK: false, + RemainingInput: "ABC", + }, { Name: "partial match", Input: "ABC", @@ -89,6 +105,14 @@ func TestSequence4(t *testing.T) { ExpectedOK: false, RemainingInput: "ABCD", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "ABCD", + Parser: core.SequenceOf4(NaughtyParser[string](), core.String("B"), core.String("C"), core.String("D")), + ExpectedMatch: core.NewTuple4("", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCD", + }, { Name: "partial match", Input: "ABCD", @@ -118,6 +142,14 @@ func TestSequence5(t *testing.T) { ExpectedOK: false, RemainingInput: "ABCDE", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "ABCDE", + Parser: core.SequenceOf5(NaughtyParser[string](), core.String("B"), core.String("C"), core.String("D"), core.String("E")), + ExpectedMatch: core.NewTuple5("", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDE", + }, { Name: "partial match", Input: "ABCDE", @@ -147,6 +179,14 @@ func TestSequence6(t *testing.T) { ExpectedOK: false, RemainingInput: "ABCDEF", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "ABCDEF", + Parser: core.SequenceOf6(NaughtyParser[string](), core.String("B"), core.String("C"), core.String("D"), core.String("E"), core.String("F")), + ExpectedMatch: core.NewTuple6("", "", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, { Name: "partial match", Input: "ABCDEF", @@ -176,6 +216,14 @@ func TestSequence7(t *testing.T) { ExpectedOK: false, RemainingInput: "ABCDEFG", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "ABCDEFG", + Parser: core.SequenceOf7(NaughtyParser[string](), core.String("B"), core.String("C"), core.String("D"), core.String("E"), core.String("F"), core.String("G")), + ExpectedMatch: core.NewTuple7("", "", "", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDEFG", + }, { Name: "partial match", Input: "ABCDEFG", @@ -205,6 +253,14 @@ func TestSequence8(t *testing.T) { ExpectedOK: false, RemainingInput: "ABCDEFGH", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "ABCDEFGH", + Parser: core.SequenceOf8(NaughtyParser[string](), core.String("B"), core.String("C"), core.String("D"), core.String("E"), core.String("F"), core.String("G"), core.String("H")), + ExpectedMatch: core.NewTuple8("", "", "", "", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDEFGH", + }, { Name: "partial match", Input: "ABCDEFGH", @@ -234,6 +290,14 @@ func TestSequence9(t *testing.T) { ExpectedOK: false, RemainingInput: "ABCDEFGHI", }, + { + Name: "no match rolls back input even if one of the parsers consumed input", + Input: "ABCDEFGHI", + Parser: core.SequenceOf9(NaughtyParser[string](), core.String("B"), core.String("C"), core.String("D"), core.String("E"), core.String("F"), core.String("G"), core.String("H"), core.String("I")), + ExpectedMatch: core.NewTuple9("", "", "", "", "", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDEFGHI", + }, { Name: "partial match", Input: "ABCDEFGHI", diff --git a/core/times_test.go b/core/times_test.go index 183b6e3..b95a028 100644 --- a/core/times_test.go +++ b/core/times_test.go @@ -37,6 +37,13 @@ func TestTime(t *testing.T) { ExpectedMatch: []string{"A", "A"}, ExpectedOK: true, }, + { + Name: "Times: no match rolls back input even if one of the parsers consumed input", + Input: "AC", + Parser: core.Times(2, NaughtyParser[string]()), + ExpectedOK: false, + RemainingInput: "AC", + }, { Name: "Between: at least 1 up to 5 with 4", Input: "AAAA", diff --git a/test/parser.go b/test/parser.go index d04ef14..75e8557 100644 --- a/test/parser.go +++ b/test/parser.go @@ -17,14 +17,14 @@ package core_test import ( "testing" - "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/core" "github.com/stretchr/testify/assert" ) type ParserTest[T any] struct { Name string Input string - Parser core.Parser[T] + Parser Parser[T] ExpectedMatch T ExpectedOK bool WantErr bool @@ -34,7 +34,7 @@ type ParserTest[T any] struct { func RunTests[T any](t *testing.T, tests []ParserTest[T]) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { - in := core.NewInput(test.Input) + in := NewInput(test.Input) match, ok, err := test.Parser(in) assert.Equal(t, test.ExpectedOK, ok) assert.Equal(t, test.ExpectedMatch, match) @@ -44,7 +44,7 @@ func RunTests[T any](t *testing.T, tests []ParserTest[T]) { assert.NoError(t, err) } - remaining, _, _ := core.StringWhileNot(core.EOF[string]())(in) + remaining, _, _ := StringWhileNot(EOF[string]())(in) assert.Equal(t, test.RemainingInput, remaining) }) } @@ -60,3 +60,13 @@ func CaPiTaLiZe(s string) string { } return string(runes) } + +// NaughtyParser parser consumes input but does not rollback +// This is useful for testing that combinators rollback input correctly +func NaughtyParser[T any]() Parser[T] { + return func(in Input) (T, bool, error) { + in.Take(1) + var res T + return res, false, nil + } +}