From 940e2dd786014a8a226c5395914c870bfc2605ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kj=C3=A6r=20Larsen?= Date: Sun, 25 Sep 2022 14:31:30 +0200 Subject: [PATCH 1/6] Ported expression parser --- .../scala/cats/parse/expr/ExprParser.scala | 71 +++++++++++++++++++ .../main/scala/cats/parse/expr/Operator.scala | 16 +++++ .../src/test/scala/cats/parse/ExprTest.scala | 43 +++++++++++ 3 files changed, 130 insertions(+) create mode 100644 core/shared/src/main/scala/cats/parse/expr/ExprParser.scala create mode 100644 core/shared/src/main/scala/cats/parse/expr/Operator.scala create mode 100644 core/shared/src/test/scala/cats/parse/ExprTest.scala diff --git a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala new file mode 100644 index 00000000..05c68622 --- /dev/null +++ b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala @@ -0,0 +1,71 @@ +package cats.parse.expr + +import cats.parse.{Parser, Parser0} + +object ExprParser { + + import Operator.{BinP, UnP} + + def make[A](term: Parser[A], table: List[List[Operator[A]]]): Parser[A] = + table.foldLeft(term)(addPrecLevel) + + private final case class Batch[A]( + inn: List[BinP[A]], + inl: List[BinP[A]], + inr: List[BinP[A]], + pre: List[UnP[A]], + post: List[UnP[A]] + ) { + def add(op: Operator[A]): Batch[A] = op match { + case Operator.InfixN(p) => this.copy(inn = p :: inn) + case Operator.InfixL(p) => this.copy(inl = p :: inl) + case Operator.InfixR(p) => this.copy(inr = p :: inr) + case Operator.Prefix(p) => this.copy(pre = p :: pre) + case Operator.Postfix(p) => this.copy(post = p :: post) + } + } + private object Batch { + def empty[A]: Batch[A] = + Batch(List.empty, List.empty, List.empty, List.empty, List.empty) + } + + private def addPrecLevel[A](p: Parser[A], level: List[Operator[A]]): Parser[A] = { + + val batch: Batch[A] = level.foldRight(Batch.empty[A]) { (op, b) => b.add(op) } + + def orId(p: Parser[A => A]): Parser0[A => A] = + p.orElse(Parser.pure((x: A) => x)) + + def pTerm(prefix: UnP[A], postfix: UnP[A]): Parser[A] = + (orId(prefix).with1 ~ p ~ orId(postfix)).map { case ((pre, t), post) => + post(pre(t)) + } + + def pInfixN(op: BinP[A], term: Parser[A], a: A): Parser[A] = + (op ~ term).map { case (f, y) => f(a, y) } + + def pInfixL(op: BinP[A], term: Parser[A], a: A): Parser[A] = + (op ~ term).flatMap { case (f, y) => + pInfixL(op, term, f(a, y)) | Parser.pure(f(a, y)) + } + + def pInfixR(op: BinP[A], term: Parser[A], a: A): Parser[A] = + (op ~ (term.flatMap(r => pInfixR(op, term, a) | Parser.pure(r)))).map { case (f, y) => + f(a, y) + } + + val term_ = pTerm(Parser.oneOf(batch.pre), Parser.oneOf(batch.post)) + + term_.flatMap(x => + Parser.oneOf0( + List( + pInfixR(Parser.oneOf(batch.inr), term_, x), + pInfixN(Parser.oneOf(batch.inn), term_, x), + pInfixL(Parser.oneOf(batch.inl), term_, x), + Parser.pure(x) + ) + ) + ) + + } +} diff --git a/core/shared/src/main/scala/cats/parse/expr/Operator.scala b/core/shared/src/main/scala/cats/parse/expr/Operator.scala new file mode 100644 index 00000000..115ab9de --- /dev/null +++ b/core/shared/src/main/scala/cats/parse/expr/Operator.scala @@ -0,0 +1,16 @@ +package cats.parse.expr + +import cats.parse.Parser + +sealed abstract class Operator[A] +object Operator { + + type BinP[A] = Parser[(A, A) => A] + type UnP[A] = Parser[A => A] + + final case class InfixN[A](c: BinP[A]) extends Operator[A] + final case class InfixL[A](c: BinP[A]) extends Operator[A] + final case class InfixR[A](c: BinP[A]) extends Operator[A] + final case class Prefix[A](c: UnP[A]) extends Operator[A] + final case class Postfix[A](c: UnP[A]) extends Operator[A] +} diff --git a/core/shared/src/test/scala/cats/parse/ExprTest.scala b/core/shared/src/test/scala/cats/parse/ExprTest.scala new file mode 100644 index 00000000..b1132223 --- /dev/null +++ b/core/shared/src/test/scala/cats/parse/ExprTest.scala @@ -0,0 +1,43 @@ +package cats.parse + +import cats.parse.expr.ExprParser +import cats.parse.expr.Operator + +class ExprTest extends munit.FunSuite { + + sealed trait Exp + final case class N(n: String) extends Exp + final case class Neg(a: Exp) extends Exp + final case class Minus(a: Exp, b: Exp) extends Exp + final case class Plus(a: Exp, b: Exp) extends Exp + final case class Times(a: Exp, b: Exp) extends Exp + + test("foo") { + + val term = Numbers.digits.map(N.apply) + + val table: List[List[Operator[Exp]]] = List( + List( + Operator.Prefix(Parser.char('-').as(Neg.apply)) + ), + List( + Operator.InfixL(Parser.char('*').as(Times.apply)) + ), + List( + Operator.InfixL(Parser.char('+').as(Plus.apply)), + Operator.InfixL(Parser.char('-').as(Minus.apply)) + ) + ) + + val exprParser = ExprParser.make(term, table) + + assertEquals(exprParser.parseAll("1"), Right(N("1"))) + assertEquals(exprParser.parseAll("-1"), Right(Neg(N("1")))) + assertEquals(exprParser.parseAll("1+2"), Right(Plus(N("1"), N("2")))) + assertEquals(exprParser.parseAll("1+-2"), Right(Plus(N("1"), Neg(N("2"))))) + assertEquals(exprParser.parseAll("1+2-3"), Right(Minus(Plus(N("1"), N("2")), N("3")))) + assertEquals(exprParser.parseAll("1+2*3"), Right(Plus(N("1"), Times(N("2"), N("3"))))) + + } + +} From bf3fc53dee40fd60943640b40450595f8a38de54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kj=C3=A6r=20Larsen?= Date: Sun, 25 Sep 2022 15:00:12 +0200 Subject: [PATCH 2/6] Added some tests --- .../scala/cats/parse/expr/ExprParser.scala | 3 +- .../main/scala/cats/parse/expr/Operator.scala | 13 ++-- .../src/test/scala/cats/parse/ExprTest.scala | 62 ++++++++++++++----- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala index 05c68622..9f5b8789 100644 --- a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala +++ b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala @@ -4,7 +4,8 @@ import cats.parse.{Parser, Parser0} object ExprParser { - import Operator.{BinP, UnP} + type BinP[A] = Parser[(A, A) => A] + type UnP[A] = Parser[A => A] def make[A](term: Parser[A], table: List[List[Operator[A]]]): Parser[A] = table.foldLeft(term)(addPrecLevel) diff --git a/core/shared/src/main/scala/cats/parse/expr/Operator.scala b/core/shared/src/main/scala/cats/parse/expr/Operator.scala index 115ab9de..409ed945 100644 --- a/core/shared/src/main/scala/cats/parse/expr/Operator.scala +++ b/core/shared/src/main/scala/cats/parse/expr/Operator.scala @@ -5,12 +5,9 @@ import cats.parse.Parser sealed abstract class Operator[A] object Operator { - type BinP[A] = Parser[(A, A) => A] - type UnP[A] = Parser[A => A] - - final case class InfixN[A](c: BinP[A]) extends Operator[A] - final case class InfixL[A](c: BinP[A]) extends Operator[A] - final case class InfixR[A](c: BinP[A]) extends Operator[A] - final case class Prefix[A](c: UnP[A]) extends Operator[A] - final case class Postfix[A](c: UnP[A]) extends Operator[A] + final case class InfixN[A](c: Parser[(A, A) => A]) extends Operator[A] + final case class InfixL[A](c: Parser[(A, A) => A]) extends Operator[A] + final case class InfixR[A](c: Parser[(A, A) => A]) extends Operator[A] + final case class Prefix[A](c: Parser[A => A]) extends Operator[A] + final case class Postfix[A](c: Parser[A => A]) extends Operator[A] } diff --git a/core/shared/src/test/scala/cats/parse/ExprTest.scala b/core/shared/src/test/scala/cats/parse/ExprTest.scala index b1132223..0e365949 100644 --- a/core/shared/src/test/scala/cats/parse/ExprTest.scala +++ b/core/shared/src/test/scala/cats/parse/ExprTest.scala @@ -8,35 +8,63 @@ class ExprTest extends munit.FunSuite { sealed trait Exp final case class N(n: String) extends Exp final case class Neg(a: Exp) extends Exp + final case class Q(a: Exp) extends Exp final case class Minus(a: Exp, b: Exp) extends Exp final case class Plus(a: Exp, b: Exp) extends Exp final case class Times(a: Exp, b: Exp) extends Exp + final case class Eq(a: Exp, b: Exp) extends Exp - test("foo") { + def token[A](p: Parser[A]): Parser[A] = + p.surroundedBy(Rfc5234.sp.rep0) - val term = Numbers.digits.map(N.apply) - - val table: List[List[Operator[Exp]]] = List( - List( - Operator.Prefix(Parser.char('-').as(Neg.apply)) - ), - List( - Operator.InfixL(Parser.char('*').as(Times.apply)) - ), - List( - Operator.InfixL(Parser.char('+').as(Plus.apply)), - Operator.InfixL(Parser.char('-').as(Minus.apply)) - ) + val table: List[List[Operator[Exp]]] = List( + List( + Operator.Prefix(token(Parser.char('-')).as(Neg.apply)), + Operator.Postfix(token(Parser.char('?')).as(Q.apply)) + ), + List( + Operator.InfixL(token(Parser.char('*')).as(Times.apply)) + ), + List( + Operator.InfixL(token(Parser.char('+')).as(Plus.apply)), + Operator.InfixL(token(Parser.char('-')).as(Minus.apply)) + ), + List( + Operator.InfixN(token(Parser.char('=')).as(Eq.apply)) ) + ) + + test("non-recursive") { + val term = token(Numbers.digits.map(N.apply)) val exprParser = ExprParser.make(term, table) assertEquals(exprParser.parseAll("1"), Right(N("1"))) assertEquals(exprParser.parseAll("-1"), Right(Neg(N("1")))) - assertEquals(exprParser.parseAll("1+2"), Right(Plus(N("1"), N("2")))) - assertEquals(exprParser.parseAll("1+-2"), Right(Plus(N("1"), Neg(N("2"))))) - assertEquals(exprParser.parseAll("1+2-3"), Right(Minus(Plus(N("1"), N("2")), N("3")))) + assertEquals(exprParser.parseAll("-1?"), Right(Q(Neg(N("1"))))) + assertEquals(exprParser.parseAll("1 + 2"), Right(Plus(N("1"), N("2")))) + assertEquals(exprParser.parseAll("1 + -2"), Right(Plus(N("1"), Neg(N("2"))))) + assertEquals(exprParser.parseAll("1 + 2 - 3"), Right(Minus(Plus(N("1"), N("2")), N("3")))) assertEquals(exprParser.parseAll("1+2*3"), Right(Plus(N("1"), Times(N("2"), N("3"))))) + assert(exprParser.parseAll("1 = 2 = 3").isLeft) + assertEquals(exprParser.parseAll("1 = 2 + 3"), Right(Eq(N("1"), Plus(N("2"), N("3"))))) + + } + + test("recursive") { + + val exprParser = Parser.recursive[Exp] { expr => + val term = token(Numbers.digits.map(N.apply)) | expr.between( + token(Parser.char('(')), + token(Parser.char(')')) + ) + ExprParser.make(term, table) + } + + assertEquals(exprParser.parseAll("( (2) )"), Right(N("2"))) + assertEquals(exprParser.parseAll("-(-1)"), Right(Neg(Neg(N("1"))))) + assertEquals(exprParser.parseAll("1*( 2+3 )"), Right(Times(N("1"), Plus(N("2"), N("3"))))) + assertEquals(exprParser.parseAll("(1 = 2) = 3?"), Right(Eq(Eq(N("1"), N("2")), Q(N("3"))))) } From cf079c3c8fefa0ff92bb50b616652c4c8fb6f0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kj=C3=A6r=20Larsen?= Date: Sun, 25 Sep 2022 15:13:43 +0200 Subject: [PATCH 3/6] Add comments --- .../scala/cats/parse/expr/ExprParser.scala | 34 +++++++++++++------ .../main/scala/cats/parse/expr/Operator.scala | 16 +++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala index 9f5b8789..afcdf5d3 100644 --- a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala +++ b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala @@ -7,9 +7,14 @@ object ExprParser { type BinP[A] = Parser[(A, A) => A] type UnP[A] = Parser[A => A] + /** Takes a parser for terms and a list of operator precedence levels and returns a parser of + * expressions. + */ def make[A](term: Parser[A], table: List[List[Operator[A]]]): Parser[A] = table.foldLeft(term)(addPrecLevel) + /** Internal helper class for splitting an operator precedence level into the varios types. + */ private final case class Batch[A]( inn: List[BinP[A]], inl: List[BinP[A]], @@ -28,41 +33,48 @@ object ExprParser { private object Batch { def empty[A]: Batch[A] = Batch(List.empty, List.empty, List.empty, List.empty, List.empty) + + def apply[A](level: List[Operator[A]]): Batch[A] = + level.foldRight(Batch.empty[A]) { (op, b) => b.add(op) } } private def addPrecLevel[A](p: Parser[A], level: List[Operator[A]]): Parser[A] = { - val batch: Batch[A] = level.foldRight(Batch.empty[A]) { (op, b) => b.add(op) } + val batch = Batch(level) def orId(p: Parser[A => A]): Parser0[A => A] = p.orElse(Parser.pure((x: A) => x)) - def pTerm(prefix: UnP[A], postfix: UnP[A]): Parser[A] = + def parseTerm(prefix: UnP[A], postfix: UnP[A]): Parser[A] = (orId(prefix).with1 ~ p ~ orId(postfix)).map { case ((pre, t), post) => post(pre(t)) } - def pInfixN(op: BinP[A], term: Parser[A], a: A): Parser[A] = + def parseInfixN(op: BinP[A], term: Parser[A], a: A): Parser[A] = (op ~ term).map { case (f, y) => f(a, y) } - def pInfixL(op: BinP[A], term: Parser[A], a: A): Parser[A] = + def parseInfixL(op: BinP[A], term: Parser[A], a: A): Parser[A] = (op ~ term).flatMap { case (f, y) => - pInfixL(op, term, f(a, y)) | Parser.pure(f(a, y)) + parseInfixL(op, term, f(a, y)) | Parser.pure(f(a, y)) } - def pInfixR(op: BinP[A], term: Parser[A], a: A): Parser[A] = - (op ~ (term.flatMap(r => pInfixR(op, term, a) | Parser.pure(r)))).map { case (f, y) => + def parseInfixR(op: BinP[A], term: Parser[A], a: A): Parser[A] = + (op ~ (term.flatMap(r => parseInfixR(op, term, a) | Parser.pure(r)))).map { case (f, y) => f(a, y) } - val term_ = pTerm(Parser.oneOf(batch.pre), Parser.oneOf(batch.post)) + /** Try to parse a term prefixed or postfixed + */ + val term_ = parseTerm(Parser.oneOf(batch.pre), Parser.oneOf(batch.post)) + /** Then try the operators in the precedence level + */ term_.flatMap(x => Parser.oneOf0( List( - pInfixR(Parser.oneOf(batch.inr), term_, x), - pInfixN(Parser.oneOf(batch.inn), term_, x), - pInfixL(Parser.oneOf(batch.inl), term_, x), + parseInfixR(Parser.oneOf(batch.inr), term_, x), + parseInfixN(Parser.oneOf(batch.inn), term_, x), + parseInfixL(Parser.oneOf(batch.inl), term_, x), Parser.pure(x) ) ) diff --git a/core/shared/src/main/scala/cats/parse/expr/Operator.scala b/core/shared/src/main/scala/cats/parse/expr/Operator.scala index 409ed945..b27e6625 100644 --- a/core/shared/src/main/scala/cats/parse/expr/Operator.scala +++ b/core/shared/src/main/scala/cats/parse/expr/Operator.scala @@ -2,12 +2,28 @@ package cats.parse.expr import cats.parse.Parser +/** This an entry in the operator table used to build an expression parser + */ sealed abstract class Operator[A] object Operator { + /** Non-associative infix operator. Example: &&, || + */ final case class InfixN[A](c: Parser[(A, A) => A]) extends Operator[A] + + /** Left-associative infix operator. Example: +, *, / + */ final case class InfixL[A](c: Parser[(A, A) => A]) extends Operator[A] + + /** Right-associative infix operator. Example: assignment in C + */ final case class InfixR[A](c: Parser[(A, A) => A]) extends Operator[A] + + /** Prefix operator like for instance unary negation + */ final case class Prefix[A](c: Parser[A => A]) extends Operator[A] + + /** Postfix operator + */ final case class Postfix[A](c: Parser[A => A]) extends Operator[A] } From 526d7ef837afedaa5d13c5dac7262ffecc85ada4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kj=C3=A6r=20Larsen?= Date: Tue, 27 Sep 2022 15:34:19 +0200 Subject: [PATCH 4/6] Get rid of flatMap --- .../scala/cats/parse/expr/ExprParser.scala | 70 +++++++++++++------ .../main/scala/cats/parse/expr/Operator.scala | 21 ++++++ .../src/test/scala/cats/parse/ExprTest.scala | 31 +++++++- 3 files changed, 100 insertions(+), 22 deletions(-) diff --git a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala index afcdf5d3..fa45402e 100644 --- a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala +++ b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala @@ -1,3 +1,24 @@ +/* + * Copyright (c) 2021 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + package cats.parse.expr import cats.parse.{Parser, Parser0} @@ -40,28 +61,34 @@ object ExprParser { private def addPrecLevel[A](p: Parser[A], level: List[Operator[A]]): Parser[A] = { + val idParser = Parser.pure((x: A) => x) + val batch = Batch(level) def orId(p: Parser[A => A]): Parser0[A => A] = - p.orElse(Parser.pure((x: A) => x)) + p.orElse(idParser) def parseTerm(prefix: UnP[A], postfix: UnP[A]): Parser[A] = (orId(prefix).with1 ~ p ~ orId(postfix)).map { case ((pre, t), post) => post(pre(t)) } - def parseInfixN(op: BinP[A], term: Parser[A], a: A): Parser[A] = - (op ~ term).map { case (f, y) => f(a, y) } + def parseInfixN(op: BinP[A], term: Parser[A]): Parser[A => A] = + (op ~ term).map { case (op, b) => a => op(a, b) } - def parseInfixL(op: BinP[A], term: Parser[A], a: A): Parser[A] = - (op ~ term).flatMap { case (f, y) => - parseInfixL(op, term, f(a, y)) | Parser.pure(f(a, y)) - } + def parseInfixL(op: BinP[A], term: Parser[A]): Parser[A => A] = + (op ~ term ~ Parser.defer(parseInfixL(op, term)).?) + .map { + case ((op, b), None) => (a: A) => op(a, b) + case ((op, b), Some(rest)) => (a: A) => rest(op(a, b)) + } - def parseInfixR(op: BinP[A], term: Parser[A], a: A): Parser[A] = - (op ~ (term.flatMap(r => parseInfixR(op, term, a) | Parser.pure(r)))).map { case (f, y) => - f(a, y) - } + def parseInfixR(op: BinP[A], term: Parser[A]): Parser[A => A] = + (op ~ term ~ Parser.defer(parseInfixR(op, term)).?) + .map { + case ((op, b), None) => (a: A) => op(a, b) + case ((op, b), Some(rest)) => (a: A) => op(a, rest(b)) + } /** Try to parse a term prefixed or postfixed */ @@ -69,16 +96,19 @@ object ExprParser { /** Then try the operators in the precedence level */ - term_.flatMap(x => - Parser.oneOf0( - List( - parseInfixR(Parser.oneOf(batch.inr), term_, x), - parseInfixN(Parser.oneOf(batch.inn), term_, x), - parseInfixL(Parser.oneOf(batch.inl), term_, x), - Parser.pure(x) + (term_ ~ + Parser + .oneOf0( + List( + parseInfixR(Parser.oneOf(batch.inr), term_), + parseInfixN(Parser.oneOf(batch.inn), term_), + parseInfixL(Parser.oneOf(batch.inl), term_) + ) ) - ) - ) + .?).map { + case (x, None) => x + case (x, Some(rest)) => rest(x) + } } } diff --git a/core/shared/src/main/scala/cats/parse/expr/Operator.scala b/core/shared/src/main/scala/cats/parse/expr/Operator.scala index b27e6625..f15861e1 100644 --- a/core/shared/src/main/scala/cats/parse/expr/Operator.scala +++ b/core/shared/src/main/scala/cats/parse/expr/Operator.scala @@ -1,3 +1,24 @@ +/* + * Copyright (c) 2021 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + package cats.parse.expr import cats.parse.Parser diff --git a/core/shared/src/test/scala/cats/parse/ExprTest.scala b/core/shared/src/test/scala/cats/parse/ExprTest.scala index 0e365949..d2f9a5be 100644 --- a/core/shared/src/test/scala/cats/parse/ExprTest.scala +++ b/core/shared/src/test/scala/cats/parse/ExprTest.scala @@ -1,3 +1,24 @@ +/* + * Copyright (c) 2021 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + package cats.parse import cats.parse.expr.ExprParser @@ -12,6 +33,7 @@ class ExprTest extends munit.FunSuite { final case class Minus(a: Exp, b: Exp) extends Exp final case class Plus(a: Exp, b: Exp) extends Exp final case class Times(a: Exp, b: Exp) extends Exp + final case class And(a: Exp, b: Exp) extends Exp final case class Eq(a: Exp, b: Exp) extends Exp def token[A](p: Parser[A]): Parser[A] = @@ -30,7 +52,8 @@ class ExprTest extends munit.FunSuite { Operator.InfixL(token(Parser.char('-')).as(Minus.apply)) ), List( - Operator.InfixN(token(Parser.char('=')).as(Eq.apply)) + Operator.InfixN(token(Parser.char('&')).as(And.apply)), + Operator.InfixR(token(Parser.char('=')).as(Eq.apply)) ) ) @@ -45,9 +68,13 @@ class ExprTest extends munit.FunSuite { assertEquals(exprParser.parseAll("1 + 2"), Right(Plus(N("1"), N("2")))) assertEquals(exprParser.parseAll("1 + -2"), Right(Plus(N("1"), Neg(N("2"))))) assertEquals(exprParser.parseAll("1 + 2 - 3"), Right(Minus(Plus(N("1"), N("2")), N("3")))) + assertEquals(exprParser.parseAll("1 + 2 + 3 + 4"), Right(Plus(Plus(Plus(N("1"), N("2")), N("3")), N("4")))) assertEquals(exprParser.parseAll("1+2*3"), Right(Plus(N("1"), Times(N("2"), N("3"))))) - assert(exprParser.parseAll("1 = 2 = 3").isLeft) + assert(exprParser.parseAll("1 & 2 & 3").isLeft) + assertEquals(exprParser.parseAll("1 = 2"), Right(Eq(N("1"), N("2")))) + assertEquals(exprParser.parseAll("1 & 2"), Right(And(N("1"), N("2")))) assertEquals(exprParser.parseAll("1 = 2 + 3"), Right(Eq(N("1"), Plus(N("2"), N("3"))))) + assertEquals(exprParser.parseAll("1 = 2 = 3"), Right(Eq(N("1"), Eq(N("2"), N("3"))))) } From 50b6487f4a856892d67ea7a0e569112671b29431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kj=C3=A6r=20Larsen?= Date: Tue, 27 Sep 2022 18:40:39 +0200 Subject: [PATCH 5/6] Property based test --- .../scala/cats/parse/expr/ExprParser.scala | 11 +- .../main/scala/cats/parse/expr/Operator.scala | 12 +- .../src/test/scala/cats/parse/ExprTest.scala | 147 +++++++++++++++--- 3 files changed, 138 insertions(+), 32 deletions(-) diff --git a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala index fa45402e..bc5f45f1 100644 --- a/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala +++ b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala @@ -25,7 +25,12 @@ import cats.parse.{Parser, Parser0} object ExprParser { + /** Parser of binary operator + */ type BinP[A] = Parser[(A, A) => A] + + /** Parser of unary operator + */ type UnP[A] = Parser[A => A] /** Takes a parser for terms and a list of operator precedence levels and returns a parser of @@ -34,7 +39,7 @@ object ExprParser { def make[A](term: Parser[A], table: List[List[Operator[A]]]): Parser[A] = table.foldLeft(term)(addPrecLevel) - /** Internal helper class for splitting an operator precedence level into the varios types. + /** Internal helper class for splitting an operator precedence level into the various types. */ private final case class Batch[A]( inn: List[BinP[A]], @@ -61,12 +66,10 @@ object ExprParser { private def addPrecLevel[A](p: Parser[A], level: List[Operator[A]]): Parser[A] = { - val idParser = Parser.pure((x: A) => x) - val batch = Batch(level) def orId(p: Parser[A => A]): Parser0[A => A] = - p.orElse(idParser) + p.orElse(Parser.pure((x: A) => x)) def parseTerm(prefix: UnP[A], postfix: UnP[A]): Parser[A] = (orId(prefix).with1 ~ p ~ orId(postfix)).map { case ((pre, t), post) => diff --git a/core/shared/src/main/scala/cats/parse/expr/Operator.scala b/core/shared/src/main/scala/cats/parse/expr/Operator.scala index f15861e1..5f816618 100644 --- a/core/shared/src/main/scala/cats/parse/expr/Operator.scala +++ b/core/shared/src/main/scala/cats/parse/expr/Operator.scala @@ -28,23 +28,23 @@ import cats.parse.Parser sealed abstract class Operator[A] object Operator { - /** Non-associative infix operator. Example: &&, || - */ + /** Non-associative infix operator. Example: ==, != + */ final case class InfixN[A](c: Parser[(A, A) => A]) extends Operator[A] /** Left-associative infix operator. Example: +, *, / - */ + */ final case class InfixL[A](c: Parser[(A, A) => A]) extends Operator[A] /** Right-associative infix operator. Example: assignment in C - */ + */ final case class InfixR[A](c: Parser[(A, A) => A]) extends Operator[A] /** Prefix operator like for instance unary negation - */ + */ final case class Prefix[A](c: Parser[A => A]) extends Operator[A] /** Postfix operator - */ + */ final case class Postfix[A](c: Parser[A => A]) extends Operator[A] } diff --git a/core/shared/src/test/scala/cats/parse/ExprTest.scala b/core/shared/src/test/scala/cats/parse/ExprTest.scala index d2f9a5be..15822e6d 100644 --- a/core/shared/src/test/scala/cats/parse/ExprTest.scala +++ b/core/shared/src/test/scala/cats/parse/ExprTest.scala @@ -23,11 +23,13 @@ package cats.parse import cats.parse.expr.ExprParser import cats.parse.expr.Operator +import org.scalacheck.Prop.forAll +import org.scalacheck.Gen -class ExprTest extends munit.FunSuite { +object ExprTest { - sealed trait Exp - final case class N(n: String) extends Exp + sealed abstract class Exp + final case class N(n: Int) extends Exp final case class Neg(a: Exp) extends Exp final case class Q(a: Exp) extends Exp final case class Minus(a: Exp, b: Exp) extends Exp @@ -52,46 +54,147 @@ class ExprTest extends munit.FunSuite { Operator.InfixL(token(Parser.char('-')).as(Minus.apply)) ), List( - Operator.InfixN(token(Parser.char('&')).as(And.apply)), + Operator.InfixN(token(Parser.char('&')).as(And.apply)) + ), + List( Operator.InfixR(token(Parser.char('=')).as(Eq.apply)) ) ) + def pLevel(e: Exp): Int = e match { + case N(_) => -1 + case Neg(_) | Q(_) => 0 + case Times(_, _) => 1 + case Plus(_, _) | Minus(_, _) => 2 + case And(_, _) => 3 + case Eq(_, _) => 4 + } + + /** Quick and dirty pretty printer than inserts few parenthesis + */ + def pp(e: Exp): String = { + + // Left-associative. Precedence level should be lower in right branch + def leftAssoc(level: Int, a: Exp, b: Exp, op: String): String = { + val leftStr = if (pLevel(a) <= level) pp(a) else s"(${pp(a)})" + val rightStr = if (pLevel(b) < level) pp(b) else s"(${pp(b)})" + s"$leftStr $op $rightStr" + } + e match { + case N(n) => n.toString + + case Neg(a) => + if (pLevel(a) < 0) s"-${pp(a)}" else s"-(${pp(a)})" + + case Q(a) => + if (pLevel(a) < 0) s"${pp(a)}?" else s"(${pp(a)})?" + + case Times(a, b) => leftAssoc(1, a, b, "*") + case Plus(a, b) => leftAssoc(2, a, b, "+") + case Minus(a, b) => leftAssoc(2, a, b, "-") + + // Non-associative. Precedence level should be lower in both branches + case And(a, b) => { + val leftStr = if (pLevel(a) < 3) pp(a) else s"(${pp(a)})" + val rightStr = if (pLevel(b) < 3) pp(b) else s"(${pp(b)})" + s"$leftStr & $rightStr" + } + + // Right-associative. Precedence level should be lower in left branch + case Eq(a, b) => { + val leftStr = if (pLevel(a) < 4) pp(a) else s"(${pp(a)})" + val rightStr = if (pLevel(b) <= 4) pp(b) else s"(${pp(b)})" + s"$leftStr = $rightStr" + } + } + } + + val genN: Gen[Exp] = Gen.choose(0, 100).map(N.apply) + + val genAnd: Gen[Exp] = genExp.flatMap(a => genExp.map(b => And(a, b))) + val genEq: Gen[Exp] = genExp.flatMap(a => genExp.map(b => Eq(a, b))) + val genPlus: Gen[Exp] = genExp.flatMap(a => genExp.map(b => Plus(a, b))) + val genMinus: Gen[Exp] = genExp.flatMap(a => genExp.map(b => Minus(a, b))) + val genTimes: Gen[Exp] = genExp.flatMap(a => genExp.map(b => Times(a, b))) + val genNeg: Gen[Exp] = genExp.map(a => Neg(a)) + val genQ: Gen[Exp] = genExp.map(a => Q(a)) + + def genExp: Gen[Exp] = Gen.sized(n => + if (n <= 1) genN + else + Gen.oneOf( + Gen.lzy(Gen.resize(n / 2, genAnd)), + Gen.lzy(Gen.resize(n / 2, genEq)), + Gen.lzy(Gen.resize(n / 2, genPlus)), + Gen.lzy(Gen.resize(n / 2, genMinus)), + Gen.lzy(Gen.resize(n / 2, genTimes)), + Gen.lzy(Gen.resize(n - 1, genNeg)), + Gen.lzy(Gen.resize(n - 1, genQ)) + ) + ) + +} + +class ExprTest extends munit.ScalaCheckSuite { + + import ExprTest._ + test("non-recursive") { - val term = token(Numbers.digits.map(N.apply)) + val term = token(Numbers.nonNegativeIntString.map(x => N(x.toInt))) val exprParser = ExprParser.make(term, table) - assertEquals(exprParser.parseAll("1"), Right(N("1"))) - assertEquals(exprParser.parseAll("-1"), Right(Neg(N("1")))) - assertEquals(exprParser.parseAll("-1?"), Right(Q(Neg(N("1"))))) - assertEquals(exprParser.parseAll("1 + 2"), Right(Plus(N("1"), N("2")))) - assertEquals(exprParser.parseAll("1 + -2"), Right(Plus(N("1"), Neg(N("2"))))) - assertEquals(exprParser.parseAll("1 + 2 - 3"), Right(Minus(Plus(N("1"), N("2")), N("3")))) - assertEquals(exprParser.parseAll("1 + 2 + 3 + 4"), Right(Plus(Plus(Plus(N("1"), N("2")), N("3")), N("4")))) - assertEquals(exprParser.parseAll("1+2*3"), Right(Plus(N("1"), Times(N("2"), N("3"))))) + assertEquals(exprParser.parseAll("1"), Right(N(1))) + assertEquals(exprParser.parseAll("-1"), Right(Neg(N(1)))) + assertEquals(exprParser.parseAll("-1?"), Right(Q(Neg(N(1))))) + assertEquals(exprParser.parseAll("1 + 2"), Right(Plus(N(1), N(2)))) + assertEquals(exprParser.parseAll("1 + -2"), Right(Plus(N(1), Neg(N(2))))) + assertEquals(exprParser.parseAll("1 + 2 - 3"), Right(Minus(Plus(N(1), N(2)), N(3)))) + assertEquals( + exprParser.parseAll("1 + 2 + 3 + 4"), + Right(Plus(Plus(Plus(N(1), N(2)), N(3)), N(4))) + ) + assertEquals(exprParser.parseAll("1+2*3"), Right(Plus(N(1), Times(N(2), N(3))))) assert(exprParser.parseAll("1 & 2 & 3").isLeft) - assertEquals(exprParser.parseAll("1 = 2"), Right(Eq(N("1"), N("2")))) - assertEquals(exprParser.parseAll("1 & 2"), Right(And(N("1"), N("2")))) - assertEquals(exprParser.parseAll("1 = 2 + 3"), Right(Eq(N("1"), Plus(N("2"), N("3"))))) - assertEquals(exprParser.parseAll("1 = 2 = 3"), Right(Eq(N("1"), Eq(N("2"), N("3"))))) + assertEquals(exprParser.parseAll("1 = 2"), Right(Eq(N(1), N(2)))) + assertEquals(exprParser.parseAll("1 & 2"), Right(And(N(1), N(2)))) + assertEquals(exprParser.parseAll("1 = 2 + 3"), Right(Eq(N(1), Plus(N(2), N(3))))) + assertEquals(exprParser.parseAll("1 = 2 = 3"), Right(Eq(N(1), Eq(N(2), N(3))))) + assertEquals(exprParser.parseAll("1 = 2 & 3"), Right(Eq(N(1), And(N(2), N(3))))) + assertEquals(exprParser.parseAll("1 & 2 = 3"), Right(Eq(And(N(1), N(2)), N(3)))) } test("recursive") { val exprParser = Parser.recursive[Exp] { expr => - val term = token(Numbers.digits.map(N.apply)) | expr.between( + val term = token(Numbers.nonNegativeIntString.map(n => N(n.toInt))) | expr.between( + token(Parser.char('(')), + token(Parser.char(')')) + ) + ExprParser.make(term, table) + } + + assertEquals(exprParser.parseAll("( (2) )"), Right(N(2))) + assertEquals(exprParser.parseAll("-(-1)"), Right(Neg(Neg(N(1))))) + assertEquals(exprParser.parseAll("1*( 2+3 )"), Right(Times(N(1), Plus(N(2), N(3))))) + assertEquals(exprParser.parseAll("(1 = 2) = 3?"), Right(Eq(Eq(N(1), N(2)), Q(N(3))))) + + } + + property("foo") { + + val exprParser = Parser.recursive[Exp] { expr => + val term = token(Numbers.nonNegativeIntString.map(x => N(x.toInt))) | expr.between( token(Parser.char('(')), token(Parser.char(')')) ) ExprParser.make(term, table) } - assertEquals(exprParser.parseAll("( (2) )"), Right(N("2"))) - assertEquals(exprParser.parseAll("-(-1)"), Right(Neg(Neg(N("1"))))) - assertEquals(exprParser.parseAll("1*( 2+3 )"), Right(Times(N("1"), Plus(N("2"), N("3"))))) - assertEquals(exprParser.parseAll("(1 = 2) = 3?"), Right(Eq(Eq(N("1"), N("2")), Q(N("3"))))) + forAll(genExp) { (e: Exp) => + assertEquals(exprParser.parseAll(pp(e)), Right(e)) + } } From 0ef7837fed9fc41cf547a0d677a387c5a37571c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kj=C3=A6r=20Larsen?= Date: Tue, 27 Sep 2022 20:52:50 +0200 Subject: [PATCH 6/6] Added microbenchmark --- .../cats/parse/bench/ExprBenchmarks.scala | 126 ++++++++++++++++++ .../src/test/scala/cats/parse/ExprTest.scala | 18 +-- 2 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 bench/src/main/scala/cats/parse/bench/ExprBenchmarks.scala diff --git a/bench/src/main/scala/cats/parse/bench/ExprBenchmarks.scala b/bench/src/main/scala/cats/parse/bench/ExprBenchmarks.scala new file mode 100644 index 00000000..78cf1f67 --- /dev/null +++ b/bench/src/main/scala/cats/parse/bench/ExprBenchmarks.scala @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2021 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package cats.parse.bench + +import java.util.concurrent.TimeUnit +import org.openjdk.jmh.annotations._ + +import cats.parse.expr.Operator +import cats.parse.expr.ExprParser +import cats.parse.{Parser, Parser0} +import cats.parse.Numbers + +@State(Scope.Benchmark) +@BenchmarkMode(Array(Mode.AverageTime)) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +private[parse] class ExprBenchmarks { + + sealed abstract class Exp + case class N(n: Int) extends Exp + case class Neg(a: Exp) extends Exp + case class Minus(a: Exp, b: Exp) extends Exp + case class Plus(a: Exp, b: Exp) extends Exp + case class Times(a: Exp, b: Exp) extends Exp + case class Div(a: Exp, b: Exp) extends Exp + case class Eq(a: Exp, b: Exp) extends Exp + + def token[A](p: Parser[A]): Parser[A] = + p.surroundedBy(Parser.char(' ').rep0) + + val base = token(Numbers.nonNegativeIntString.map(x => N(x.toInt))) + + val table: List[List[Operator[Exp]]] = List( + List( + Operator.Prefix(token(Parser.char('-')).as(Neg.apply)) + ), + List( + Operator.InfixL(token(Parser.char('*')).as(Times.apply)), + Operator.InfixL(token(Parser.char('/')).as(Div.apply)) + ), + List( + Operator.InfixL(token(Parser.char('+')).as(Plus.apply)), + Operator.InfixL(token(Parser.char('-')).as(Minus.apply)) + ), + List( + Operator.InfixN(token(Parser.string("==")).as(Eq.apply)) + ) + ) + + val exprParser = Parser.recursive[Exp] { expr => + val term = base | expr.between( + token(Parser.char('(')), + token(Parser.char(')')) + ) + ExprParser.make(term, table) + } + + /** Manually written parser in the same style as the generated one. + */ + val manualExprParser = Parser.recursive[Exp] { expr => + def factor: Parser[Exp] = + (token(Parser.char('-')) *> Parser.defer(factor)).map(Neg.apply) | base | + expr.between( + token(Parser.char('(')), + token(Parser.char(')')) + ) + + val plus: Parser[(Exp, Exp) => Exp] = token(Parser.char('+').as(Plus.apply)) + val times: Parser[(Exp, Exp) => Exp] = token(Parser.char('*').as(Times.apply)) + val minus: Parser[(Exp, Exp) => Exp] = token(Parser.char('-').as(Minus.apply)) + val div: Parser[(Exp, Exp) => Exp] = token(Parser.char('/').as(Div.apply)) + val eq: Parser[Unit] = token(Parser.string("==")) + + def term1: Parser0[Exp => Exp] = + ((times | div) ~ factor ~ Parser.defer0(term1)).map { case ((op, b), f) => + (a: Exp) => f(op(a, b)) + } | Parser.pure((x: Exp) => x) + + def term: Parser[Exp] = (factor ~ term1).map { case (a, f) => f(a) } + + def arith1: Parser0[Exp => Exp] = + ((plus | minus) ~ term ~ Parser.defer0(arith1)).map { case ((op, b), f) => + (a: Exp) => f(op(a, b)) + } | Parser.pure((x: Exp) => x) + + def arith: Parser[Exp] = (term ~ arith1).map { case (a, f) => f(a) } + + arith ~ (eq *> arith).? map { + case (e1, None) => e1 + case (e1, Some(e2)) => Eq(e1, e2) + } + + } + + @Benchmark + def manual: Exp = + manualExprParser.parseAll("5 * ((1 + 2) * 3 / -4) == 5 + 2") match { + case Right(e) => e + case Left(e) => sys.error(e.toString) + } + + def tabular: Exp = + exprParser.parseAll("5 * ((1 + 2) * 3 / -4) == 5 + 2") match { + case Right(e) => e + case Left(e) => sys.error(e.toString) + } + +} diff --git a/core/shared/src/test/scala/cats/parse/ExprTest.scala b/core/shared/src/test/scala/cats/parse/ExprTest.scala index 15822e6d..2ca3d2ef 100644 --- a/core/shared/src/test/scala/cats/parse/ExprTest.scala +++ b/core/shared/src/test/scala/cats/parse/ExprTest.scala @@ -29,14 +29,14 @@ import org.scalacheck.Gen object ExprTest { sealed abstract class Exp - final case class N(n: Int) extends Exp - final case class Neg(a: Exp) extends Exp - final case class Q(a: Exp) extends Exp - final case class Minus(a: Exp, b: Exp) extends Exp - final case class Plus(a: Exp, b: Exp) extends Exp - final case class Times(a: Exp, b: Exp) extends Exp - final case class And(a: Exp, b: Exp) extends Exp - final case class Eq(a: Exp, b: Exp) extends Exp + case class N(n: Int) extends Exp + case class Neg(a: Exp) extends Exp + case class Q(a: Exp) extends Exp + case class Minus(a: Exp, b: Exp) extends Exp + case class Plus(a: Exp, b: Exp) extends Exp + case class Times(a: Exp, b: Exp) extends Exp + case class And(a: Exp, b: Exp) extends Exp + case class Eq(a: Exp, b: Exp) extends Exp def token[A](p: Parser[A]): Parser[A] = p.surroundedBy(Rfc5234.sp.rep0) @@ -182,7 +182,7 @@ class ExprTest extends munit.ScalaCheckSuite { } - property("foo") { + property("parsing pretty printed expressions") { val exprParser = Parser.recursive[Exp] { expr => val term = token(Numbers.nonNegativeIntString.map(x => N(x.toInt))) | expr.between(