diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a843e56..412148e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Cron4s Change Log +## 0.8.0 + +Breaking changes: + +* In previous versions, Cron expression parsing was using [Scala Parser Combinators](https://github.com/scala/scala-parser-combinators). + Parsing is now achieved using [Atto library](https://tpolecat.github.io/atto/) by default on all targets except Native. + Both parsers should behave the same way and the API didn't change. They will be kept in sync for the time being. + In case you notice any change in behavior, please open an issue with your input. + You can always fall back to the Parser Combinator version by adding `cron4s-parserc` as a dependency of your project and + use `Cron.withParser(cron4s.parsing.Parser)` instead of `Cron` instance. + ## 0.6.1 Bug fixes diff --git a/bench/src/main/scala/cron4s/atto/package.scala b/bench/src/main/scala/cron4s/atto/package.scala deleted file mode 100644 index ec2f8094..00000000 --- a/bench/src/main/scala/cron4s/atto/package.scala +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright 2017 Antonio Alonso Dominguez - * - * 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 cron4s - -import _root_.atto._ -import Atto._ -import cats.implicits._ -import cron4s.expr._ - -package object atto { - import CronField._ - import CronUnit._ - - private def oneOrTwoDigitsPositiveInt: Parser[Int] = { - - val getDigits = for { - d1 <- digit - d2 <- opt(digit) - } yield d2.fold(s"$d1")(x => s"$d1$x") - - getDigits.flatMap(s => - try - ok(s.toInt) - catch { - // scala-js can't parse non-alpha digits so we just fail in that case. - case _: java.lang.NumberFormatException => - err[Int]("https://github.com/scala-js/scala-js/issues/2935") - } - ) - } namedOpaque "oneOrTwoDigitsPositiveInt" - - private val sexagesimal: Parser[Int] = oneOrTwoDigitsPositiveInt.filter(x => x >= 0 && x < 60) - - private val literal: Parser[String] = takeWhile1(x => x != ' ' && x != '-') - - private val hyphen: Parser[Char] = elem(_ == '-', "hyphen") - private val comma: Parser[Char] = elem(_ == ',', "comma") - private val slash: Parser[Char] = elem(_ == '/', "slash") - private val asterisk: Parser[Char] = elem(_ == '*', "asterisk") - private val questionMark: Parser[Char] = elem(_ == '?', "question-mark") - private val blank: Parser[Char] = elem(_ == ' ', "blank") - - // ---------------------------------------- - // Individual Expression Atoms - // ---------------------------------------- - - // Seconds - - val seconds: Parser[ConstNode[Second]] = - sexagesimal.map(ConstNode[Second](_)) - - // Minutes - - val minutes: Parser[ConstNode[Minute]] = - sexagesimal.map(ConstNode[Minute](_)) - - // Hours - - val hours: Parser[ConstNode[Hour]] = - oneOrTwoDigitsPositiveInt.filter(x => (x >= 0) && (x < 24)).map(ConstNode[Hour](_)) - - // Days Of Month - - val daysOfMonth: Parser[ConstNode[DayOfMonth]] = - oneOrTwoDigitsPositiveInt.filter(x => (x >= 1) && (x <= 31)).map(ConstNode[DayOfMonth](_)) - - // Months - - private[this] val numericMonths = - oneOrTwoDigitsPositiveInt.filter(x => (x >= 0) && (x <= 12)).map(ConstNode[Month](_)) - - private[this] val textualMonths = - literal.filter(Months.textValues.contains).map { value => - val index = Months.textValues.indexOf(value) - ConstNode[Month](index + 1, Some(value)) - } - - val months: Parser[ConstNode[Month]] = - textualMonths | numericMonths - - // Days Of Week - - private[this] val numericDaysOfWeek = - oneOrTwoDigitsPositiveInt.filter(x => (x >= 0) && (x <= 6)).map(ConstNode[DayOfWeek](_)) - - private[this] val textualDaysOfWeek = - literal.filter(DaysOfWeek.textValues.contains).map { value => - val index = DaysOfWeek.textValues.indexOf(value) - ConstNode[DayOfWeek](index, Some(value)) - } - - val daysOfWeek: Parser[ConstNode[DayOfWeek]] = - textualDaysOfWeek | numericDaysOfWeek - - // ---------------------------------------- - // Field-Based Expression Atoms - // ---------------------------------------- - - def each[F <: CronField](implicit unit: CronUnit[F]): Parser[EachNode[F]] = - asterisk.as(EachNode[F]) - - def any[F <: CronField](implicit unit: CronUnit[F]): Parser[AnyNode[F]] = - questionMark.as(AnyNode[F]) - - def between[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[BetweenNode[F]] = - for { - min <- base <~ hyphen - max <- base - } yield BetweenNode[F](min, max) - - def several[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[SeveralNode[F]] = { - def compose(b: => Parser[EnumerableNode[F]]) = - sepBy(b, comma) - .collect { - case first :: second :: tail => SeveralNode(first, second, tail: _*) - } - - compose(between(base).map(between2Enumerable) | base.map(const2Enumerable)) - } - - def every[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[EveryNode[F]] = { - def compose(b: => Parser[DivisibleNode[F]]) = - ((b <~ slash) ~ oneOrTwoDigitsPositiveInt.filter(_ > 0)).map { - case (exp, freq) => EveryNode[F](exp, freq) - } - - compose( - several(base).map(several2Divisible) | - between(base).map(between2Divisible) | - each[F].map(each2Divisible) - ) - } - - // ---------------------------------------- - // AST Parsing & Building - // ---------------------------------------- - - def field[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[FieldNode[F]] = - every(base).map(every2Field) | - several(base).map(several2Field) | - between(base).map(between2Field) | - base.map(const2Field) | - each[F].map(each2Field) - - def fieldWithAny[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[FieldNodeWithAny[F]] = - every(base).map(every2FieldWithAny) | - several(base).map(several2FieldWithAny) | - between(base).map(between2FieldWithAny) | - base.map(const2FieldWithAny) | - each[F].map(each2FieldWithAny) | - any[F].map(any2FieldWithAny) - - val cron: Parser[CronExpr] = for { - sec <- field(seconds) <~ blank - min <- field(minutes) <~ blank - hour <- field(hours) <~ blank - day <- fieldWithAny(daysOfMonth) <~ blank - month <- field(months) <~ blank - weekDay <- fieldWithAny(daysOfWeek) - } yield CronExpr(sec, min, hour, day, month, weekDay) - - def parse(e: String): Either[Error, CronExpr] = - (phrase(cron).parseOnly(e): @unchecked) match { - case ParseResult.Done(_, result) => Right(result) - case ParseResult.Fail("", _, _) => Left(ExprTooShort) - case ParseResult.Fail(rest, _, msg) => - val position = e.length() - rest.length() + 1 - Left(ParseFailed(msg, position, Some(rest))) - } -} diff --git a/bench/src/main/scala/cron4s/bench/ParserBenchmark.scala b/bench/src/main/scala/cron4s/bench/ParserBenchmark.scala index 07ebb428..642401eb 100644 --- a/bench/src/main/scala/cron4s/bench/ParserBenchmark.scala +++ b/bench/src/main/scala/cron4s/bench/ParserBenchmark.scala @@ -17,11 +17,11 @@ package cron4s.bench import java.util.concurrent.TimeUnit - import cron4s._ - import org.openjdk.jmh.annotations._ +import scala.annotation.nowarn + @State(Scope.Thread) @BenchmarkMode(Array(Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MICROSECONDS) @@ -37,9 +37,10 @@ class ParserBenchmark { ) var cronString: String = _ + @nowarn("cat=deprecation") @Benchmark - def parserCombinators() = parsing.parse(cronString) + def parserCombinators() = parsing.Parser.parse(cronString) @Benchmark - def attoParser() = atto.parse(cronString) + def attoParser() = atto.Parser.parse(cronString) } diff --git a/build.sbt b/build.sbt index 73c7934f..c7aeac17 100644 --- a/build.sbt +++ b/build.sbt @@ -279,8 +279,17 @@ lazy val cron4sJS = (project in file(".js")) .settings(commonJsSettings: _*) .settings(noPublishSettings) .enablePlugins(ScalaJSPlugin) - .aggregate(core.js, momentjs, circe.js, decline.js, testkit.js, tests.js) - .dependsOn(core.js, momentjs, circe.js, decline.js, testkit.js, tests.js % Test) + .aggregate(core.js, parser.js, atto.js, momentjs, circe.js, decline.js, testkit.js, tests.js) + .dependsOn( + core.js, + parser.js, + atto.js, + momentjs, + circe.js, + decline.js, + testkit.js, + tests.js % Test + ) lazy val cron4sJVM = (project in file(".jvm")) .settings( @@ -291,8 +300,26 @@ lazy val cron4sJVM = (project in file(".jvm")) .settings(commonJvmSettings) .settings(consoleSettings) .settings(noPublishSettings) - .aggregate(core.jvm, joda, doobie, circe.jvm, decline.jvm, testkit.jvm, tests.jvm) - .dependsOn(core.jvm, joda, doobie, circe.jvm, decline.jvm, testkit.jvm, tests.jvm % Test) + .aggregate( + parser.jvm, + core.jvm, + atto.jvm, + joda, + doobie, + circe.jvm, + decline.jvm, + testkit.jvm, + tests.jvm + ) + .dependsOn( + core.jvm, + joda, + doobie, + circe.jvm, + decline.jvm, + testkit.jvm, + tests.jvm % Test + ) lazy val cron4sNative = (project in file(".native")) .settings( @@ -301,8 +328,20 @@ lazy val cron4sNative = (project in file(".native")) ) .settings(commonSettings) .settings(noPublishSettings) - .aggregate(core.native, circe.native, decline.native, testkit.native, tests.native) - .dependsOn(core.native, circe.native, decline.native, testkit.native, tests.native % Test) + .aggregate( + core.native, + circe.native, + decline.native, + testkit.native, + tests.native + ) + .dependsOn( + core.native, + circe.native, + decline.native, + testkit.native, + tests.native % Test + ) lazy val docs = project .enablePlugins(MicrositesPlugin, ScalaUnidocPlugin, GhpagesPlugin) @@ -312,12 +351,29 @@ lazy val docs = project .settings(commonSettings) .settings(noPublishSettings) .settings(docSettings) - .dependsOn(core.jvm, joda, doobie, circe.jvm, decline.jvm, testkit.jvm) + .dependsOn(core.jvm, parserc.jvm, joda, doobie, circe.jvm, decline.jvm, testkit.jvm) // ================================================================================= // Main modules // ================================================================================= +lazy val parser = (crossProject(JSPlatform, JVMPlatform, NativePlatform) in file("modules/parser")) + .enablePlugins(AutomateHeaderPlugin, ScalafmtPlugin, MimaPlugin) + .settings( + name := "parser", + moduleName := "cron4s-parser" + ) + .settings(commonSettings) + .settings(publishSettings) + .settings(Dependencies.core) + .jsSettings(commonJsSettings) + .jsSettings(Dependencies.coreJS) + .jvmSettings(commonJvmSettings) + .jvmSettings(consoleSettings) + .jvmSettings(Dependencies.coreJVM) + .jvmSettings(mimaSettings("parser")) + .nativeSettings(Dependencies.coreNative) + lazy val core = (crossProject(JSPlatform, JVMPlatform, NativePlatform) in file("modules/core")) .enablePlugins(AutomateHeaderPlugin, ScalafmtPlugin, MimaPlugin) .settings( @@ -329,11 +385,47 @@ lazy val core = (crossProject(JSPlatform, JVMPlatform, NativePlatform) in file(" .settings(Dependencies.core) .jsSettings(commonJsSettings) .jsSettings(Dependencies.coreJS) + .jsConfigure(_.dependsOn(atto.js)) .jvmSettings(commonJvmSettings) .jvmSettings(consoleSettings) .jvmSettings(Dependencies.coreJVM) .jvmSettings(mimaSettings("core")) + .jvmConfigure(_.dependsOn(atto.jvm)) .nativeSettings(Dependencies.coreNative) + .nativeConfigure(_.dependsOn(parserc.native)) + .dependsOn(parser) + +lazy val parserc = + (crossProject(JSPlatform, JVMPlatform, NativePlatform) in file("modules/parserc")) + .enablePlugins(AutomateHeaderPlugin, ScalafmtPlugin, MimaPlugin) + .settings( + name := "parserc", + moduleName := "cron4s-parserc" + ) + .settings(commonSettings) + .settings(publishSettings) + .settings(Dependencies.parserc) + .jsSettings(commonJsSettings) + .jsSettings(Dependencies.coreJS) + .jvmSettings(commonJvmSettings) + .jvmSettings(Dependencies.coreJVM) + .nativeSettings(Dependencies.coreNative) + .dependsOn(parser) + +lazy val atto = (crossProject(JSPlatform, JVMPlatform) in file("modules/atto")) + .enablePlugins(AutomateHeaderPlugin, ScalafmtPlugin, MimaPlugin) + .settings( + name := "atto", + moduleName := "cron4s-atto" + ) + .settings(commonSettings) + .settings(publishSettings) + .settings(Dependencies.atto) + .jsSettings(commonJsSettings) + .jsSettings(Dependencies.coreJS) + .jvmSettings(commonJvmSettings) + .jvmSettings(Dependencies.coreJVM) + .dependsOn(parser) lazy val testkit = (crossProject(JSPlatform, JVMPlatform, NativePlatform) in file("modules/testkit")) @@ -351,7 +443,7 @@ lazy val testkit = .jvmSettings(Dependencies.coreJVM) .jvmSettings(mimaSettings("testkit")) .nativeSettings(Dependencies.coreNative) - .dependsOn(core) + .dependsOn(core, parserc) lazy val tests = (crossProject(JSPlatform, JVMPlatform, NativePlatform) in file("tests")) .enablePlugins(AutomateHeaderPlugin, ScalafmtPlugin) @@ -376,9 +468,8 @@ lazy val bench = (project in file("bench")) .settings(commonSettings) .settings(noPublishSettings) .settings(commonJvmSettings) - .settings(Dependencies.bench) .enablePlugins(JmhPlugin) - .dependsOn(core.jvm) + .dependsOn(core.jvm, parserc.jvm) // ================================================================================= // DateTime library extensions @@ -432,7 +523,7 @@ lazy val circe = .jvmSettings(Dependencies.coreJVM) .jsSettings(commonJsSettings) .nativeSettings(Dependencies.coreNative) - .dependsOn(core, testkit % Test) + .dependsOn(core, parserc, testkit % Test) lazy val decline = (crossProject(JSPlatform, JVMPlatform, NativePlatform).crossType(CrossType.Pure) in file( @@ -452,7 +543,7 @@ lazy val decline = .jvmSettings(Dependencies.coreJVM) .jsSettings(commonJsSettings) .nativeSettings(Dependencies.coreNative) - .dependsOn(core, testkit % Test) + .dependsOn(core, parserc, testkit % Test) lazy val doobie = (project in file("modules/doobie")) .enablePlugins(AutomateHeaderPlugin, ScalafmtPlugin, MimaPlugin) @@ -467,7 +558,7 @@ lazy val doobie = (project in file("modules/doobie")) .settings(mimaSettings("doobie")) .settings(Dependencies.doobie) .settings(Dependencies.coreJVM) - .dependsOn(core.jvm, testkit.jvm % Test) + .dependsOn(core.jvm, parserc.jvm, testkit.jvm % Test) // ================================================================================= // Utility command aliases diff --git a/docs/src/main/mdoc/userguide/ast.md b/docs/src/main/mdoc/userguide/ast.md index e54cd703..17b2d5a3 100644 --- a/docs/src/main/mdoc/userguide/ast.md +++ b/docs/src/main/mdoc/userguide/ast.md @@ -162,8 +162,8 @@ assert(minutesRange.implies(fixedMinute) == fixedMinute.impliedBy(minutesRange)) It's important to notice that when using either the `implies` or `impliedBy` operation, if the two nodes are not parameterized by the same field type, the code won't compile: - -```scala mdoc:fail + +```scala //mdoc:fail disabled because it breaks mdoc minutesRange.implies(eachSecond) ``` diff --git a/docs/src/main/mdoc/userguide/index.md b/docs/src/main/mdoc/userguide/index.md index b34c3be9..680f20c1 100644 --- a/docs/src/main/mdoc/userguide/index.md +++ b/docs/src/main/mdoc/userguide/index.md @@ -102,3 +102,26 @@ a `NonEmptyList` with all the validation errors that the expression had. To demo ```scala mdoc failsValidation.swap.foreach { err => err.asInstanceOf[InvalidCron].reason.toList.mkString("\n") } ``` + +## Using a specific parser implementation + +Cron4s has two parser implementation. Since 0.8.0, it uses the [Atto](https://tpolecat.github.io/atto/) implementation +by default for JS and JVM targets and [Scala Parser Combinators](https://github.com/scala/scala-parser-combinators) +for Native. + +You can choose to use the Parser Combinators version by importing cron4s-parserc + +```scala +libraryDependencies += "com.github.alonsodomin.cron4s" %%% "cron4s-parserc" % "{{site.cron4sVersion}}" +``` + +Then you need to specify the parser you want + +```scala mdoc:invisible +import scala.annotation.nowarn +``` + +```scala mdoc:silent +@nowarn("cat=deprecation") +val parser = Cron.withParser(cron4s.parsing.Parser) +``` \ No newline at end of file diff --git a/modules/atto/shared/src/main/scala/cron4s/atto/Parser.scala b/modules/atto/shared/src/main/scala/cron4s/atto/Parser.scala new file mode 100644 index 00000000..8b7ea94b --- /dev/null +++ b/modules/atto/shared/src/main/scala/cron4s/atto/Parser.scala @@ -0,0 +1,174 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s.atto + +import _root_.atto.{Parser => AttoParser, _} +import atto.Atto._ +import cats.implicits._ + +object Parser extends cron4s.parser.Parser { + + import cron4s.parser._ + import cron4s.parser.Node._ + + private def oneOrTwoDigitsPositiveInt: AttoParser[Int] = { + + val getDigits = for { + d1 <- digit + d2 <- opt(digit) + } yield d2.fold(s"$d1")(x => s"$d1$x") + + getDigits.flatMap(s => + try + ok(s.toInt) + catch { + // scala-js can't parse non-alpha digits so we just fail in that case. + case _: java.lang.NumberFormatException => + err[Int]("https://github.com/scala-js/scala-js/issues/2935") + } + ) + } namedOpaque "oneOrTwoDigitsPositiveInt" + + private val sexagesimal: AttoParser[Int] = oneOrTwoDigitsPositiveInt.filter(x => x >= 0 && x < 60) + + private val literal: AttoParser[String] = takeWhile1(x => x != ' ' && x != '-') + + private val hyphen: AttoParser[Char] = elem(_ == '-', "hyphen") + private val comma: AttoParser[Char] = elem(_ == ',', "comma") + private val slash: AttoParser[Char] = elem(_ == '/', "slash") + private val asterisk: AttoParser[Char] = elem(_ == '*', "asterisk") + private val questionMark: AttoParser[Char] = elem(_ == '?', "question-mark") + private val blank: AttoParser[Char] = elem(_ == ' ', "blank") + + // ---------------------------------------- + // Individual Expression Atoms + // ---------------------------------------- + + // Seconds + private val seconds: AttoParser[ConstNode] = sexagesimal.map(ConstNode(_)) + + // Minutes + + private val minutes: AttoParser[ConstNode] = sexagesimal.map(ConstNode(_)) + + // Hours + + private val hours: AttoParser[ConstNode] = + oneOrTwoDigitsPositiveInt.filter(x => (x >= 0) && (x < 24)).map(ConstNode(_)) + + // Days Of Month + + private val daysOfMonth: AttoParser[ConstNode] = + oneOrTwoDigitsPositiveInt.filter(x => (x >= 1) && (x <= 31)).map(ConstNode(_)) + + // Months + + private[this] val numericMonths: AttoParser[ConstNode] = + oneOrTwoDigitsPositiveInt.filter(x => (x >= 0) && (x <= 12)).map(ConstNode(_)) + + private[this] val textualMonths: AttoParser[ConstNode] = + literal.filter(Months.textValues.contains).map { value => + val index = Months.textValues.indexOf(value) + ConstNode(index + 1, Some(value)) + } + + private val months: AttoParser[ConstNode] = + textualMonths | numericMonths + + // Days Of Week + + private[this] val numericDaysOfWeek: AttoParser[ConstNode] = + oneOrTwoDigitsPositiveInt.filter(x => (x >= 0) && (x <= 6)).map(ConstNode(_)) + + private[this] val textualDaysOfWeek: AttoParser[ConstNode] = + literal.filter(DaysOfWeek.textValues.contains).map { value => + val index = DaysOfWeek.textValues.indexOf(value) + ConstNode(index, Some(value)) + } + + private val daysOfWeek: AttoParser[ConstNode] = + textualDaysOfWeek | numericDaysOfWeek + + // ---------------------------------------- + // Field-Based Expression Atoms + // ---------------------------------------- + + private def each: AttoParser[EachNode.type] = asterisk.as(EachNode) + + private def any: AttoParser[AnyNode.type] = questionMark.as(AnyNode) + + private def between(base: AttoParser[ConstNode]): AttoParser[BetweenNode] = + for { + min <- base <~ hyphen + max <- base + } yield BetweenNode(min, max) + + private def several(base: AttoParser[ConstNode]): AttoParser[SeveralNode] = { + def compose(b: => AttoParser[EnumerableNode]) = + sepBy(b, comma) + .collect { + case first :: second :: tail => SeveralNode(first, second, tail: _*) + } + + compose(between(base) | base) + } + + private def every(base: AttoParser[ConstNode]): AttoParser[EveryNode] = { + def compose(b: => AttoParser[DivisibleNode]) = + ((b <~ slash) ~ oneOrTwoDigitsPositiveInt.filter(_ > 0)).map { + case (exp, freq) => EveryNode(exp, freq) + } + compose(several(base) | between(base) | each) + } + + // ---------------------------------------- + // AST Parsing & Building + // ---------------------------------------- + + private def field(base: AttoParser[ConstNode]): AttoParser[NodeWithoutAny] = + every(base) | + several(base) | + between(base) | + base | + each + + private def fieldWithAny(base: AttoParser[ConstNode]): AttoParser[Node] = + every(base) | + several(base) | + between(base) | + base | + each | + any + + private val cron: AttoParser[CronExpr] = for { + sec <- field(seconds) <~ blank + min <- field(minutes) <~ blank + hour <- field(hours) <~ blank + day <- fieldWithAny(daysOfMonth) <~ blank + month <- field(months) <~ blank + weekDay <- fieldWithAny(daysOfWeek) + } yield CronExpr(sec, min, hour, day, month, weekDay) + + def parse(e: String): Either[Error, CronExpr] = + (phrase(cron).parseOnly(e): @unchecked) match { + case ParseResult.Done(_, result) => Right(result) + case ParseResult.Fail("", _, _) => Left(ExprTooShort) + case ParseResult.Fail(rest, _, msg) => + val position = e.length() - rest.length() + 1 + Left(ParseFailed(msg, position, Some(rest))) + } +} diff --git a/modules/core/js/src/main/scala/cron4s/Cron.scala b/modules/core/js/src/main/scala/cron4s/Cron.scala new file mode 100644 index 00000000..2f267f54 --- /dev/null +++ b/modules/core/js/src/main/scala/cron4s/Cron.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s + +import cron4s.parser.Parser + +import scala.scalajs.js.annotation.JSExportTopLevel + +/** + * The entry point for parsing cron expressions + * + * @author Antonio Alonso Dominguez + */ +@JSExportTopLevel("Cron") +object Cron extends CronImpl(atto.Parser) { + + def withParser(parser: Parser): CronImpl = new CronImpl(parser) + +} diff --git a/modules/core/jvm/src/main/scala/cron4s/Cron.scala b/modules/core/jvm/src/main/scala/cron4s/Cron.scala new file mode 100644 index 00000000..2f267f54 --- /dev/null +++ b/modules/core/jvm/src/main/scala/cron4s/Cron.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s + +import cron4s.parser.Parser + +import scala.scalajs.js.annotation.JSExportTopLevel + +/** + * The entry point for parsing cron expressions + * + * @author Antonio Alonso Dominguez + */ +@JSExportTopLevel("Cron") +object Cron extends CronImpl(atto.Parser) { + + def withParser(parser: Parser): CronImpl = new CronImpl(parser) + +} diff --git a/modules/core/native/src/main/scala/cron4s/Cron.scala b/modules/core/native/src/main/scala/cron4s/Cron.scala new file mode 100644 index 00000000..190c845f --- /dev/null +++ b/modules/core/native/src/main/scala/cron4s/Cron.scala @@ -0,0 +1,35 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s + +import cron4s.parser.Parser + +import scala.annotation.nowarn +import scala.scalajs.js.annotation.JSExportTopLevel + +/** + * The entry point for parsing cron expressions + * + * @author Antonio Alonso Dominguez + */ +@JSExportTopLevel("Cron") +@nowarn("cat=deprecation") +object Cron extends CronImpl(parsing.Parser) { + + def withParser(parser: Parser): CronImpl = new CronImpl(parser) + +} diff --git a/modules/core/shared/src/main/scala/cron4s/Cron.scala b/modules/core/shared/src/main/scala/cron4s/CronImpl.scala similarity index 90% rename from modules/core/shared/src/main/scala/cron4s/Cron.scala rename to modules/core/shared/src/main/scala/cron4s/CronImpl.scala index b1d70e5a..7b8c05b7 100644 --- a/modules/core/shared/src/main/scala/cron4s/Cron.scala +++ b/modules/core/shared/src/main/scala/cron4s/CronImpl.scala @@ -16,16 +16,11 @@ package cron4s -import scala.scalajs.js.annotation.JSExportTopLevel +import cron4s.parser.Parser + import scala.util.Try -/** - * The entry point for parsing cron expressions - * - * @author Antonio Alonso Dominguez - */ -@JSExportTopLevel("Cron") -object Cron { +private[cron4s] class CronImpl(parser: Parser) { /** * Parses the given cron expression into a cron AST using Either as return type. This is a short-hand for @@ -47,7 +42,7 @@ object Cron { */ @inline def parse(e: String): Either[Error, CronExpr] = - parsing.parse(e).flatMap(validation.validateCron) + ParserAdapter.adapt(parser)(e).flatMap(validation.validateCron) /** * Parses the given cron expression into a cron AST using Try as return type diff --git a/modules/core/shared/src/main/scala/cron4s/ParserAdapter.scala b/modules/core/shared/src/main/scala/cron4s/ParserAdapter.scala new file mode 100644 index 00000000..01eedf80 --- /dev/null +++ b/modules/core/shared/src/main/scala/cron4s/ParserAdapter.scala @@ -0,0 +1,109 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s + +private[cron4s] object ParserAdapter { + + import cron4s.parser._ + import cron4s.parser.Node._ + + def adapt(parser: Parser)(input: String): Either[cron4s.Error, cron4s.CronExpr] = + parser + .parse(input) + .fold(e => Left(mapError(e)), v => Right(mapExpr(v))) + + private def mapError(error: cron4s.parser.Error): cron4s.Error = + error match { + case ExprTooShort => cron4s.ExprTooShort + case e: ParseFailed => + cron4s.ParseFailed(expected = e.expected, position = e.position, found = e.found) + } + + private def mapExpr(expr: CronExpr): cron4s.CronExpr = { + cron4s.expr.CronExpr( + seconds = mapNode[CronField.Second](expr.seconds), + minutes = mapNode[CronField.Minute](expr.minutes), + hours = mapNode[CronField.Hour](expr.hours), + daysOfMonth = mapNodeWithAny[CronField.DayOfMonth](expr.daysOfMonth), + months = mapNode[CronField.Month](expr.months), + daysOfWeek = mapNodeWithAny[CronField.DayOfWeek](expr.daysOfWeek) + ) + } + + private def mapNode[F <: cron4s.CronField]( + node: NodeWithoutAny + )(implicit unit: cron4s.CronUnit[F]): cron4s.expr.FieldNode[F] = + node match { + case Node.EachNode => cron4s.expr.EachNode[F] + case n: Node.ConstNode => mapConst[F](n) + case n: Node.BetweenNode => mapBetweenNode[F](n) + case n: Node.SeveralNode => mapSeveral[F](n) + case n: Node.EveryNode => cron4s.expr.EveryNode[F](mapDivisible(n.base), n.freq) + } + + private def mapNodeWithAny[F <: cron4s.CronField]( + node: Node + )(implicit unit: cron4s.CronUnit[F]): cron4s.expr.FieldNodeWithAny[F] = + node match { + case Node.EachNode => cron4s.expr.EachNode[F] + case Node.AnyNode => cron4s.expr.AnyNode[F] + case n: Node.ConstNode => mapConst[F](n) + case n: Node.BetweenNode => mapBetweenNode[F](n) + case n: Node.SeveralNode => mapSeveral[F](n) + case n: Node.EveryNode => cron4s.expr.EveryNode[F](mapDivisible(n.base), n.freq) + } + + private def mapDivisible[F <: CronField](divisible: DivisibleNode)(implicit + unit: cron4s.CronUnit[F] + ): cron4s.expr.DivisibleNode[F] = + divisible match { + case n: Node.BetweenNode => mapBetweenNode[F](n) + case Node.EachNode => cron4s.expr.EachNode[F] + case n: Node.SeveralNode => mapSeveral[F](n) + } + + private def mapSeveral[F <: CronField](n: Node.SeveralNode)(implicit + unit: cron4s.CronUnit[F] + ): cron4s.expr.SeveralNode[F] = cron4s.expr.SeveralNode.apply[F]( + first = mapEnumerable[F](n.head), + second = mapEnumerable[F](n.tail.head), + tail = n.tail.tail.map(node => mapEnumerable[F](node)): _* + ) + + private def mapEnumerable[F <: CronField](enumerable: EnumerableNode)(implicit + unit: cron4s.CronUnit[F] + ): cron4s.expr.EnumerableNode[F] = + enumerable match { + case n: Node.BetweenNode => mapBetweenNode[F](n) + case n: Node.ConstNode => mapConst[F](n) + } + + private def mapBetweenNode[F <: CronField](n: Node.BetweenNode)(implicit + unit: cron4s.CronUnit[F] + ): cron4s.expr.BetweenNode[F] = { + cron4s.expr.BetweenNode( + begin = mapConst[F](n.begin), + end = mapConst[F](n.end) + ) + } + + private def mapConst[F <: CronField](n: Node.ConstNode)(implicit + unit: cron4s.CronUnit[F] + ): cron4s.expr.ConstNode[F] = + cron4s.expr.ConstNode(value = n.value, textValue = n.textValue) + +} diff --git a/modules/parser/shared/src/main/scala/cron4s/parser/package.scala b/modules/parser/shared/src/main/scala/cron4s/parser/package.scala new file mode 100644 index 00000000..391e7b25 --- /dev/null +++ b/modules/parser/shared/src/main/scala/cron4s/parser/package.scala @@ -0,0 +1,162 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s + +import cats.data.NonEmptyList + +package parser { + trait Parser { + def parse(input: String): Either[Error, CronExpr] + } + + sealed trait CronUnit + object CronUnit { + sealed trait Second extends CronUnit + case object Second extends Second + + sealed trait Minute extends CronUnit + case object Minute extends Minute + + sealed trait Hour extends CronUnit + case object Hour extends Hour + + sealed trait DayOfMonth extends CronUnit + case object DayOfMonth extends DayOfMonth + + sealed trait Month extends CronUnit + case object Month extends Month + + sealed trait DayOfWeek extends CronUnit + case object DayOfWeek extends DayOfWeek + + final val All: List[CronUnit] = + List(Second, Minute, Hour, DayOfMonth, Month, DayOfWeek) + } + + sealed trait Node + + object Node { + sealed trait EnumerableNode + sealed trait DivisibleNode + sealed trait NodeWithoutAny + + case object EachNode extends Node with NodeWithoutAny with DivisibleNode { + override val toString = "*" + } + + case object AnyNode extends Node { + override def toString: String = "?" + } + + final case class ConstNode( + value: Int, + textValue: Option[String] = None + ) extends Node with NodeWithoutAny with EnumerableNode { + override lazy val toString: String = textValue.getOrElse(value.toString) + } + + final case class BetweenNode( + begin: ConstNode, + end: ConstNode + ) extends Node with NodeWithoutAny with EnumerableNode with DivisibleNode { + override lazy val toString: String = s"$begin-$end" + } + + final case class SeveralNode( + head: EnumerableNode, + tail: NonEmptyList[EnumerableNode] + ) extends Node with NodeWithoutAny with DivisibleNode { + lazy val values: List[EnumerableNode] = head :: tail.toList + override lazy val toString: String = values.mkString(",") + } + + object SeveralNode { + @inline def apply( + first: EnumerableNode, + second: EnumerableNode, + tail: EnumerableNode* + ): SeveralNode = + new SeveralNode(first, NonEmptyList.of(second, tail: _*)) + + def fromSeq(xs: Seq[EnumerableNode]): Option[SeveralNode] = { + def splitSeq( + xs: Seq[EnumerableNode] + ): Option[(EnumerableNode, EnumerableNode, Seq[EnumerableNode])] = + if (xs.length < 2) None + else Some((xs.head, xs.tail.head, xs.tail.tail)) + + splitSeq(xs).map { + case (first, second, tail) => SeveralNode(first, second, tail: _*) + } + } + + } + + final case class EveryNode( + base: DivisibleNode, + freq: Int + ) extends Node with NodeWithoutAny { + override lazy val toString: String = s"$base/$freq" + } + } + + object Months { + val textValues = IndexedSeq( + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "ago", + "sep", + "oct", + "nov", + "dec" + ) + } + object DaysOfWeek { + val textValues = IndexedSeq("mon", "tue", "wed", "thu", "fri", "sat", "sun") + } + + final case class CronExpr( + seconds: Node.NodeWithoutAny, + minutes: Node.NodeWithoutAny, + hours: Node.NodeWithoutAny, + daysOfMonth: Node, + months: Node.NodeWithoutAny, + daysOfWeek: Node + ) + + sealed abstract class Error(description: String) extends Exception(description) + + case object ExprTooShort extends Error("The provided expression was too short") + + final case class ParseFailed(expected: String, position: Int, found: Option[String]) + extends Error(s"$expected at position ${position}${found.fold("")(f => s" but found '$f'")}") + + object ParseFailed { + def apply(expected: String, position: Int, found: Option[String] = None): ParseFailed = + new ParseFailed(expected, position, found) + + @deprecated("Use the other apply method signature with optional 'found'", "0.6.1") + def apply(msg: String, found: String, position: Int): ParseFailed = + ParseFailed(msg, position, Some(found)) + } + +} diff --git a/modules/core/shared/src/main/scala/cron4s/parsing/base.scala b/modules/parserc/shared/src/main/scala/cron4s/parsing/base.scala similarity index 85% rename from modules/core/shared/src/main/scala/cron4s/parsing/base.scala rename to modules/parserc/shared/src/main/scala/cron4s/parsing/base.scala index 396f2c71..ffd33579 100644 --- a/modules/core/shared/src/main/scala/cron4s/parsing/base.scala +++ b/modules/parserc/shared/src/main/scala/cron4s/parsing/base.scala @@ -17,11 +17,13 @@ package cron4s package parsing +import cron4s.parser.{ExprTooShort, ParseFailed} + import scala.util.parsing.combinator.Parsers -import scala.util.parsing.input.{Position, NoPosition} +import scala.util.parsing.input.{NoPosition, Position} private[parsing] trait BaseParser extends Parsers { - protected def handleError(err: NoSuccess): _root_.cron4s.Error = + protected def handleError(err: NoSuccess): _root_.cron4s.parser.Error = err.next.pos match { case NoPosition => ExprTooShort case pos: Position => diff --git a/modules/core/shared/src/main/scala/cron4s/parsing/lexer.scala b/modules/parserc/shared/src/main/scala/cron4s/parsing/lexer.scala similarity index 96% rename from modules/core/shared/src/main/scala/cron4s/parsing/lexer.scala rename to modules/parserc/shared/src/main/scala/cron4s/parsing/lexer.scala index dc404037..29f87323 100644 --- a/modules/core/shared/src/main/scala/cron4s/parsing/lexer.scala +++ b/modules/parserc/shared/src/main/scala/cron4s/parsing/lexer.scala @@ -91,7 +91,7 @@ object CronLexer extends RegexParsers with BaseParser { number | text | hyphen | slash | comma | asterisk | questionMark | blank ) - def tokenize(expr: String): Either[_root_.cron4s.Error, List[CronToken]] = + def tokenize(expr: String): Either[_root_.cron4s.parser.Error, List[CronToken]] = parse(tokens, expr) match { case err: NoSuccess => Left(handleError(err)) case Success(result, _) => Right(result) diff --git a/modules/parserc/shared/src/main/scala/cron4s/parsing/package.scala b/modules/parserc/shared/src/main/scala/cron4s/parsing/package.scala new file mode 100644 index 00000000..009d6210 --- /dev/null +++ b/modules/parserc/shared/src/main/scala/cron4s/parsing/package.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s + +import cron4s.parser.CronExpr + +package object parsing { + + @deprecated(message = "Parser-Combinator parser in deprecated in favor of atto parser", since = "0.8.0") + object Parser extends cron4s.parser.Parser { + + override def parse(input: String): Either[parser.Error, CronExpr] = + for { + tokens <- CronLexer.tokenize(input) + expr <- CronParser.read(tokens) + } yield expr + + } + +} diff --git a/modules/core/shared/src/main/scala/cron4s/parsing/parser.scala b/modules/parserc/shared/src/main/scala/cron4s/parsing/parser.scala similarity index 55% rename from modules/core/shared/src/main/scala/cron4s/parsing/parser.scala rename to modules/parserc/shared/src/main/scala/cron4s/parsing/parser.scala index 5f605164..b972b628 100644 --- a/modules/core/shared/src/main/scala/cron4s/parsing/parser.scala +++ b/modules/parserc/shared/src/main/scala/cron4s/parsing/parser.scala @@ -17,7 +17,7 @@ package cron4s package parsing -import cron4s.expr._ +import cron4s.parser._ import scala.util.parsing.input._ import scala.util.parsing.combinator._ @@ -31,8 +31,7 @@ class CronTokenReader(tokens: List[CronToken]) extends Reader[CronToken] { } object CronParser extends Parsers with BaseParser { - import CronField._ - import CronUnit._ + import Node._ import CronToken._ override type Elem = CronToken @@ -55,115 +54,94 @@ object CronParser extends Parsers with BaseParser { // Seconds - val seconds: Parser[ConstNode[Second]] = - sexagesimal.map(ConstNode[Second](_)) + val seconds: Parser[ConstNode] = sexagesimal.map(ConstNode(_)) // Minutes - val minutes: Parser[ConstNode[Minute]] = - sexagesimal.map(ConstNode[Minute](_)) + val minutes: Parser[ConstNode] = sexagesimal.map(ConstNode(_)) // Hours - val hours: Parser[ConstNode[Hour]] = - decimal.filter(x => (x >= 0) && (x < 24)).map(ConstNode[Hour](_)) + val hours: Parser[ConstNode] = decimal.filter(x => (x >= 0) && (x < 24)).map(ConstNode(_)) // Days Of Month - val daysOfMonth: Parser[ConstNode[DayOfMonth]] = - decimal.filter(x => (x >= 1) && (x <= 31)).map(ConstNode[DayOfMonth](_)) + val daysOfMonth: Parser[ConstNode] = decimal.filter(x => (x >= 1) && (x <= 31)).map(ConstNode(_)) // Months - private[this] val numericMonths = - decimal.filter(_ <= 12).map(ConstNode[Month](_)) + private[this] val numericMonths = decimal.filter(_ <= 12).map(ConstNode(_)) private[this] val textualMonths = literal.filter(Months.textValues.contains).map { value => val index = Months.textValues.indexOf(value) - ConstNode[Month](index + 1, Some(value)) + ConstNode(index + 1, Some(value)) } - val months: Parser[ConstNode[Month]] = + val months: Parser[ConstNode] = textualMonths | numericMonths // Days Of Week private[this] val numericDaysOfWeek = - decimal.filter(_ < 7).map(ConstNode[DayOfWeek](_)) + decimal.filter(_ < 7).map(ConstNode(_)) private[this] val textualDaysOfWeek = literal.filter(DaysOfWeek.textValues.contains).map { value => val index = DaysOfWeek.textValues.indexOf(value) - ConstNode[DayOfWeek](index, Some(value)) + ConstNode(index, Some(value)) } - val daysOfWeek: Parser[ConstNode[DayOfWeek]] = + val daysOfWeek: Parser[ConstNode] = textualDaysOfWeek | numericDaysOfWeek // ---------------------------------------- // Field-Based Expression Atoms // ---------------------------------------- - def each[F <: CronField](implicit unit: CronUnit[F]): Parser[EachNode[F]] = - accept("*", { case Asterisk => EachNode[F] }) + def each: Parser[EachNode.type] = accept("*", { case Asterisk => EachNode }) - def any[F <: CronField](implicit unit: CronUnit[F]): Parser[AnyNode[F]] = - accept("?", { case QuestionMark => AnyNode[F] }) + def any: Parser[AnyNode.type] = accept("?", { case QuestionMark => AnyNode }) - def between[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[BetweenNode[F]] = - ((base <~ Hyphen) ~ base) ^^ { case min ~ max => BetweenNode[F](min, max) } + def between(base: Parser[ConstNode]): Parser[BetweenNode] = + ((base <~ Hyphen) ~ base) ^^ { case min ~ max => BetweenNode(min, max) } - def several[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[SeveralNode[F]] = { - def compose(b: Parser[EnumerableNode[F]]) = + def several(base: Parser[ConstNode]): Parser[SeveralNode] = { + def compose(b: Parser[EnumerableNode]) = repsep(b, Comma) .filter(_.length > 1) - .map(values => SeveralNode.fromSeq[F](values).get) + .map(values => SeveralNode.fromSeq(values).get) - compose(between(base).map(between2Enumerable) | base.map(const2Enumerable)) + compose(between(base) | base) } - def every[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[EveryNode[F]] = { - def compose(b: Parser[DivisibleNode[F]]) = + def every(base: Parser[ConstNode]): Parser[EveryNode] = { + def compose(b: Parser[DivisibleNode]) = ((b <~ Slash) ~ decimal.filter(_ > 0)) ^^ { - case exp ~ freq => EveryNode[F](exp, freq) + case exp ~ freq => EveryNode(exp, freq) } - compose( - several(base).map(several2Divisible) | - between(base).map(between2Divisible) | - each[F].map(each2Divisible) - ) + compose(several(base) | between(base) | each) } // ---------------------------------------- // AST Parsing & Building // ---------------------------------------- - def field[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[FieldNode[F]] = - every(base).map(every2Field) | - several(base).map(several2Field) | - between(base).map(between2Field) | - base.map(const2Field) | - each[F].map(each2Field) - - def fieldWithAny[F <: CronField](base: Parser[ConstNode[F]])(implicit - unit: CronUnit[F] - ): Parser[FieldNodeWithAny[F]] = - every(base).map(every2FieldWithAny) | - several(base).map(several2FieldWithAny) | - between(base).map(between2FieldWithAny) | - base.map(const2FieldWithAny) | - each[F].map(each2FieldWithAny) | - any[F].map(any2FieldWithAny) + def field(base: Parser[ConstNode]): Parser[NodeWithoutAny] = + every(base) | + several(base) | + between(base) | + base | + each + + def fieldWithAny(base: Parser[ConstNode]): Parser[Node] = + every(base) | + several(base) | + between(base) | + base | + each | + any val cron: Parser[CronExpr] = { phrase( @@ -179,7 +157,7 @@ object CronParser extends Parsers with BaseParser { } } - def read(tokens: List[CronToken]): Either[_root_.cron4s.Error, CronExpr] = { + def read(tokens: List[CronToken]): Either[_root_.cron4s.parser.Error, CronExpr] = { val reader = new CronTokenReader(tokens) cron(reader) match { case err: NoSuccess => Left(handleError(err)) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 62dec65c..79976890 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -33,14 +33,25 @@ object Dependencies { lazy val core = Def.settings( libraryDependencies ++= Seq( - "org.typelevel" %%% "cats-core" % version.cats.main, - "org.scala-lang.modules" %%% "scala-parser-combinators" % version.parserc + "org.typelevel" %%% "cats-core" % version.cats.main ), libraryDependencies ++= (if (scalaVersion.value.startsWith("2.")) Seq("com.chuusai" %%% "shapeless" % version.shapeless) else Seq.empty) ) + lazy val parserc = Def.settings { + libraryDependencies ++= Seq( + "org.scala-lang.modules" %%% "scala-parser-combinators" % version.parserc + ) + } + + lazy val atto = Def.settings { + libraryDependencies ++= Seq( + "org.tpolecat" %%% "atto-core" % version.atto + ) + } + lazy val coreJS = Def.settings { libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % version.scalaJavaTime } @@ -49,7 +60,7 @@ object Dependencies { libraryDependencies += "org.scala-js" %% "scalajs-stubs" % "1.1.0" % Provided } - lazy val coreNative = coreJS ++ coreJVM + lazy val coreNative = coreJS ++ coreJVM ++ parserc lazy val testkit = Def.settings { libraryDependencies ++= Seq( @@ -70,12 +81,6 @@ object Dependencies { ) } - lazy val bench = Def.settings { - libraryDependencies ++= Seq( - "org.tpolecat" %% "atto-core" % version.atto - ) - } - // Dependencies of extension libraries lazy val joda = Def.settings { diff --git a/tests/js/src/test/scala/cron4s/JsCronSpec.scala b/tests/js/src/test/scala/cron4s/JsCronSpec.scala new file mode 100644 index 00000000..64734fa5 --- /dev/null +++ b/tests/js/src/test/scala/cron4s/JsCronSpec.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.annotation.nowarn + +class ParserCombinatorsCronSpec extends AnyFlatSpec with CronSpec { + @nowarn("cat=deprecation") + def parser = parsing.Parser +} + +class AttoCronSpec extends AnyFlatSpec with CronSpec { + def parser = atto.Parser +} + +@nowarn("cat=deprecation") +class CronParserComparisonSpec extends AnyFlatSpec with Matchers { + import CronSpec._ + + "Parser-Combinators and Atto parsers" should "parse valid expressions with the same result" in { + forAll(CronSpec.validExpressions) { expr => + parsing.Parser.parse(expr) shouldBe atto.Parser.parse(expr) + } + } +} diff --git a/tests/jvm/src/test/scala/cron4s/JvmCronSpec.scala b/tests/jvm/src/test/scala/cron4s/JvmCronSpec.scala new file mode 100644 index 00000000..64734fa5 --- /dev/null +++ b/tests/jvm/src/test/scala/cron4s/JvmCronSpec.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2017 Antonio Alonso Dominguez + * + * 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 cron4s + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.annotation.nowarn + +class ParserCombinatorsCronSpec extends AnyFlatSpec with CronSpec { + @nowarn("cat=deprecation") + def parser = parsing.Parser +} + +class AttoCronSpec extends AnyFlatSpec with CronSpec { + def parser = atto.Parser +} + +@nowarn("cat=deprecation") +class CronParserComparisonSpec extends AnyFlatSpec with Matchers { + import CronSpec._ + + "Parser-Combinators and Atto parsers" should "parse valid expressions with the same result" in { + forAll(CronSpec.validExpressions) { expr => + parsing.Parser.parse(expr) shouldBe atto.Parser.parse(expr) + } + } +} diff --git a/modules/core/shared/src/main/scala/cron4s/parsing/package.scala b/tests/native/src/test/scala/cron4s/NativeCronSpec.scala similarity index 75% rename from modules/core/shared/src/main/scala/cron4s/parsing/package.scala rename to tests/native/src/test/scala/cron4s/NativeCronSpec.scala index c37fed45..2d63abe6 100644 --- a/modules/core/shared/src/main/scala/cron4s/parsing/package.scala +++ b/tests/native/src/test/scala/cron4s/NativeCronSpec.scala @@ -16,10 +16,11 @@ package cron4s -package object parsing { - private[cron4s] def parse(e: String): Either[Error, CronExpr] = - for { - tokens <- CronLexer.tokenize(e) - expr <- CronParser.read(tokens) - } yield expr +import org.scalatest.flatspec.AnyFlatSpec + +import scala.annotation.nowarn + +class ParserCombinatorsCronSpec extends AnyFlatSpec with CronSpec { + @nowarn("cat=deprecation") + def parser = parsing.Parser } diff --git a/tests/shared/src/test/scala/cron4s/CronSpec.scala b/tests/shared/src/test/scala/cron4s/CronSpec.scala index 659d6c48..7d7efa47 100644 --- a/tests/shared/src/test/scala/cron4s/CronSpec.scala +++ b/tests/shared/src/test/scala/cron4s/CronSpec.scala @@ -87,11 +87,24 @@ object CronSpec extends TableDrivenPropertyChecks { AnyNode[DayOfWeek] ) + val validExpressions = Table( + "expression", + "* 5 4 * * *", + "* 0 0,12 1 */2 *", + "* 5 4 * * sun", + "* 0 0,12 1 */2 *", + "0 0,5,10,15,20,25,30,35,40,45,50,55 * * * *", + "0 1 2-4 * 4,5,6 */3", + "1 5 4 * * mon-2,sun" + ) } -class CronSpec extends AnyFlatSpec with Matchers { +trait CronSpec extends Matchers { this: AnyFlatSpec => import CronSpec._ + def parser: cron4s.parser.Parser + def cron: CronImpl = new CronImpl(parser) + "Cron" should "not parse an invalid expression" in { val _ = InvalidFieldCombination("Fields DayOfMonth and DayOfWeek can't both have the expression: *") @@ -112,10 +125,11 @@ class CronSpec extends AnyFlatSpec with Matchers { it should "parse a valid expression" in { val exprStr = ValidExpr.toString - Cron(exprStr) shouldBe Right(ValidExpr) + cron(exprStr) shouldBe Right(ValidExpr) - Cron.tryParse(exprStr) shouldBe Success(ValidExpr) + cron.tryParse(exprStr) shouldBe Success(ValidExpr) - Cron.unsafeParse(exprStr) shouldBe ValidExpr + cron.unsafeParse(exprStr) shouldBe ValidExpr } + } diff --git a/tests/shared/src/test/scala/cron4s/parsing/ParserSpec.scala b/tests/shared/src/test/scala/cron4s/parsing/ParserSpec.scala index 1052e9e4..718a8e45 100644 --- a/tests/shared/src/test/scala/cron4s/parsing/ParserSpec.scala +++ b/tests/shared/src/test/scala/cron4s/parsing/ParserSpec.scala @@ -17,24 +17,20 @@ package cron4s package parsing -import cron4s.expr._ +import cron4s.parser._ +import cron4s.parser.Node._ import cron4s.testkit.Cron4sPropSpec -import cron4s.testkit.gen.{ArbitraryEachNode, NodeGenerators} -import org.scalacheck.Gen import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks /** * Created by alonsodomin on 13/01/2016. */ -class ParserSpec - extends Cron4sPropSpec with ScalaCheckDrivenPropertyChecks with InputGenerators - with NodeGenerators with ArbitraryEachNode { - import CronField._ - import CronUnit._ +class ParserSpec extends Cron4sPropSpec with ScalaCheckDrivenPropertyChecks with InputGenerators { + import CronParser._ - def verifyParsed[F <: CronField, N <: Node[F]](parser: Parser[N], input: String)( + def verifyParsed[N <: Node](parser: Parser[N], input: String)( verify: N => Boolean ): Boolean = { def readField(tokens: List[CronToken]) = { @@ -50,42 +46,39 @@ class ParserSpec // Utility methods to help with type inference - def verifyConst[F <: CronField](parser: Parser[ConstNode[F]], input: String)( - verify: ConstNode[F] => Boolean + def verifyConst(parser: Parser[ConstNode], input: String)( + verify: ConstNode => Boolean ): Boolean = - verifyParsed[F, ConstNode[F]](parser, input)(verify) + verifyParsed[ConstNode](parser, input)(verify) - def verifyEach(parser: Parser[EachNode[CronField]], input: String): Boolean = - verifyParsed[CronField, EachNode[CronField]](parser, input)(_ => true) + def verifyEach(parser: Parser[EachNode.type], input: String): Boolean = + verifyParsed[EachNode.type](parser, input)(_ => true) - def verifyAny(parser: Parser[AnyNode[CronField]], input: String): Boolean = - verifyParsed[CronField, AnyNode[CronField]](parser, input)(_ => true) + def verifyAny(parser: Parser[AnyNode.type], input: String): Boolean = + verifyParsed[AnyNode.type](parser, input)(_ => true) - def verifyBetween[F <: CronField](parser: Parser[BetweenNode[F]], input: String)( - verify: BetweenNode[F] => Boolean + def verifyBetween(parser: Parser[BetweenNode], input: String)( + verify: BetweenNode => Boolean ): Boolean = - verifyParsed[F, BetweenNode[F]](parser, input)(verify) + verifyParsed[BetweenNode](parser, input)(verify) - def verifySeveral[F <: CronField, A]( - parser: Parser[SeveralNode[F]], + def verifySeveral[A]( + parser: Parser[SeveralNode], input: String, expected: List[Either[String, (A, A)]] )( - verify: (ConstNode[F], String) => Boolean + verify: (ConstNode, String) => Boolean ): Boolean = - verifyParsed[F, SeveralNode[F]](parser, input) { expr => + verifyParsed[SeveralNode](parser, input) { expr => if (expr.values.toList.size == expected.size) { val matches = expr.values.toList.zip(expected).map { - case (exprPart, expectedPart) => - expectedPart match { - case Left(value) => - exprPart.raw.select[ConstNode[F]].exists(verify(_, value)) - - case Right((start, end)) => - exprPart.raw.select[BetweenNode[F]].exists { part => - verify(part.begin, start.toString) && verify(part.end, end.toString) - } - } + + case (part: ConstNode, Left(value)) => verify(part, value) + + case (part: BetweenNode, Right((start, end))) => + verify(part.begin, start.toString) && verify(part.end, end.toString) + case _ => false + } !matches.contains(false) @@ -96,21 +89,12 @@ class ParserSpec // Properties for the individual parsers // -------------------------------------------------------------- - val eachParserGen: Gen[Parser[EachNode[CronField]]] = - Gen.oneOf(CronUnit.All.map(each(_).asInstanceOf[Parser[EachNode[CronField]]])) - - property("should be able to parse an asterisk in any field") { - forAll(eachParserGen)(parser => verifyEach(parser, "*")) + property("should be able to parse an asterisk as each") { + verifyEach(CronParser.each, "*") } - val anyParserGen: Gen[Parser[AnyNode[CronField]]] = - Gen.oneOf( - Seq(any[DayOfMonth], any[DayOfWeek]) - .map(_.asInstanceOf[Parser[AnyNode[CronField]]]) - ) - - property("should be able to parse a question mark in any field") { - forAll(anyParserGen)(parser => verifyAny(parser, "?")) + property("should be able to parse a question mark as any") { + verifyAny(CronParser.any, "?") } property("should be able to parse seconds") { @@ -137,7 +121,7 @@ class ParserSpec property("should be able to parse named months") { forAll(nameMonthsGen) { x => verifyConst(months, x) { expr => - expr.textValue.contains(x) && expr.matches(Months.textValues.indexOf(x) + 1) + expr.textValue.contains(x) && expr.value == (Months.textValues.indexOf(x) + 1) } } } @@ -148,7 +132,7 @@ class ParserSpec property("should be able to parse named days of week") { forAll(namedDaysOfWeekGen) { x => verifyConst(daysOfWeek, x) { expr => - expr.textValue.contains(x) && expr.matches(DaysOfWeek.textValues.indexOf(x)) + expr.textValue.contains(x) && expr.value == (DaysOfWeek.textValues.indexOf(x)) } } }