diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..404f2c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + hygiene: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: false + + - name: Install Licenser + run: go install github.com/liamawhite/licenser@21016ac7e79acc475e3b11f71aac7499a21790a5 + + - run: make hygiene + - run: make dirty + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: false + + - run: make test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a902e2 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +# Copyright 2024 Liam White +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +.PHONY: all hygiene dirty mod format test licenser + +all: format mod test dirty + +hygiene: format mod + +dirty: + git diff --exit-code + +mod: + go mod tidy + +format: licenser + gofmt -w . + +test: + go test -v ./... + +licenser: + licenser apply -r "Liam White" diff --git a/README.md b/README.md index 3a80c19..ccbca0f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ -# parse -A functional parser library written in Go. +# Parse + +Parse is a functional parsing library written in Go. It enables the building of complex parsers from small, simple and (most importantly) unit testable functions that process the input. + +The core of the library is a partial rewrite of [Adrian Hesketh's library](https://github.com/a-h/parse/) with reduced verbosity, bug fixes and then extended as required for [Notedown](https://github.com/notedownorg/notedown). + +## Packages + +- [`core`](./core) contains all the base parsers for parsing documents. +- [`time`](./time) contains all parsers related to time, dates and durations. +- [`test`](./test) contains helper functions for unit testing your own parsers. + +The packages are designed to be composable via dot import. Dot imports are generally discouraged in Golang except in the case of reducing verbosity for DSL-like APIs which is typical here. + +```go + +import ( + . "github.com/liamawhite/parse/core" + // . "github.com/liamawhite/parse/time" whichever other packages you may need +) + +var BlankLine = Times(2, NewLine) // \n\n + +``` + + +## Implementing Your Own Parsers + +To implement a parser implement the `Parser[T]` type alias, a function that takes an `Input` and returns `(T, bool, error)`. Each parser should attempt to parse the `Input` and roll back if it is unable to find what it is looking for. + +You can find examples in the [`time`](./time) package. diff --git a/core/all.go b/core/all.go new file mode 100644 index 0000000..29f9bdf --- /dev/null +++ b/core/all.go @@ -0,0 +1,32 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +// All matches all of the given parsers in order or rolls back the input. +func All[T any](parsers ...Parser[T]) Parser[[]T] { + return func(in Input) ([]T, bool, error) { + start := in.Checkpoint() + match := make([]T, 0, len(parsers)) + for _, parser := range parsers { + m, ok, err := parser(in) + if err != nil || !ok { + in.Restore(start) + return nil, false, err + } + match = append(match, m) + } + return match, true, nil + } +} diff --git a/core/all_test.go b/core/all_test.go new file mode 100644 index 0000000..66f13f2 --- /dev/null +++ b/core/all_test.go @@ -0,0 +1,42 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestAll(t *testing.T) { + tests := []ParserTest[[]string]{ + { + Name: "no match", + Input: "AC", + Parser: core.All(core.Rune('A'), core.Rune('B')), + ExpectedOK: false, + RemainingInput: "AC", + }, + { + Name: "match", + Input: "AB", + Parser: core.All(core.Rune('A'), core.Rune('B')), + ExpectedMatch: []string{"A", "B"}, + ExpectedOK: true, + }, + } + RunTests(t, tests) +} diff --git a/core/any.go b/core/any.go new file mode 100644 index 0000000..5caaa76 --- /dev/null +++ b/core/any.go @@ -0,0 +1,29 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +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) { + for _, parser := range parsers { + match, ok, err := parser(in) + if err != nil || ok { + return match, true, err + } + } + var t T + return t, false, nil + } +} diff --git a/core/any_test.go b/core/any_test.go new file mode 100644 index 0000000..14db620 --- /dev/null +++ b/core/any_test.go @@ -0,0 +1,43 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestAny(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "no match", + Input: "C", + Parser: core.Any(core.Rune('A'), core.Rune('B')), + ExpectedOK: false, + RemainingInput: "C", + }, + { + Name: "match", + Input: "B", + Parser: core.Any(core.Rune('A'), core.Rune('B')), + ExpectedMatch: "B", + ExpectedOK: true, + }, + } + RunTests(t, tests) + +} diff --git a/core/eof.go b/core/eof.go new file mode 100644 index 0000000..841569d --- /dev/null +++ b/core/eof.go @@ -0,0 +1,24 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +// EOF matches the end of the input. Use T to make it composeable with other parsers. +func EOF[T any]() Parser[T] { + return func(in Input) (T, bool, error) { + _, canAdvance := in.Peek(1) + var t T + return t, !canAdvance, nil + } +} diff --git a/core/eof_test.go b/core/eof_test.go new file mode 100644 index 0000000..0337e2b --- /dev/null +++ b/core/eof_test.go @@ -0,0 +1,41 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestEOF(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "no match", + Input: "A", + Parser: core.EOF[string](), + ExpectedOK: false, + RemainingInput: "A", + }, + { + Name: "match", + Input: "", + Parser: core.EOF[string](), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} diff --git a/core/input.go b/core/input.go new file mode 100644 index 0000000..7193747 --- /dev/null +++ b/core/input.go @@ -0,0 +1,68 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +// Use unexported type alias so we control the interface and prevent people doing arbirary things e.g. use negative ints. +type checkpoint int + +type Input interface { + Peek(n int) (string, bool) + Take(n int) (string, bool) + Checkpoint() checkpoint + Restore(cp checkpoint) +} + +type input struct { + s string + index int +} + +func NewInput(s string) Input { + return &input{ + s: s, + } +} + +func (i *input) Peek(n int) (s string, ok bool) { + if i.index+n > len(i.s) { + return + } + if n < 0 { + return i.s[i.index:], true + } + return i.s[i.index : i.index+n], true +} + +func (i *input) Take(n int) (s string, ok bool) { + if i.index+n > len(i.s) { + return + } + if n < 0 { + return + } + from := i.index + i.index += n + return i.s[from:i.index], true +} + +// Take a snapshot of the current parsing position +func (i *input) Checkpoint() checkpoint { + return checkpoint(i.index) +} + +// Restore the parsing position to a previous snapshot +func (i *input) Restore(cp checkpoint) { + i.index = int(cp) +} diff --git a/core/optional.go b/core/optional.go new file mode 100644 index 0000000..2416ad3 --- /dev/null +++ b/core/optional.go @@ -0,0 +1,49 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +type Match[T any] interface { + Values() T + Ok() bool +} + +type match[T any] struct { + value T + ok bool +} + +func NewMatch[T any](value T, ok bool) Match[T] { + return match[T]{value: value, ok: ok} +} + +func (m match[T]) Values() T { + return m.value +} + +func (m match[T]) Ok() bool { + return m.ok +} + +// Optional wraps a parser, returning a match struct with Ok set to true if the parser matches, otherwise Ok is set to false. +// The top level parser will always return true, unless an error occurs. +func Optional[T any](parser Parser[T]) Parser[Match[T]] { + return func(in Input) (Match[T], bool, error) { + m, ok, err := parser(in) + if err != nil { + return match[T]{}, false, err + } + return match[T]{value: m, ok: ok}, true, nil + } +} diff --git a/core/optional_test.go b/core/optional_test.go new file mode 100644 index 0000000..0f1bd7b --- /dev/null +++ b/core/optional_test.go @@ -0,0 +1,44 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestOptional(t *testing.T) { + tests := []ParserTest[core.Match[string]]{ + { + Name: "Optional: it's not there, but that's OK", + Input: "ABCDEF", + Parser: core.Optional(core.String("1")), + ExpectedMatch: core.NewMatch("", false), + ExpectedOK: true, + RemainingInput: "ABCDEF", + }, + { + Name: "Optional: it's there, so return the value", + Input: "ABCDEF", + Parser: core.Optional(core.String("A")), + ExpectedMatch: core.NewMatch("A", true), + ExpectedOK: true, + RemainingInput: "BCDEF", + }, + } + RunTests(t, tests) +} diff --git a/core/or.go b/core/or.go new file mode 100644 index 0000000..a060ae0 --- /dev/null +++ b/core/or.go @@ -0,0 +1,43 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +// Or returns successful if either of the parsers are successful. +// 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) { + var res tuple2[Match[A], Match[B]] + + matchA, okA, errA := a(in) + if errA != nil { + return res, false, errA + } + if okA { + res.A = NewMatch(matchA, true) + return res, true, nil + } + + matchB, okB, errB := b(in) + if errB != nil { + return res, false, errB + } + if okB { + res.B = NewMatch(matchB, true) + return res, true, nil + } + + return res, false, nil + } +} diff --git a/core/or_test.go b/core/or_test.go new file mode 100644 index 0000000..58ae732 --- /dev/null +++ b/core/or_test.go @@ -0,0 +1,50 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestOr(t *testing.T) { + tests := []ParserTest[core.Tuple2[core.Match[string], core.Match[string]]]{ + { + Name: "no match", + Input: "C", + Parser: core.Or(core.Rune('A'), core.Rune('B')), + ExpectedMatch: core.NewTuple2[core.Match[string], core.Match[string]](nil, nil), + ExpectedOK: false, + RemainingInput: "C", + }, + { + Name: "first match", + Input: "A", + Parser: core.Or(core.Rune('A'), core.Rune('B')), + ExpectedMatch: core.NewTuple2[core.Match[string], core.Match[string]](core.NewMatch("A", true), nil), + ExpectedOK: true, + }, + { + Name: "second match", + Input: "B", + Parser: core.Or(core.Rune('A'), core.Rune('B')), + ExpectedMatch: core.NewTuple2[core.Match[string]](nil, core.NewMatch("B", true)), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} diff --git a/core/parser.go b/core/parser.go new file mode 100644 index 0000000..ba61d74 --- /dev/null +++ b/core/parser.go @@ -0,0 +1,17 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +type Parser[T any] func(in Input) (T, bool, error) diff --git a/core/rune.go b/core/rune.go new file mode 100644 index 0000000..de8fe89 --- /dev/null +++ b/core/rune.go @@ -0,0 +1,70 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +import ( + "strings" + "unicode" +) + +// Rune matches a single rune. +func Rune(r rune) Parser[string] { + return RuneWhere(func(candidate rune) bool { + return candidate == r + }) +} + +// RuneWhere matches a single rune when the predicate is true. +func RuneWhere(predicate func(r rune) bool) Parser[string] { + return func(in Input) (string, bool, error) { + match, ok := in.Peek(1) + if !ok { + return "", false, nil + } + if !predicate(rune(match[0])) { + return "", false, nil + } + res, _ := in.Take(1) + return res, true, nil + } +} + +// RuneIn matches a single rune when the rune is in the given string. +func RuneIn(s string) Parser[string] { + return RuneWhere(func(r rune) bool { + return strings.Contains(s, string(r)) + }) +} + +// RuneNotIn matches a single rune when the rune is not in the given string. +func RuneNotIn(s string) Parser[string] { + return RuneWhere(func(r rune) bool { + return !strings.Contains(s, string(r)) + }) +} + +// RuneInRanges matches a single rune when the rune is in one of the given unicode ranges. +func RuneInRanges(ranges ...*unicode.RangeTable) Parser[string] { + return RuneWhere(func(r rune) bool { return unicode.IsOneOf(ranges, r) }) +} + +// AnyRune matches any single rune. +var AnyRune = RuneWhere(func(r rune) bool { return true }) + +// Letter matches any rune within the letter unicode range. +var Letter = RuneInRanges(unicode.Letter) + +// Digit matches any rune within the number unicode range. +var Digit = RuneInRanges(unicode.Number) diff --git a/core/rune_test.go b/core/rune_test.go new file mode 100644 index 0000000..90ec4bd --- /dev/null +++ b/core/rune_test.go @@ -0,0 +1,139 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + "unicode" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestRuneWhere(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "RuneWhere: no match", + Input: "ABCDEF", + Parser: core.RuneWhere(func(r rune) bool { return r == 'a' }), + ExpectedMatch: "", + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "RuneWhere: match", + Input: "ABCDEF", + Parser: core.RuneWhere(func(r rune) bool { + return unicode.IsUpper(r) + }), + ExpectedMatch: "A", + ExpectedOK: true, + RemainingInput: "BCDEF", + }, + { + Name: "AnyRune: match", + Input: "ABCDEF", + Parser: core.AnyRune, + ExpectedMatch: "A", + ExpectedOK: true, + RemainingInput: "BCDEF", + }, + { + Name: "RuneIn: no match", + Input: "ABCDEF", + Parser: core.RuneIn("123"), + ExpectedMatch: "", + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "RuneIn: match", + Input: "ABCDEF", + Parser: core.RuneIn("CBA"), + ExpectedMatch: "A", + ExpectedOK: true, + RemainingInput: "BCDEF", + }, + { + Name: "RuneNotIn: no match", + Input: "ABCDEF", + Parser: core.RuneNotIn("ABC"), + ExpectedMatch: "", + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "RuneNotIn: match", + Input: "ABCDEF", + Parser: core.RuneNotIn("123"), + ExpectedMatch: "A", + ExpectedOK: true, + RemainingInput: "BCDEF", + }, + { + Name: "RuneInRanges: match", + Input: " ", + Parser: core.RuneInRanges(unicode.White_Space), + ExpectedMatch: " ", + ExpectedOK: true, + RemainingInput: " ", + }, + { + Name: "RuneInRanges: no match", + Input: " ", + Parser: core.RuneInRanges(unicode.Han), + ExpectedOK: false, + RemainingInput: " ", + }, + { + Name: "Letter: match", + Input: "a", + Parser: core.Letter, + ExpectedMatch: "a", + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestDigit(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "Digit: no match", + Input: "ABCDEF", + Parser: core.Digit, + ExpectedMatch: "", + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "Digit: nine", + Input: "987", + Parser: core.Digit, + ExpectedMatch: "9", + ExpectedOK: true, + RemainingInput: "87", + }, + { + Name: "Digit: zero", + Input: "0123", + Parser: core.Digit, + ExpectedMatch: "0", + ExpectedOK: true, + RemainingInput: "123", + }, + } + RunTests(t, tests) +} diff --git a/core/sequences.go b/core/sequences.go new file mode 100644 index 0000000..f9f64e1 --- /dev/null +++ b/core/sequences.go @@ -0,0 +1,343 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +// SequenceOf2 parses two values in order or rolls back the input. +func SequenceOf2[A any, B any](a Parser[A], b Parser[B]) Parser[Tuple2[A, B]] { + return func(in Input) (Tuple2[A, B], bool, error) { + start := in.Checkpoint() + matchA, okA, errA := a(in) + if errA != nil || !okA { + in.Restore(start) + return tuple2[A, B]{}, false, errA + } + + matchB, okB, errB := b(in) + if errB != nil || !okB { + in.Restore(start) + return tuple2[A, B]{}, false, errB + } + + return tuple2[A, B]{A: matchA, B: matchB}, true, nil + } +} + +// SequenceOf3 parses three values in order or rolls back the input. +func SequenceOf3[A any, B any, C any](a Parser[A], b Parser[B], c Parser[C]) Parser[Tuple3[A, B, C]] { + return func(in Input) (Tuple3[A, B, C], bool, error) { + start := in.Checkpoint() + matchA, okA, errA := a(in) + if errA != nil || !okA { + in.Restore(start) + return tuple3[A, B, C]{}, false, errA + } + + matchB, okB, errB := b(in) + if errB != nil || !okB { + in.Restore(start) + return tuple3[A, B, C]{}, false, errB + } + + matchC, okC, errC := c(in) + if errC != nil || !okC { + in.Restore(start) + return tuple3[A, B, C]{}, false, errC + } + + return tuple3[A, B, C]{A: matchA, B: matchB, C: matchC}, true, nil + } +} + +// SequenceOf4 parses four values in order or rolls back the input. +func SequenceOf4[A any, B any, C any, D any](a Parser[A], b Parser[B], c Parser[C], d Parser[D]) Parser[Tuple4[A, B, C, D]] { + return func(in Input) (Tuple4[A, B, C, D], bool, error) { + start := in.Checkpoint() + matchA, okA, errA := a(in) + if errA != nil || !okA { + in.Restore(start) + return tuple4[A, B, C, D]{}, false, errA + } + + matchB, okB, errB := b(in) + if errB != nil || !okB { + in.Restore(start) + return tuple4[A, B, C, D]{}, false, errB + } + + matchC, okC, errC := c(in) + if errC != nil || !okC { + in.Restore(start) + return tuple4[A, B, C, D]{}, false, errC + } + + matchD, okD, errD := d(in) + if errD != nil || !okD { + in.Restore(start) + return tuple4[A, B, C, D]{}, false, errD + } + + return tuple4[A, B, C, D]{A: matchA, B: matchB, C: matchC, D: matchD}, true, nil + } +} + +// SequenceOf5 parses five values in order or rolls back the input. +func SequenceOf5[A any, B any, C any, D any, E any](a Parser[A], b Parser[B], c Parser[C], d Parser[D], e Parser[E]) Parser[Tuple5[A, B, C, D, E]] { + return func(in Input) (Tuple5[A, B, C, D, E], bool, error) { + start := in.Checkpoint() + matchA, okA, errA := a(in) + if errA != nil || !okA { + in.Restore(start) + return tuple5[A, B, C, D, E]{}, false, errA + } + + matchB, okB, errB := b(in) + if errB != nil || !okB { + in.Restore(start) + return tuple5[A, B, C, D, E]{}, false, errB + } + + matchC, okC, errC := c(in) + if errC != nil || !okC { + in.Restore(start) + return tuple5[A, B, C, D, E]{}, false, errC + } + + matchD, okD, errD := d(in) + if errD != nil || !okD { + in.Restore(start) + return tuple5[A, B, C, D, E]{}, false, errD + } + + matchE, okE, errE := e(in) + if errE != nil || !okE { + in.Restore(start) + return tuple5[A, B, C, D, E]{}, false, errE + } + + return tuple5[A, B, C, D, E]{A: matchA, B: matchB, C: matchC, D: matchD, E: matchE}, true, nil + } +} + +// SequenceOf6 parses six values in order or rolls back the input. +func SequenceOf6[A any, B any, C any, D any, E any, F any](a Parser[A], b Parser[B], c Parser[C], d Parser[D], e Parser[E], f Parser[F]) Parser[Tuple6[A, B, C, D, E, F]] { + return func(in Input) (Tuple6[A, B, C, D, E, F], bool, error) { + start := in.Checkpoint() + matchA, okA, errA := a(in) + if errA != nil || !okA { + in.Restore(start) + return tuple6[A, B, C, D, E, F]{}, false, errA + } + + matchB, okB, errB := b(in) + if errB != nil || !okB { + in.Restore(start) + return tuple6[A, B, C, D, E, F]{}, false, errB + } + + matchC, okC, errC := c(in) + if errC != nil || !okC { + in.Restore(start) + return tuple6[A, B, C, D, E, F]{}, false, errC + } + + matchD, okD, errD := d(in) + if errD != nil || !okD { + in.Restore(start) + return tuple6[A, B, C, D, E, F]{}, false, errD + } + + matchE, okE, errE := e(in) + if errE != nil || !okE { + in.Restore(start) + return tuple6[A, B, C, D, E, F]{}, false, errE + } + + matchF, okF, errF := f(in) + if errF != nil || !okF { + in.Restore(start) + return tuple6[A, B, C, D, E, F]{}, false, errF + } + + return tuple6[A, B, C, D, E, F]{A: matchA, B: matchB, C: matchC, D: matchD, E: matchE, F: matchF}, true, nil + } +} + +// SequenceOf7 parses seven values in order or rolls back the input. +func SequenceOf7[A any, B any, C any, D any, E any, F any, G any](a Parser[A], b Parser[B], c Parser[C], d Parser[D], e Parser[E], f Parser[F], g Parser[G]) Parser[Tuple7[A, B, C, D, E, F, G]] { + return func(in Input) (Tuple7[A, B, C, D, E, F, G], bool, error) { + start := in.Checkpoint() + matchA, okA, errA := a(in) + if errA != nil || !okA { + in.Restore(start) + return tuple7[A, B, C, D, E, F, G]{}, false, errA + } + + matchB, okB, errB := b(in) + if errB != nil || !okB { + in.Restore(start) + return tuple7[A, B, C, D, E, F, G]{}, false, errB + } + + matchC, okC, errC := c(in) + if errC != nil || !okC { + in.Restore(start) + return tuple7[A, B, C, D, E, F, G]{}, false, errC + } + + matchD, okD, errD := d(in) + if errD != nil || !okD { + in.Restore(start) + return tuple7[A, B, C, D, E, F, G]{}, false, errD + } + + matchE, okE, errE := e(in) + if errE != nil || !okE { + in.Restore(start) + return tuple7[A, B, C, D, E, F, G]{}, false, errE + } + + matchF, okF, errF := f(in) + if errF != nil || !okF { + in.Restore(start) + return tuple7[A, B, C, D, E, F, G]{}, false, errF + } + + matchG, okG, errG := g(in) + if errG != nil || !okG { + in.Restore(start) + return tuple7[A, B, C, D, E, F, G]{}, false, errG + } + + return tuple7[A, B, C, D, E, F, G]{A: matchA, B: matchB, C: matchC, D: matchD, E: matchE, F: matchF, G: matchG}, true, nil + } +} + +// SequenceOf8 parses eight values in order or rolls back the input. +func SequenceOf8[A any, B any, C any, D any, E any, F any, G any, H any](a Parser[A], b Parser[B], c Parser[C], d Parser[D], e Parser[E], f Parser[F], g Parser[G], h Parser[H]) Parser[Tuple8[A, B, C, D, E, F, G, H]] { + return func(in Input) (Tuple8[A, B, C, D, E, F, G, H], bool, error) { + start := in.Checkpoint() + matchA, okA, errA := a(in) + if errA != nil || !okA { + in.Restore(start) + return tuple8[A, B, C, D, E, F, G, H]{}, false, errA + } + + matchB, okB, errB := b(in) + if errB != nil || !okB { + in.Restore(start) + return tuple8[A, B, C, D, E, F, G, H]{}, false, errB + } + + matchC, okC, errC := c(in) + if errC != nil || !okC { + in.Restore(start) + return tuple8[A, B, C, D, E, F, G, H]{}, false, errC + } + + matchD, okD, errD := d(in) + if errD != nil || !okD { + in.Restore(start) + return tuple8[A, B, C, D, E, F, G, H]{}, false, errD + } + + matchE, okE, errE := e(in) + if errE != nil || !okE { + in.Restore(start) + return tuple8[A, B, C, D, E, F, G, H]{}, false, errE + } + + matchF, okF, errF := f(in) + if errF != nil || !okF { + in.Restore(start) + return tuple8[A, B, C, D, E, F, G, H]{}, false, errF + } + + matchG, okG, errG := g(in) + if errG != nil || !okG { + in.Restore(start) + return tuple8[A, B, C, D, E, F, G, H]{}, false, errG + } + + matchH, okH, errH := h(in) + if errH != nil || !okH { + in.Restore(start) + return tuple8[A, B, C, D, E, F, G, H]{}, false, errH + } + + return tuple8[A, B, C, D, E, F, G, H]{A: matchA, B: matchB, C: matchC, D: matchD, E: matchE, F: matchF, G: matchG, H: matchH}, true, nil + } +} + +// SequenceOf9 parses nine values in order or rolls back the input. +func SequenceOf9[A any, B any, C any, D any, E any, F any, G any, H any, I any](a Parser[A], b Parser[B], c Parser[C], d Parser[D], e Parser[E], f Parser[F], g Parser[G], h Parser[H], i Parser[I]) Parser[Tuple9[A, B, C, D, E, F, G, H, I]] { + return func(in Input) (Tuple9[A, B, C, D, E, F, G, H, I], bool, error) { + start := in.Checkpoint() + matchA, okA, errA := a(in) + if errA != nil || !okA { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errA + } + + matchB, okB, errB := b(in) + if errB != nil || !okB { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errB + } + + matchC, okC, errC := c(in) + if errC != nil || !okC { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errC + } + + matchD, okD, errD := d(in) + if errD != nil || !okD { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errD + } + + matchE, okE, errE := e(in) + if errE != nil || !okE { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errE + } + + matchF, okF, errF := f(in) + if errF != nil || !okF { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errF + } + + matchG, okG, errG := g(in) + if errG != nil || !okG { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errG + } + + matchH, okH, errH := h(in) + if errH != nil || !okH { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errH + } + + matchI, okI, errI := i(in) + if errI != nil || !okI { + in.Restore(start) + return tuple9[A, B, C, D, E, F, G, H, I]{}, false, errI + } + + return tuple9[A, B, C, D, E, F, G, H, I]{A: matchA, B: matchB, C: matchC, D: matchD, E: matchE, F: matchF, G: matchG, H: matchH, I: matchI}, true, nil + } +} diff --git a/core/sequences_test.go b/core/sequences_test.go new file mode 100644 index 0000000..c408712 --- /dev/null +++ b/core/sequences_test.go @@ -0,0 +1,254 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestSequence2(t *testing.T) { + tests := []ParserTest[core.Tuple2[string, string]]{ + { + Name: "no match", + Input: "AB", + Parser: core.SequenceOf2(core.String("1"), core.String("B")), + ExpectedMatch: core.NewTuple2("", ""), + ExpectedOK: false, + RemainingInput: "AB", + }, + { + Name: "partial match", + Input: "AB", + Parser: core.SequenceOf2(core.String("A"), core.String("2")), + ExpectedMatch: core.NewTuple2("", ""), + ExpectedOK: false, + RemainingInput: "AB", + }, + { + Name: "match", + Input: "AB", + Parser: core.SequenceOf2(core.String("A"), core.String("B")), + ExpectedMatch: core.NewTuple2("A", "B"), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestSequence3(t *testing.T) { + tests := []ParserTest[core.Tuple3[string, string, string]]{ + { + Name: "no match", + Input: "ABC", + Parser: core.SequenceOf3(core.String("1"), core.String("B"), core.String("3")), + ExpectedMatch: core.NewTuple3("", "", ""), + ExpectedOK: false, + RemainingInput: "ABC", + }, + { + Name: "partial match", + Input: "ABC", + Parser: core.SequenceOf3(core.String("A"), core.String("2"), core.String("C")), + ExpectedMatch: core.NewTuple3("", "", ""), + ExpectedOK: false, + RemainingInput: "ABC", + }, + { + Name: "match", + Input: "ABC", + Parser: core.SequenceOf3(core.String("A"), core.String("B"), core.String("C")), + ExpectedMatch: core.NewTuple3("A", "B", "C"), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestSequence4(t *testing.T) { + tests := []ParserTest[core.Tuple4[string, string, string, string]]{ + { + Name: "no match", + Input: "ABCD", + Parser: core.SequenceOf4(core.String("1"), core.String("B"), core.String("3"), core.String("D")), + ExpectedMatch: core.NewTuple4("", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCD", + }, + { + Name: "partial match", + Input: "ABCD", + Parser: core.SequenceOf4(core.String("A"), core.String("2"), core.String("C"), core.String("D")), + ExpectedMatch: core.NewTuple4("", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCD", + }, + { + Name: "match", + Input: "ABCD", + Parser: core.SequenceOf4(core.String("A"), core.String("B"), core.String("C"), core.String("D")), + ExpectedMatch: core.NewTuple4("A", "B", "C", "D"), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestSequence5(t *testing.T) { + tests := []ParserTest[core.Tuple5[string, string, string, string, string]]{ + { + Name: "no match", + Input: "ABCDE", + Parser: core.SequenceOf5(core.String("1"), core.String("B"), core.String("3"), core.String("D"), core.String("E")), + ExpectedMatch: core.NewTuple5("", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDE", + }, + { + Name: "partial match", + Input: "ABCDE", + Parser: core.SequenceOf5(core.String("A"), core.String("2"), core.String("C"), core.String("D"), core.String("E")), + ExpectedMatch: core.NewTuple5("", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDE", + }, + { + Name: "match", + Input: "ABCDE", + Parser: core.SequenceOf5(core.String("A"), core.String("B"), core.String("C"), core.String("D"), core.String("E")), + ExpectedMatch: core.NewTuple5("A", "B", "C", "D", "E"), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestSequence6(t *testing.T) { + tests := []ParserTest[core.Tuple6[string, string, string, string, string, string]]{ + { + Name: "no match", + Input: "ABCDEF", + Parser: core.SequenceOf6(core.String("1"), core.String("B"), core.String("3"), core.String("D"), core.String("E"), core.String("F")), + ExpectedMatch: core.NewTuple6("", "", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "partial match", + Input: "ABCDEF", + Parser: core.SequenceOf6(core.String("A"), core.String("2"), core.String("C"), core.String("D"), core.String("E"), core.String("F")), + ExpectedMatch: core.NewTuple6("", "", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "match", + Input: "ABCDEF", + Parser: core.SequenceOf6(core.String("A"), core.String("B"), core.String("C"), core.String("D"), core.String("E"), core.String("F")), + ExpectedMatch: core.NewTuple6("A", "B", "C", "D", "E", "F"), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestSequence7(t *testing.T) { + tests := []ParserTest[core.Tuple7[string, string, string, string, string, string, string]]{ + { + Name: "no match", + Input: "ABCDEFG", + Parser: core.SequenceOf7(core.String("1"), core.String("B"), core.String("3"), core.String("D"), core.String("E"), core.String("F"), core.String("G")), + ExpectedMatch: core.NewTuple7("", "", "", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDEFG", + }, + { + Name: "partial match", + Input: "ABCDEFG", + Parser: core.SequenceOf7(core.String("A"), core.String("2"), core.String("C"), core.String("D"), core.String("E"), core.String("F"), core.String("G")), + ExpectedMatch: core.NewTuple7("", "", "", "", "", "", ""), + ExpectedOK: false, + RemainingInput: "ABCDEFG", + }, + { + Name: "match", + Input: "ABCDEFG", + Parser: core.SequenceOf7(core.String("A"), core.String("B"), core.String("C"), core.String("D"), core.String("E"), core.String("F"), core.String("G")), + ExpectedMatch: core.NewTuple7("A", "B", "C", "D", "E", "F", "G"), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestSequence8(t *testing.T) { + tests := []ParserTest[core.Tuple8[string, string, string, string, string, string, string, string]]{ + { + Name: "no match", + Input: "ABCDEFGH", + Parser: core.SequenceOf8(core.String("1"), core.String("B"), core.String("3"), 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", + Parser: core.SequenceOf8(core.String("A"), core.String("2"), 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: "match", + Input: "ABCDEFGH", + Parser: core.SequenceOf8(core.String("A"), core.String("B"), core.String("C"), core.String("D"), core.String("E"), core.String("F"), core.String("G"), core.String("H")), + ExpectedMatch: core.NewTuple8("A", "B", "C", "D", "E", "F", "G", "H"), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestSequence9(t *testing.T) { + tests := []ParserTest[core.Tuple9[string, string, string, string, string, string, string, string, string]]{ + { + Name: "no match", + Input: "ABCDEFGHI", + Parser: core.SequenceOf9(core.String("1"), core.String("B"), core.String("3"), 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", + Parser: core.SequenceOf9(core.String("A"), core.String("2"), 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: "match", + Input: "ABCDEFGHI", + Parser: core.SequenceOf9(core.String("A"), 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("A", "B", "C", "D", "E", "F", "G", "H", "I"), + ExpectedOK: true, + }, + } + RunTests(t, tests) +} diff --git a/core/string.go b/core/string.go new file mode 100644 index 0000000..49c8279 --- /dev/null +++ b/core/string.go @@ -0,0 +1,144 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +import "strings" + +// String matches the given string (case sensitive). +func String(s string) Parser[string] { + return stringWhere(s, func(candidate string) bool { + return s == candidate + }) +} + +// StringInsensitive matches the given string (case insensitive). +func StringInsensitive(s string) Parser[string] { + return stringWhere(s, func(candidate string) bool { + return strings.EqualFold(s, candidate) + }) +} + +func stringWhere(s string, predicate func(candidate string) bool) Parser[string] { + return func(in Input) (string, bool, error) { + match, ok := in.Peek(len(s)) + if !ok { + return "", false, nil + } + if !predicate(match) { + return "", false, nil + } + res, _ := in.Take(len(s)) + return res, true, nil + } +} + +// StringFrom returns the string range match by the given parsers. +func StringFrom[T any](parsers ...Parser[T]) Parser[string] { + return func(in Input) (string, bool, error) { + start := in.Checkpoint() + for _, parser := range parsers { + _, ok, err := parser(in) + if err != nil { + return "", false, err + } + if !ok { + in.Restore(start) + return "", false, nil + } + } + + end := in.Checkpoint() + in.Restore(start) + res, ok := in.Take(int(end - start)) + return res, ok, nil + } +} + +// StringUntil matches until the delimiter is found. +// This differs from while as the delimiter is searched for AFTER the first match. +func StringUntil[T any](delimiter Parser[T]) Parser[string] { + return func(in Input) (string, bool, error) { + start := in.Checkpoint() + for { + _, chompOk := in.Take(1) + if !chompOk { + in.Restore(start) + return "", false, nil + } + + beforeDelimiter := in.Checkpoint() + _, ok, err := delimiter(in) + if err != nil { + in.Restore(start) + return "", false, err + } + if ok { + in.Restore(beforeDelimiter) + break + } + } + end := in.Checkpoint() + in.Restore(start) + res, ok := in.Take(int(end - start)) + return res, ok, nil + } +} + +// StringUntilEOF matches until the delimiter is found or the end of the input. +// This requires at least one character to be matched before the end of the input. +// See the tests for examples. +func StringUntilEOF[T any](delimiter Parser[T]) Parser[string] { + return func(in Input) (string, bool, error) { + return StringUntil(Or(delimiter, EOF[string]()))(in) + } +} + +// StringWhileNot matches while the delimiter does not match. +// This differs from until as the delimiter is searched for BEFORE the first match. +func StringWhileNot[T any](delimiter Parser[T]) Parser[string] { + return func(in Input) (string, bool, error) { + start := in.Checkpoint() + for { + beforeDelimiter := in.Checkpoint() + _, ok, err := delimiter(in) + if err != nil { + in.Restore(start) + return "", false, err + } + if ok { + in.Restore(beforeDelimiter) + break + } + + _, chompOk := in.Take(1) + if !chompOk { + in.Restore(start) + return "", false, nil + } + } + end := in.Checkpoint() + in.Restore(start) + res, ok := in.Take(int(end - start)) + return res, ok, nil + } +} + +// StringWhileNotOrEOF matches while the delimiter does not match or the end of the input. +// This does not require any characters to be matched before the end of the input. +func StringWhileNotEOFOr[T any](delimiter Parser[T]) Parser[string] { + return func(in Input) (string, bool, error) { + return StringWhileNot(Or(delimiter, EOF[string]()))(in) + } +} diff --git a/core/string_test.go b/core/string_test.go new file mode 100644 index 0000000..c6dd06b --- /dev/null +++ b/core/string_test.go @@ -0,0 +1,185 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestString(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "no match", + Input: "ABCDEF", + Parser: core.String("123"), + ExpectedMatch: "", + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "matches", + Input: "ABCDEF", + Parser: core.String("ABC"), + ExpectedMatch: "ABC", + ExpectedOK: true, + RemainingInput: "DEF", + }, + { + Name: "matches insensitive", + Input: "ABCDEF", + Parser: core.StringInsensitive("abc"), + ExpectedMatch: "ABC", + ExpectedOK: true, + RemainingInput: "DEF", + }, + { + Name: "matches insensitive (inverse)", + Input: "abCDEF", + Parser: core.StringInsensitive("ABC"), + ExpectedMatch: "abC", + ExpectedOK: true, + RemainingInput: "DEF", + }, + } + RunTests(t, tests) +} + +func TestStringFrom(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "no match", + Input: "ABCDEF", + Parser: core.StringFrom(core.String("ABC"), core.String("123")), + ExpectedMatch: "", + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "matches", + Input: "ABCDEF", + Parser: core.StringFrom(core.String("A"), core.String("BC"), core.String("DEF")), + ExpectedMatch: "ABCDEF", + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestStringUntil(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "StringUntil: success", + Input: "ABCDEF", + Parser: core.StringUntil(core.String("D")), + ExpectedMatch: "ABC", + ExpectedOK: true, + RemainingInput: "DEF", + }, + { + Name: "StringUntil: fail, reached EOF before delimiter was found", + Input: "ABCDEF", + Parser: core.StringUntil(core.String("G")), + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "StringUntil: success, delimiter matches first character", + Input: "ABCDEFA", + Parser: core.StringUntil(core.String("A")), + ExpectedMatch: "ABCDEF", + ExpectedOK: true, + RemainingInput: "A", + }, + { + Name: "StringUntilEOF: stop at the delimiter if it's there", + Input: "ABCDEF", + Parser: core.StringUntilEOF(core.String("F")), + ExpectedMatch: "ABCDE", + ExpectedOK: true, + RemainingInput: "F", + }, + { + Name: "StringUntilEOF: allow EOF", + Input: "ABCDEF", + Parser: core.StringUntilEOF(core.String("G")), + ExpectedMatch: "ABCDEF", + ExpectedOK: true, + }, + { + Name: "StringUntilEOF: allow EOF, empty input", + Input: "", + Parser: core.StringUntilEOF(core.String("G")), + ExpectedMatch: "", // expects a character to be matched before EOF + ExpectedOK: false, + }, + } + RunTests(t, tests) +} + +func TestStringWhileNot(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "StringWhileNot: success", + Input: "ABCDEF1", + Parser: core.StringWhileNot(core.Digit), + ExpectedMatch: "ABCDEF", + ExpectedOK: true, + RemainingInput: "1", + }, + { + Name: "StringWhileNot: fail, reached EOF before delimiter was found", + Input: "ADBCDEF", + Parser: core.StringWhileNot(core.Digit), + ExpectedMatch: "", + ExpectedOK: false, + RemainingInput: "ADBCDEF", + }, + { + Name: "StringWhileNot: success, delimiter matches first character", + Input: "1ABCDEF", + Parser: core.StringWhileNot(core.Digit), + ExpectedMatch: "", + ExpectedOK: true, + RemainingInput: "1ABCDEF", + }, + { + Name: "StringWhileNotEOF: stop at the delimiter if it's there", + Input: "ABCDEF1", + Parser: core.StringWhileNotEOFOr(core.Digit), + ExpectedMatch: "ABCDEF", + ExpectedOK: true, + RemainingInput: "1", + }, + { + Name: "StringWhileNotEOF: allow EOF", + Input: "ABCDEF", + Parser: core.StringWhileNotEOFOr(core.Digit), + ExpectedMatch: "ABCDEF", + ExpectedOK: true, + RemainingInput: "", + }, + { + Name: "StringWhileNotEOF: allow EOF, empty input", + Input: "", + Parser: core.StringWhileNotEOFOr(core.Digit), + ExpectedMatch: "", + ExpectedOK: true, + }, + } + RunTests(t, tests) +} diff --git a/core/times.go b/core/times.go new file mode 100644 index 0000000..99e8345 --- /dev/null +++ b/core/times.go @@ -0,0 +1,69 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +func times[T any](min int, max func(i int) bool, p Parser[T]) Parser[[]T] { + return func(in Input) ([]T, bool, error) { + start := in.Checkpoint() + match := make([]T, 0) + for i := 0; max(i); i++ { + m, ok, err := p(in) + if err != nil { + in.Restore(start) + return match, false, err + } + if !ok { + break + } + match = append(match, m) + } + ok := len(match) >= min && max(len(match)-1) + if !ok { + in.Restore(start) + return nil, false, nil + } + return match, true, nil + } +} + +// Matches the given parser exactly n times. +func Times[T any](n int, p Parser[T]) Parser[[]T] { + return times(n, func(i int) bool { return i < n }, p) +} + +// Matches the given parser between min and max times . +func Between[T any](min, max int, p Parser[T]) Parser[[]T] { + return times(min, func(i int) bool { return i < max }, p) +} + +// Matches the given parser at least n times. +func AtLeast[T any](n int, p Parser[T]) Parser[[]T] { + return times(n, func(i int) bool { return true }, p) +} + +// Matches the given parser at most n times. +func AtMost[T any](n int, p Parser[T]) Parser[[]T] { + return times(0, func(i int) bool { return i < n }, p) +} + +// Matches the given parser zero or more times. +func ZeroOrMore[T any](p Parser[T]) Parser[[]T] { + return times(0, func(i int) bool { return true }, p) +} + +// Matches the given parser one or more times. +func OneOrMore[T any](p Parser[T]) Parser[[]T] { + return times(1, func(i int) bool { return true }, p) +} diff --git a/core/times_test.go b/core/times_test.go new file mode 100644 index 0000000..183b6e3 --- /dev/null +++ b/core/times_test.go @@ -0,0 +1,155 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestTime(t *testing.T) { + tests := []ParserTest[[]string]{ + { + Name: "Times: no match", + Input: "ABCDEF", + Parser: core.Times(2, core.Rune('A')), + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "Times: match", + Input: "AA", + Parser: core.Times(2, core.Rune('A')), + ExpectedMatch: []string{"A", "A"}, + ExpectedOK: true, + }, + { + Name: "Between: at least 1 up to 5 with 4", + Input: "AAAA", + Parser: core.Between(1, 5, core.Rune('A')), + ExpectedMatch: []string{"A", "A", "A", "A"}, + ExpectedOK: true, + }, + { + Name: "Between: at least 1 up to 5 with 5", + Input: "AAAAA", + Parser: core.Between(1, 5, core.Rune('A')), + ExpectedMatch: []string{"A", "A", "A", "A", "A"}, + ExpectedOK: true, + }, + { + Name: "Between: at least 1 up to 5 with 6", + Input: "AAAAAA", + Parser: core.Between(1, 5, core.Rune('A')), + ExpectedMatch: []string{"A", "A", "A", "A", "A"}, + ExpectedOK: true, + RemainingInput: "A", + }, + { + Name: "Between: at least 1 up to 5 with 0", + Input: "", + Parser: core.Between(1, 5, core.Rune('A')), + ExpectedOK: false, + }, + { + Name: "Between: at least 1 up to 5 with 1", + Input: "A", + Parser: core.Between(1, 5, core.Rune('A')), + ExpectedMatch: []string{"A"}, + ExpectedOK: true, + }, + { + Name: "AtLeast: at least 1 with 0", + Input: "", + Parser: core.AtLeast(1, core.Rune('A')), + ExpectedOK: false, + }, + { + Name: "AtLeast: at least 1 with 1", + Input: "A", + Parser: core.AtLeast(1, core.Rune('A')), + ExpectedMatch: []string{"A"}, + ExpectedOK: true, + }, + { + Name: "AtLeast: at least 1 with 2", + Input: "AA", + Parser: core.AtLeast(1, core.Rune('A')), + ExpectedMatch: []string{"A", "A"}, + ExpectedOK: true, + }, + { + Name: "AtMost: at most 3 with 0", + Input: "", + Parser: core.AtMost(3, core.Rune('A')), + ExpectedMatch: []string{}, + ExpectedOK: true, + }, + { + Name: "AtMost: at most 3 with 1", + Input: "A", + Parser: core.AtMost(3, core.Rune('A')), + ExpectedMatch: []string{"A"}, + ExpectedOK: true, + }, + { + Name: "AtMost: at most 3 with 3", + Input: "AAA", + Parser: core.AtMost(3, core.Rune('A')), + ExpectedMatch: []string{"A", "A", "A"}, + ExpectedOK: true, + }, + { + Name: "AtMost: at most 3 with 4", + Input: "AAAA", + Parser: core.AtMost(3, core.Rune('A')), + ExpectedMatch: []string{"A", "A", "A"}, + ExpectedOK: true, + RemainingInput: "A", + }, + { + Name: "ZeroOrMore: no match", + Input: "BCDEF", + Parser: core.ZeroOrMore(core.Rune('A')), + ExpectedOK: true, + ExpectedMatch: []string{}, + RemainingInput: "BCDEF", + }, + { + Name: "ZeroOrMore: match", + Input: "AA", + Parser: core.ZeroOrMore(core.Rune('A')), + ExpectedMatch: []string{"A", "A"}, + ExpectedOK: true, + }, + { + Name: "OneOrMore: no match", + Input: "BCDEF", + Parser: core.OneOrMore(core.Rune('A')), + ExpectedOK: false, + RemainingInput: "BCDEF", + }, + { + Name: "OneOrMore: match", + Input: "AA", + Parser: core.OneOrMore(core.Rune('A')), + ExpectedMatch: []string{"A", "A"}, + ExpectedOK: true, + }, + } + RunTests(t, tests) +} diff --git a/core/tuples.go b/core/tuples.go new file mode 100644 index 0000000..9f24d88 --- /dev/null +++ b/core/tuples.go @@ -0,0 +1,179 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +type tuple2[A, B any] struct { + A A + B B +} + +func NewTuple2[A, B any](a A, b B) tuple2[A, B] { + return tuple2[A, B]{A: a, B: b} +} + +type Tuple2[A, B any] interface { + Values() (A, B) +} + +func (t tuple2[A, B]) Values() (A, B) { + return t.A, t.B +} + +type tuple3[A, B, C any] struct { + A A + B B + C C +} + +func (t tuple3[A, B, C]) Values() (A, B, C) { + return t.A, t.B, t.C +} + +func NewTuple3[A, B, C any](a A, b B, c C) tuple3[A, B, C] { + return tuple3[A, B, C]{A: a, B: b, C: c} +} + +type Tuple3[A, B, C any] interface { + Values() (A, B, C) +} + +type tuple4[A, B, C, D any] struct { + A A + B B + C C + D D +} + +func NewTuple4[A, B, C, D any](a A, b B, c C, d D) tuple4[A, B, C, D] { + return tuple4[A, B, C, D]{A: a, B: b, C: c, D: d} +} + +func (t tuple4[A, B, C, D]) Values() (A, B, C, D) { + return t.A, t.B, t.C, t.D +} + +type Tuple4[A, B, C, D any] interface { + Values() (A, B, C, D) +} + +type tuple5[A, B, C, D, E any] struct { + A A + B B + C C + D D + E E +} + +func NewTuple5[A, B, C, D, E any](a A, b B, c C, d D, e E) tuple5[A, B, C, D, E] { + return tuple5[A, B, C, D, E]{A: a, B: b, C: c, D: d, E: e} +} + +func (t tuple5[A, B, C, D, E]) Values() (A, B, C, D, E) { + return t.A, t.B, t.C, t.D, t.E +} + +type Tuple5[A, B, C, D, E any] interface { + Values() (A, B, C, D, E) +} + +type tuple6[A, B, C, D, E, F any] struct { + A A + B B + C C + D D + E E + F F +} + +func NewTuple6[A, B, C, D, E, F any](a A, b B, c C, d D, e E, f F) tuple6[A, B, C, D, E, F] { + return tuple6[A, B, C, D, E, F]{A: a, B: b, C: c, D: d, E: e, F: f} +} + +func (t tuple6[A, B, C, D, E, F]) Values() (A, B, C, D, E, F) { + return t.A, t.B, t.C, t.D, t.E, t.F +} + +type Tuple6[A, B, C, D, E, F any] interface { + Values() (A, B, C, D, E, F) +} + +type tuple7[A, B, C, D, E, F, G any] struct { + A A + B B + C C + D D + E E + F F + G G +} + +func NewTuple7[A, B, C, D, E, F, G any](a A, b B, c C, d D, e E, f F, g G) tuple7[A, B, C, D, E, F, G] { + return tuple7[A, B, C, D, E, F, G]{A: a, B: b, C: c, D: d, E: e, F: f, G: g} +} + +func (t tuple7[A, B, C, D, E, F, G]) Values() (A, B, C, D, E, F, G) { + return t.A, t.B, t.C, t.D, t.E, t.F, t.G +} + +type Tuple7[A, B, C, D, E, F, G any] interface { + Values() (A, B, C, D, E, F, G) +} + +type tuple8[A, B, C, D, E, F, G, H any] struct { + A A + B B + C C + D D + E E + F F + G G + H H +} + +func NewTuple8[A, B, C, D, E, F, G, H any](a A, b B, c C, d D, e E, f F, g G, h H) tuple8[A, B, C, D, E, F, G, H] { + return tuple8[A, B, C, D, E, F, G, H]{A: a, B: b, C: c, D: d, E: e, F: f, G: g, H: h} +} + +func (t tuple8[A, B, C, D, E, F, G, H]) Values() (A, B, C, D, E, F, G, H) { + return t.A, t.B, t.C, t.D, t.E, t.F, t.G, t.H +} + +type Tuple8[A, B, C, D, E, F, G, H any] interface { + Values() (A, B, C, D, E, F, G, H) +} + +type tuple9[A, B, C, D, E, F, G, H, I any] struct { + A A + B B + C C + D D + E E + F F + G G + H H + I I +} + +func NewTuple9[A, B, C, D, E, F, G, H, I any](a A, b B, c C, d D, e E, f F, g G, h H, i I) tuple9[A, B, C, D, E, F, G, H, I] { + return tuple9[A, B, C, D, E, F, G, H, I]{A: a, B: b, C: c, D: d, E: e, F: f, G: g, H: h, I: i} +} + +func (t tuple9[A, B, C, D, E, F, G, H, I]) Values() (A, B, C, D, E, F, G, H, I) { + return t.A, t.B, t.C, t.D, t.E, t.F, t.G, t.H, t.I +} + +type Tuple9[A, B, C, D, E, F, G, H, I any] interface { + Values() (A, B, C, D, E, F, G, H, I) +} diff --git a/core/until.go b/core/until.go new file mode 100644 index 0000000..4b933e1 --- /dev/null +++ b/core/until.go @@ -0,0 +1,54 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +// Until matches until the delmiter is found. +// This differs from while as the delimiter is searched for AFTER the first match. +func Until[T, D any](parser Parser[T], delimiter Parser[D]) Parser[[]T] { + return func(in Input) ([]T, bool, error) { + start := in.Checkpoint() + match := make([]T, 0) + for { + m, ok, err := parser(in) + if err != nil { + in.Restore(start) + return nil, false, err + } + if !ok { + in.Restore(start) + return nil, false, nil + } + match = append(match, m) + + beforeDelimiter := in.Checkpoint() + _, ok, err = delimiter(in) + if err != nil { + in.Restore(start) + return nil, false, err + } + if ok { + in.Restore(beforeDelimiter) + return match, true, nil + } + } + } +} + +// UntilEOF matched until the delimiter is found or the end of the input is reached. +// This requires at least once parse to be successful before the end of the input. +// See tests for examples. +func UntilEOF[T, D any](parser Parser[T], delimiter Parser[D]) Parser[[]T] { + return Until(parser, Or(delimiter, EOF[T]())) +} diff --git a/core/until_test.go b/core/until_test.go new file mode 100644 index 0000000..05e0757 --- /dev/null +++ b/core/until_test.go @@ -0,0 +1,73 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestUntil(t *testing.T) { + tests := []ParserTest[[]string]{ + { + Name: "Until: success", + Input: "ABCDEF", + Parser: core.Until(core.AnyRune, core.Rune('D')), + ExpectedMatch: []string{"A", "B", "C"}, + ExpectedOK: true, + RemainingInput: "DEF", + }, + { + Name: "Until: reach end before delimiter", + Input: "ABC", + Parser: core.Until(core.AnyRune, core.Rune('D')), + ExpectedOK: false, + RemainingInput: "ABC", + }, + { + Name: "Until: delimiter matches on first character", + Input: "ABCA", + Parser: core.Until(core.AnyRune, core.Rune('A')), + ExpectedMatch: []string{"A", "B", "C"}, + ExpectedOK: true, + RemainingInput: "A", + }, + { + Name: "UntilEOF: allow EOF", + Input: "ABC", + Parser: core.UntilEOF(core.AnyRune, core.Digit), + ExpectedMatch: []string{"A", "B", "C"}, + ExpectedOK: true, + }, + { + Name: "UntilEOF: delimiter reached", + Input: "ABC1", + Parser: core.UntilEOF(core.AnyRune, core.Digit), + ExpectedOK: true, + ExpectedMatch: []string{"A", "B", "C"}, + RemainingInput: "1", + }, + { + Name: "UntilEOF: empty input", + Input: "", + Parser: core.UntilEOF(core.AnyRune, core.Digit), + ExpectedOK: false, // needs at least one match before EOF + }, + } + RunTests(t, tests) + +} diff --git a/core/while.go b/core/while.go new file mode 100644 index 0000000..4773fdc --- /dev/null +++ b/core/while.go @@ -0,0 +1,54 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +// WhileNot matches while the given delimiter is not found. +// This differs from until as the delimiter is searched for BEFORE the first match. +func WhileNot[T, D any](parser Parser[T], delimiter Parser[D]) Parser[[]T] { + return func(in Input) ([]T, bool, error) { + start := in.Checkpoint() + match := make([]T, 0) + for { + beforeDelimiter := in.Checkpoint() + _, ok, err := delimiter(in) + if err != nil { + in.Restore(start) + return nil, false, err + } + if ok { + in.Restore(beforeDelimiter) + return match, true, nil + } + + m, ok, err := parser(in) + if err != nil { + in.Restore(start) + return nil, false, err + } + if !ok { + in.Restore(start) + return nil, false, nil + } + match = append(match, m) + } + } +} + +// WhileNotEOFOr matches while the given delimiter is not found or the end of the input is reached. +// This requires no successful parses before the delimiter or EOF is found. +// See tests for examples. +func WhileNotEOFOr[T, D any](parser Parser[T], delimiter Parser[D]) Parser[[]T] { + return WhileNot(parser, Or(delimiter, EOF[T]())) +} diff --git a/core/while_test.go b/core/while_test.go new file mode 100644 index 0000000..cae2ec6 --- /dev/null +++ b/core/while_test.go @@ -0,0 +1,74 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestWhileNot(t *testing.T) { + tests := []ParserTest[[]string]{ + { + Name: "WhileNot: success", + Input: "ABCDEF", + Parser: core.WhileNot(core.AnyRune, core.Rune('D')), + ExpectedMatch: []string{"A", "B", "C"}, + ExpectedOK: true, + RemainingInput: "DEF", + }, + { + Name: "WhileNot: reach end before delimiter", + Input: "ABC", + Parser: core.WhileNot(core.AnyRune, core.Rune('D')), + ExpectedOK: false, + RemainingInput: "ABC", + }, + { + Name: "WhileNot: delimiter matches on first character", + Input: "ABCA", + Parser: core.WhileNot(core.AnyRune, core.Rune('A')), + ExpectedMatch: []string{}, + ExpectedOK: true, + RemainingInput: "ABCA", + }, + { + Name: "WhileNotEOF: allow EOF", + Input: "ABC", + Parser: core.WhileNotEOFOr(core.AnyRune, core.Rune('D')), + ExpectedMatch: []string{"A", "B", "C"}, + ExpectedOK: true, + }, + { + Name: "WhileNotEOF: delimiter reached", + Input: "ABCD", + Parser: core.WhileNotEOFOr(core.AnyRune, core.Rune('D')), + ExpectedMatch: []string{"A", "B", "C"}, + ExpectedOK: true, + RemainingInput: "D", + }, + { + Name: "WhileNotEOF: empty input", + Input: "", + Parser: core.WhileNotEOFOr(core.AnyRune, core.Rune('D')), + ExpectedMatch: []string{}, + ExpectedOK: true, + }, + } + RunTests(t, tests) + +} diff --git a/core/whitespace.go b/core/whitespace.go new file mode 100644 index 0000000..d074057 --- /dev/null +++ b/core/whitespace.go @@ -0,0 +1,53 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +import "unicode" + +// Whitespace parses whitespace. +var Whitespace Parser[string] = StringFrom(OneOrMore(RuneInRanges(unicode.White_Space))) + +// InlineWhitespace parses inline whitespace (spaces and tabs). +var InlineWhitespace = StringFrom(OneOrMore(RuneIn(" \t"))) + +// OptionalWhitespace parses optional whitespace. +var OptionalWhitespace = func(in Input) (output string, ok bool, err error) { + output, ok, err = Whitespace(in) + if err != nil { + return + } + return output, true, nil +} + +// OptionalInlineWhitespace parses optional inline whitespace. +var OptionalInlineWhitespace = func(in Input) (output string, ok bool, err error) { + output, ok, err = InlineWhitespace(in) + if err != nil { + return + } + return output, true, nil +} + +// CR is a carriage return. +var CR = Rune('\r') + +// CR parses a line feed, used by Unix systems as the newline. +var LF = Rune('\n') + +// CRLF parses a carriage returned, followed by a line feed, used by Windows systems as the newline. +var CRLF = String("\r\n") + +// NewLine matches either a Windows or Unix line break character. +var NewLine = Any(CRLF, LF) diff --git a/core/whitespace_test.go b/core/whitespace_test.go new file mode 100644 index 0000000..faee29a --- /dev/null +++ b/core/whitespace_test.go @@ -0,0 +1,97 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + . "github.com/liamawhite/parse/core" + . "github.com/liamawhite/parse/test" +) + +func TestWhitespace(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "Whitespace: no match", + Input: "ABCDEF", + Parser: Whitespace, + ExpectedMatch: "", + ExpectedOK: false, + RemainingInput: "ABCDEF", + }, + { + Name: "Whitespace: match", + Input: " \t \n \r ABC", + Parser: Whitespace, + ExpectedMatch: " \t \n \r ", + ExpectedOK: true, + RemainingInput: "ABC", + }, + { + Name: "Inlined whitespace: no match", + Input: "\nABC", + Parser: InlineWhitespace, + ExpectedOK: false, + RemainingInput: "\nABC", + }, + { + Name: "Inlined whitespace: match", + Input: " \t ABC", + Parser: InlineWhitespace, + ExpectedMatch: " \t ", + ExpectedOK: true, + RemainingInput: "ABC", + }, + } + RunTests(t, tests) +} + +func TestOptionalWhitespace(t *testing.T) { + tests := []ParserTest[string]{ + { + Name: "OptionalWhitespace: no match", + Input: "ABCDEF", + Parser: OptionalWhitespace, + ExpectedMatch: "", + ExpectedOK: true, + RemainingInput: "ABCDEF", + }, + { + Name: "OptionalWhitespace: match", + Input: " ABC", + Parser: OptionalWhitespace, + ExpectedMatch: " ", + ExpectedOK: true, + RemainingInput: "ABC", + }, + { + Name: "OptionalInlineWhitespace: no match", + Input: "\nABC", + Parser: OptionalInlineWhitespace, + ExpectedMatch: "", + ExpectedOK: true, + RemainingInput: "\nABC", + }, + { + Name: "OptionalInlineWhitespace: match", + Input: " \t ABC", + Parser: OptionalInlineWhitespace, + ExpectedMatch: " \t ", + ExpectedOK: true, + RemainingInput: "ABC", + }, + } + RunTests(t, tests) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8874458 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/liamawhite/parse + +go 1.23.2 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..713a0b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/parser.go b/test/parser.go new file mode 100644 index 0000000..d04ef14 --- /dev/null +++ b/test/parser.go @@ -0,0 +1,62 @@ +// Copyright 2024 Liam White +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core_test + +import ( + "testing" + + "github.com/liamawhite/parse/core" + "github.com/stretchr/testify/assert" +) + +type ParserTest[T any] struct { + Name string + Input string + Parser core.Parser[T] + ExpectedMatch T + ExpectedOK bool + WantErr bool + RemainingInput string +} + +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) + match, ok, err := test.Parser(in) + assert.Equal(t, test.ExpectedOK, ok) + assert.Equal(t, test.ExpectedMatch, match) + if test.WantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + remaining, _, _ := core.StringWhileNot(core.EOF[string]())(in) + assert.Equal(t, test.RemainingInput, remaining) + }) + } +} + +// Change some of the characters in the string to uppercase +func CaPiTaLiZe(s string) string { + runes := []rune(s) + for i, r := range s { + if i%2 == 0 && r >= 'a' && r <= 'z' { + runes[i] = r - 32 + } + } + return string(runes) +} diff --git a/time/date.go b/time/date.go new file mode 100644 index 0000000..fa7eac2 --- /dev/null +++ b/time/date.go @@ -0,0 +1,157 @@ +// Copyright 2024 Notedown Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package time + +import ( + "fmt" + "strconv" + "time" + + . "github.com/liamawhite/parse/core" +) + +// Parse a date in the format yyyy-MM-dd. +var YearMonthDay = func(in Input) (time.Time, bool, error) { + // Create parsers for year, month and day. + year := StringFrom(Times(4, Digit)) + month := StringFrom(RuneIn("01"), Digit) + day := StringFrom(RuneIn("0123"), Digit) + + // Create string parser for yyyy-MM-dd. + date := StringFrom(All(year, Rune('-'), month, Rune('-'), day)) + + s, ok, err := date(in) + if err != nil || !ok { + return time.Time{}, false, err + } + + // Parse the date. + match, err := time.Parse("2006-01-02", s) + if err != nil { + return time.Time{}, false, fmt.Errorf("failed to parse date: %w", err) + } + + return match, true, nil +} + +// mon, monday, tue, tues, tuesday, wed, weds, wednesday, thu, thur, thurs, thursday, fri, friday, sat, saturday, sun, sunday +var DayOfWeek = func(in Input) (match time.Weekday, ok bool, err error) { + m := map[time.Weekday]Parser[string]{ + // Ordering is important! Use more specific parsers first. + time.Monday: Any(StringInsensitive("monday"), StringInsensitive("mon")), + time.Tuesday: Any(StringInsensitive("tuesday"), StringInsensitive("tues"), StringInsensitive("tue")), + time.Wednesday: Any(StringInsensitive("wednesday"), StringInsensitive("weds"), StringInsensitive("wed")), + time.Thursday: Any(StringInsensitive("thursday"), StringInsensitive("thurs"), StringInsensitive("thur"), StringInsensitive("thu")), + time.Friday: Any(StringInsensitive("friday"), StringInsensitive("fri")), + time.Saturday: Any(StringInsensitive("saturday"), StringInsensitive("sat")), + time.Sunday: Any(StringInsensitive("sunday"), StringInsensitive("sun")), + } + + for day, parser := range m { + _, ok, err := parser(in) + if err != nil { + return time.Weekday(-1), false, err + } + if ok { + return day, true, nil + } + } + + return time.Weekday(-1), false, nil +} + +// Space separated list of days of the week. +var DaysOfWeek = func(in Input) (match []time.Weekday, ok bool, err error) { + var days []time.Weekday + + delimiter := RuneIn(" ") + + for { + day, ok, err := DayOfWeek(in) + if err != nil { + return nil, false, err + } + if !ok { + break + } + _, ok, err = delimiter(in) + if err != nil { + return nil, false, err + } + days = append(days, day) + } + + if len(days) == 0 { + return nil, false, nil + } + + return days, true, nil +} + +// Parse a number followed by an optional ordinal (st, nd, rd, th). +var MonthDay = func(in Input) (match int, ok bool, err error) { + n, ok, err := StringFrom(AtLeast(1, Digit))(in) + if err != nil { + return 0, false, err + } + if !ok { + return 0, false, nil + } + + // Parse an optional ordinal. + ordinal := Any(StringInsensitive("st"), StringInsensitive("nd"), StringInsensitive("rd"), StringInsensitive("th")) + _, _, err = ordinal(in) + if err != nil { + return 0, false, err + } + + // Convert the number to an integer. + number, err := strconv.Atoi(n) + if err != nil { + return 0, false, fmt.Errorf("failed to parse number: %w", err) + } + + return number, true, nil +} + +var MonthOfYear = func(in Input) (match time.Month, ok bool, err error) { + m := map[time.Month]Parser[string]{ + // Ordering is important! Use more specific parsers first. + time.January: Any(StringInsensitive("january"), StringInsensitive("jan")), + time.February: Any(StringInsensitive("february"), StringInsensitive("feb")), + time.March: Any(StringInsensitive("march"), StringInsensitive("mar")), + time.April: Any(StringInsensitive("april"), StringInsensitive("apr")), + time.May: StringInsensitive("may"), + time.June: Any(StringInsensitive("june"), StringInsensitive("jun")), + time.July: Any(StringInsensitive("july"), StringInsensitive("jul")), + time.August: Any(StringInsensitive("august"), StringInsensitive("aug")), + time.September: Any(StringInsensitive("september"), StringInsensitive("sept"), StringInsensitive("sep")), + time.October: Any(StringInsensitive("october"), StringInsensitive("oct")), + time.November: Any(StringInsensitive("november"), StringInsensitive("nov")), + time.December: Any(StringInsensitive("december"), StringInsensitive("dec")), + } + + for month, parser := range m { + _, ok, err := parser(in) + if err != nil { + return time.Month(-1), false, err + } + if ok { + return month, true, nil + } + } + + return 0, false, nil +} diff --git a/time/date_test.go b/time/date_test.go new file mode 100644 index 0000000..1c969f1 --- /dev/null +++ b/time/date_test.go @@ -0,0 +1,460 @@ +// Copyright 2024 Notedown Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package time_test + +import ( + "testing" + "time" + + . "github.com/liamawhite/parse/test" + . "github.com/liamawhite/parse/time" +) + +func TestYearMonthDay(t *testing.T) { + tests := []ParserTest[time.Time]{ + { + Name: "match", + Input: "2021-01-01", + Parser: YearMonthDay, + ExpectedMatch: time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC), + ExpectedOK: true, + }, + { + Name: "invalid day high", + Input: "2021-01-32", + Parser: YearMonthDay, + ExpectedOK: false, + WantErr: true, + }, + { + Name: "invalid day low", + Input: "2021-01-00", + Parser: YearMonthDay, + ExpectedOK: false, + WantErr: true, + }, + { + Name: "invalid month high", + Input: "2021-13-01", + Parser: YearMonthDay, + ExpectedOK: false, + WantErr: true, + }, + { + Name: "invalid month low", + Input: "2021-00-01", + Parser: YearMonthDay, + ExpectedOK: false, + WantErr: true, + }, + { + Name: "leap year", + Input: "2024-02-29", + Parser: YearMonthDay, + ExpectedMatch: time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC), + ExpectedOK: true, + }, + { + Name: "not a leap year", + Input: "2021-02-29", + Parser: YearMonthDay, + ExpectedOK: false, + WantErr: true, + }, + } + RunTests(t, tests) +} + +func TestDayOfWeek(t *testing.T) { + tests := []ParserTest[time.Weekday]{ + { + Name: "monday long", + Input: CaPiTaLiZe("monday"), + Parser: DayOfWeek, + ExpectedMatch: time.Monday, + ExpectedOK: true, + }, + { + Name: "monday short", + Input: CaPiTaLiZe("mon"), + Parser: DayOfWeek, + ExpectedMatch: time.Monday, + ExpectedOK: true, + }, + { + Name: "tuesday long", + Input: CaPiTaLiZe("tuesday"), + Parser: DayOfWeek, + ExpectedMatch: time.Tuesday, + ExpectedOK: true, + }, + { + Name: "tuesday short", + Input: CaPiTaLiZe("tues"), + Parser: DayOfWeek, + ExpectedMatch: time.Tuesday, + ExpectedOK: true, + }, + { + Name: "tuesday shortest", + Input: CaPiTaLiZe("tue"), + Parser: DayOfWeek, + ExpectedMatch: time.Tuesday, + ExpectedOK: true, + }, + { + Name: "wednesday long", + Input: CaPiTaLiZe("wednesday"), + Parser: DayOfWeek, + ExpectedMatch: time.Wednesday, + ExpectedOK: true, + }, + { + Name: "wednesday short", + Input: CaPiTaLiZe("weds"), + Parser: DayOfWeek, + ExpectedMatch: time.Wednesday, + ExpectedOK: true, + }, + { + Name: "wednesday shortest", + Input: CaPiTaLiZe("wed"), + Parser: DayOfWeek, + ExpectedMatch: time.Wednesday, + ExpectedOK: true, + }, + { + Name: "thursday long", + Input: CaPiTaLiZe("thursday"), + Parser: DayOfWeek, + ExpectedMatch: time.Thursday, + ExpectedOK: true, + }, + { + Name: "thursday short", + Input: CaPiTaLiZe("thurs"), + Parser: DayOfWeek, + ExpectedMatch: time.Thursday, + ExpectedOK: true, + }, + { + Name: "thursday shorter", + Input: CaPiTaLiZe("thur"), + Parser: DayOfWeek, + ExpectedMatch: time.Thursday, + ExpectedOK: true, + }, + { + Name: "thursday shortest", + Input: CaPiTaLiZe("thu"), + Parser: DayOfWeek, + ExpectedMatch: time.Thursday, + ExpectedOK: true, + }, + { + Name: "friday long", + Input: CaPiTaLiZe("friday"), + Parser: DayOfWeek, + ExpectedMatch: time.Friday, + ExpectedOK: true, + }, + { + Name: "friday short", + Input: CaPiTaLiZe("fri"), + Parser: DayOfWeek, + ExpectedMatch: time.Friday, + ExpectedOK: true, + }, + { + Name: "saturday long", + Input: CaPiTaLiZe("saturday"), + Parser: DayOfWeek, + ExpectedMatch: time.Saturday, + ExpectedOK: true, + }, + { + Name: "saturday short", + Input: CaPiTaLiZe("sat"), + Parser: DayOfWeek, + ExpectedMatch: time.Saturday, + ExpectedOK: true, + }, + { + Name: "sunday long", + Input: CaPiTaLiZe("sunday"), + Parser: DayOfWeek, + ExpectedMatch: time.Sunday, + ExpectedOK: true, + }, + { + Name: "sunday short", + Input: CaPiTaLiZe("sun"), + Parser: DayOfWeek, + ExpectedMatch: time.Sunday, + ExpectedOK: true, + }, + { + Name: "no match", + Input: "foo", + Parser: DayOfWeek, + ExpectedOK: false, + ExpectedMatch: -1, + RemainingInput: "foo", + }, + } + RunTests(t, tests) +} + +func TestDaysOfWeek(t *testing.T) { + tests := []ParserTest[[]time.Weekday]{ + { + Name: "monday", + Input: CaPiTaLiZe("monday"), + Parser: DaysOfWeek, + ExpectedMatch: []time.Weekday{time.Monday}, + ExpectedOK: true, + }, + { + Name: "every day of the week", + Input: CaPiTaLiZe("mon tues wed thu fri sat sun"), + Parser: DaysOfWeek, + ExpectedMatch: []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday}, + ExpectedOK: true, + }, + { + Name: "not a day", + Input: "not a day", + Parser: DaysOfWeek, + ExpectedOK: false, + RemainingInput: "not a day", + }, + } + RunTests(t, tests) +} + +func TestMonthDay(t *testing.T) { + tests := []ParserTest[int]{ + { + Name: "1st", + Input: CaPiTaLiZe("1st"), + Parser: MonthDay, + ExpectedMatch: 1, + ExpectedOK: true, + }, + { + Name: "1", + Input: "1", + Parser: MonthDay, + ExpectedMatch: 1, + ExpectedOK: true, + }, + { + Name: "2nd", + Input: CaPiTaLiZe("2nd"), + Parser: MonthDay, + ExpectedMatch: 2, + ExpectedOK: true, + }, + { + Name: "3rd", + Input: CaPiTaLiZe("3rd"), + Parser: MonthDay, + ExpectedMatch: 3, + ExpectedOK: true, + }, + { + Name: "4th", + Input: CaPiTaLiZe("4th"), + Parser: MonthDay, + ExpectedMatch: 4, + ExpectedOK: true, + }, + } + RunTests(t, tests) +} + +func TestMonthOfYear(t *testing.T) { + tests := []ParserTest[time.Month]{ + { + Name: "january", + Input: CaPiTaLiZe("january"), + Parser: MonthOfYear, + ExpectedMatch: time.January, + ExpectedOK: true, + }, + { + Name: "jan", + Input: CaPiTaLiZe("jan"), + Parser: MonthOfYear, + ExpectedMatch: time.January, + ExpectedOK: true, + }, + { + Name: "february", + Input: CaPiTaLiZe("february"), + Parser: MonthOfYear, + ExpectedMatch: time.February, + ExpectedOK: true, + }, + { + Name: "feb", + Input: CaPiTaLiZe("feb"), + Parser: MonthOfYear, + ExpectedMatch: time.February, + ExpectedOK: true, + }, + { + Name: "march", + Input: CaPiTaLiZe("march"), + Parser: MonthOfYear, + ExpectedMatch: time.March, + ExpectedOK: true, + }, + { + Name: "mar", + Input: CaPiTaLiZe("mar"), + Parser: MonthOfYear, + ExpectedMatch: time.March, + ExpectedOK: true, + }, + { + Name: "april", + Input: CaPiTaLiZe("april"), + Parser: MonthOfYear, + ExpectedMatch: time.April, + ExpectedOK: true, + }, + { + Name: "apr", + Input: CaPiTaLiZe("apr"), + Parser: MonthOfYear, + ExpectedMatch: time.April, + ExpectedOK: true, + }, + { + Name: "may", + Input: CaPiTaLiZe("may"), + Parser: MonthOfYear, + ExpectedMatch: time.May, + ExpectedOK: true, + }, + { + Name: "june", + Input: CaPiTaLiZe("june"), + Parser: MonthOfYear, + ExpectedMatch: time.June, + ExpectedOK: true, + }, + { + Name: "jun", + Input: CaPiTaLiZe("jun"), + Parser: MonthOfYear, + ExpectedMatch: time.June, + ExpectedOK: true, + }, + { + Name: "july", + Input: CaPiTaLiZe("july"), + Parser: MonthOfYear, + ExpectedMatch: time.July, + ExpectedOK: true, + }, + { + Name: "jul", + Input: CaPiTaLiZe("jul"), + Parser: MonthOfYear, + ExpectedMatch: time.July, + ExpectedOK: true, + }, + { + Name: "august", + Input: CaPiTaLiZe("august"), + Parser: MonthOfYear, + ExpectedMatch: time.August, + ExpectedOK: true, + }, + { + Name: "aug", + Input: CaPiTaLiZe("aug"), + Parser: MonthOfYear, + ExpectedMatch: time.August, + ExpectedOK: true, + }, + { + Name: "september", + Input: CaPiTaLiZe("september"), + Parser: MonthOfYear, + ExpectedMatch: time.September, + ExpectedOK: true, + }, + { + Name: "sept", + Input: CaPiTaLiZe("sept"), + Parser: MonthOfYear, + ExpectedMatch: time.September, + ExpectedOK: true, + }, + { + Name: "sep", + Input: CaPiTaLiZe("sep"), + Parser: MonthOfYear, + ExpectedMatch: time.September, + ExpectedOK: true, + }, + { + Name: "october", + Input: CaPiTaLiZe("october"), + Parser: MonthOfYear, + ExpectedMatch: time.October, + ExpectedOK: true, + }, + { + Name: "oct", + Input: CaPiTaLiZe("oct"), + Parser: MonthOfYear, + ExpectedMatch: time.October, + ExpectedOK: true, + }, + { + Name: "november", + Input: CaPiTaLiZe("november"), + Parser: MonthOfYear, + ExpectedMatch: time.November, + ExpectedOK: true, + }, + { + Name: "nov", + Input: CaPiTaLiZe("nov"), + Parser: MonthOfYear, + ExpectedMatch: time.November, + ExpectedOK: true, + }, + { + Name: "december", + Input: CaPiTaLiZe("december"), + Parser: MonthOfYear, + ExpectedMatch: time.December, + ExpectedOK: true, + }, + { + Name: "dec", + Input: CaPiTaLiZe("dec"), + Parser: MonthOfYear, + ExpectedMatch: time.December, + ExpectedOK: true, + }, + } + RunTests(t, tests) +} diff --git a/time/duration.go b/time/duration.go new file mode 100644 index 0000000..fabcc2d --- /dev/null +++ b/time/duration.go @@ -0,0 +1,27 @@ +// Copyright 2024 Notedown Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package time + +import ( + . "github.com/liamawhite/parse/core" +) + +var ( + // More specific first. + Day = Any(String("days"), String("day")) + Week = Any(String("weeks"), String("week")) + Month = Any(String("months"), String("month")) + Year = Any(String("years"), String("year")) +)