Skip to content

Commit

Permalink
Fix parsing, add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
radeusgd committed Jun 22, 2021
1 parent 943ad31 commit 3c10ebc
Show file tree
Hide file tree
Showing 12 changed files with 211 additions and 41 deletions.
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,7 @@ lazy val editions = project
"org.scalatest" %% "scalatest" % scalatestVersion % Test
)
)
.dependsOn(testkit % Test)

lazy val `library-manager` = project
.in(file("lib/scala/library-manager"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.enso.editions

import io.circe.Decoder

/** A helper type to handle special parsing logic of edition names.
*
* The issue is that if an edition is called `2021.4` and it is written
* unquoted inside of a YAML file, that is treated as a floating point
* number, so special care must be taken to correctly parse it.
*/
case class EditionName(name: String) extends AnyVal

object EditionName {

/** A helper method for constructing an [[EditionName]]. */
def apply(name: String): EditionName = new EditionName(name)

/** A [[Decoder]] instance for [[EditionName]] that accepts not only strings
* but also numbers as valid edition names.
*/
implicit val editionNameDecoder: Decoder[EditionName] = { json =>
json
.as[String]
.orElse(json.as[Int].map(_.toString))
.orElse(json.as[Float].map(_.toString))
.map(EditionName(_))
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package org.enso.editions

import cats.Show
import org.enso.yaml.ParseError

/** Indicates an error during resolution of a raw edition. */
sealed class EditionResolutionError(message: String, cause: Throwable = null)
Expand All @@ -18,8 +18,11 @@ object EditionResolutionError {
)

/** Indicates that the edition cannot be parsed. */
case class EditionParseError(message: String, cause: Throwable)
extends EditionResolutionError(message, cause)
case class EditionParseError(cause: Throwable)
extends EditionResolutionError(
s"Cannot parse the edition: ${cause.getMessage}",
cause
)

/** Indicates that a library defined in an edition references a repository
* that is not defined in that edition or any of its parents, and so such a
Expand All @@ -41,16 +44,6 @@ object EditionResolutionError {
s"Edition resolution encountered a cycle: ${editions.mkString(" -> ")}"
)

/** Wraps a Circe's decoding error into a more user-friendly error. */
def wrapDecodingError(decodingError: io.circe.Error): EditionParseError = {
val errorMessage =
implicitly[Show[io.circe.Error]].show(decodingError)
EditionParseError(
s"Could not parse the edition: $errorMessage",
decodingError
)
}

/** Wraps a general error thrown when loading a parsing an edition into a more
* specific error type.
*/
Expand All @@ -59,7 +52,7 @@ object EditionResolutionError {
throwable: Throwable
): EditionResolutionError =
throwable match {
case decodingError: io.circe.Error => wrapDecodingError(decodingError)
case other => CannotLoadEdition(editionName, other)
case error: ParseError => EditionParseError(error)
case other => CannotLoadEdition(editionName, other)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.circe._
import nl.gn0s1s.bump.SemVer
import org.enso.editions.Editions.{Raw, Repository}
import org.enso.editions.SemVerJson._
import org.enso.yaml.YamlHelper

import java.io.FileReader
import java.net.URL
Expand All @@ -17,11 +18,10 @@ object EditionSerialization {

/** Tries to parse an edition definition from a string in the YAML format. */
def parseYamlString(yamlString: String): Try[Raw.Edition] =
yaml.parser
.parse(yamlString)
.flatMap(_.as[Raw.Edition])
YamlHelper
.parseString[Raw.Edition](yamlString)
.left
.map(EditionResolutionError.wrapDecodingError)
.map(EditionResolutionError.EditionParseError)
.toTry

/** Tries to load an edition definition from a YAML file. */
Expand Down Expand Up @@ -157,22 +157,6 @@ object EditionSerialization {
}
}

/** A helper opaque type to handle special parsing logic of edition names.
*
* The issue is that if an edition is called `2021.4` and it is written
* unquoted inside of a YAML file, that is treated as a floating point
* number, so special care must be taken to correctly parse it.
*/
private case class EditionName(name: String)

implicit private val editionNameDecoder: Decoder[EditionName] = { json =>
json
.as[String]
.orElse(json.as[Int].map(_.toString))
.orElse(json.as[Float].map(_.toString))
.map(EditionName)
}

implicit private val libraryDecoder: Decoder[Raw.Library] = { json =>
def makeLibrary(name: String, repository: String, version: Option[SemVer]) =
if (repository == Fields.localRepositoryName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@ object LibraryName {
implicit val decoder: Decoder[LibraryName] = { json =>
for {
str <- json.as[String]
name <- parseLibraryName(str).left.map { errorMessage =>
name <- fromString(str).left.map { errorMessage =>
DecodingFailure(errorMessage, json.history)
}
} yield name
}

private def parseLibraryName(str: String): Either[String, LibraryName] = {
str.split(".") match {
/** Creates a [[LibraryName]] from its string representation.
*
* Returns an error message on failure.
*/
def fromString(str: String): Either[String, LibraryName] = {
str.split('.') match {
case Array(prefix, name) => Right(LibraryName(prefix, name))
case _ => Left(s"`$str` is not a valid library name")
case _ => Left(s"`$str` is not a valid library name.")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
package org.enso.editions.repository

import io.circe._
import org.enso.editions.EditionName

case class Manifest(editions: Seq[String])
/** The Edition Repository manifest, which lists all editions that the
* repository provides.
*/
case class Manifest(editions: Seq[EditionName])

object Manifest {
object Fields {
val editions = "editions"
}

/** A [[Decoder]] instance for parsing [[Manifest]]. */
implicit val decoder: Decoder[Manifest] = { json =>
for {
editions <- json.get[Seq[String]](Fields.editions)
editions <- json.get[Seq[EditionName]](Fields.editions)
} yield Manifest(editions)
}
}
19 changes: 19 additions & 0 deletions lib/scala/editions/src/main/scala/org/enso/yaml/ParseError.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.enso.yaml

import cats.Show

/** Indicates a parse failure, usually meaning that the input data has
* unexpected format (like missing fields or wrong field types).
*/
case class ParseError(message: String, cause: io.circe.Error)
extends RuntimeException(message, cause)

object ParseError {

/** Wraps a [[io.circe.Error]] into a more user-friendly [[ParseError]]. */
def apply(error: io.circe.Error): ParseError = {
val errorMessage =
implicitly[Show[io.circe.Error]].show(error)
ParseError(errorMessage, error)
}
}
17 changes: 17 additions & 0 deletions lib/scala/editions/src/main/scala/org/enso/yaml/YamlHelper.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.enso.yaml

import io.circe.{yaml, Decoder}

/** A helper for parsing YAML configs. */
object YamlHelper {

/** Parses a string representation of a YAML configuration of type `R`. */
def parseString[R](
yamlString: String
)(implicit decoder: Decoder[R]): Either[ParseError, R] =
yaml.parser
.parse(yamlString)
.flatMap(_.as[R])
.left
.map(ParseError(_))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.enso.editions

import org.enso.testkit.EitherValue
import org.enso.yaml.YamlHelper
import org.scalatest.EitherValues
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec

class LibraryNameSpec
extends AnyWordSpec
with Matchers
with EitherValue
with EitherValues {
"LibraryName" should {
"parse and serialize to the same thing" in {
val str = "Foo.Bar"
val libraryName = LibraryName.fromString(str).rightValue
libraryName.qualifiedName shouldEqual str
libraryName.name shouldEqual "Bar"
libraryName.prefix shouldEqual "Foo"

val yamlParsed = YamlHelper.parseString[LibraryName](str).rightValue
yamlParsed shouldEqual libraryName
}

"fail to parse if there are too many parts" in {
LibraryName.fromString("A.B.C") shouldEqual Left(
"`A.B.C` is not a valid library name."
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.enso.editions.repository

import org.enso.editions.EditionName
import org.enso.testkit.EitherValue
import org.enso.yaml.YamlHelper
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec

class ManifestParserSpec extends AnyWordSpec with Matchers with EitherValue {
"Manifest" should {
"be parsed from YAML format" in {
val str =
"""editions:
|- foo
|- 2021.4
|- bar
|""".stripMargin
YamlHelper.parseString[Manifest](str).rightValue.editions shouldEqual Seq(
EditionName("foo"),
EditionName("2021.4"),
EditionName("bar")
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ package org.enso.librarymanager.published.repository
import io.circe.Decoder
import org.enso.editions.LibraryName

/** The manifest file containing metadata related to a published library.
*
* @param archives sequence of sub-archives that the library package is
* composed of
* @param dependencies sequence of direct dependencies of the library
* @param tagLine a short description of the library
* @param description a longer description of the library, for the Marketplace
*/
case class LibraryManifest(
archives: Seq[String],
dependencies: Seq[LibraryName],
Expand All @@ -18,6 +26,7 @@ object LibraryManifest {
val description = "description"
}

/** A [[Decoder]] instance for parsing [[LibraryManifest]]. */
implicit val decoder: Decoder[LibraryManifest] = { json =>
for {
archives <- json.get[Seq[String]](Fields.archives)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.enso.librarymanager.published.repository

import org.enso.editions.LibraryName
import org.enso.testkit.EitherValue
import org.enso.yaml.YamlHelper
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec

class LibraryManifestParserSpec
extends AnyWordSpec
with Matchers
with EitherValue {
"LibraryManifest" should {
"be parsed from YAML format" in {
val str =
"""archives:
|- main.tgz
|- tests.tgz
|dependencies:
|- Standard.Base
|- Foo.Bar
|tag-line: Foo Bar
|description: Foo bar baz.
|""".stripMargin
YamlHelper
.parseString[LibraryManifest](str)
.rightValue shouldEqual LibraryManifest(
archives = Seq("main.tgz", "tests.tgz"),
dependencies = Seq(
LibraryName.fromString("Standard.Base").rightValue,
LibraryName.fromString("Foo.Bar").rightValue
),
tagLine = Some("Foo Bar"),
description = Some("Foo bar baz.")
)
}

"require only a minimal set of fields to parse" in {
val str =
"""archives:
|- main.tgz
|""".stripMargin
YamlHelper
.parseString[LibraryManifest](str)
.rightValue shouldEqual LibraryManifest(
archives = Seq("main.tgz"),
dependencies = Seq(),
tagLine = None,
description = None
)
}
}
}

0 comments on commit 3c10ebc

Please sign in to comment.