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/main/scala/cats/parse/expr/ExprParser.scala b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala new file mode 100644 index 00000000..bc5f45f1 --- /dev/null +++ b/core/shared/src/main/scala/cats/parse/expr/ExprParser.scala @@ -0,0 +1,117 @@ +/* + * 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} + +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 + * 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 various types. + */ + 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) + + 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(level) + + def orId(p: Parser[A => A]): Parser0[A => A] = + 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) => + post(pre(t)) + } + + 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]): 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]): 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 + */ + val term_ = parseTerm(Parser.oneOf(batch.pre), Parser.oneOf(batch.post)) + + /** Then try the operators in the precedence level + */ + (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 new file mode 100644 index 00000000..5f816618 --- /dev/null +++ b/core/shared/src/main/scala/cats/parse/expr/Operator.scala @@ -0,0 +1,50 @@ +/* + * 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 + +/** 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] +} 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..2ca3d2ef --- /dev/null +++ b/core/shared/src/test/scala/cats/parse/ExprTest.scala @@ -0,0 +1,201 @@ +/* + * 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 +import cats.parse.expr.Operator +import org.scalacheck.Prop.forAll +import org.scalacheck.Gen + +object ExprTest { + + sealed abstract class 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) + + 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(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.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))))) + 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 & 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.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("parsing pretty printed expressions") { + + 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) + } + + forAll(genExp) { (e: Exp) => + assertEquals(exprParser.parseAll(pp(e)), Right(e)) + } + + } + +}