diff --git a/src/main/scala/io/circe/yaml/Parser.scala b/src/main/scala/io/circe/yaml/Parser.scala new file mode 100644 index 00000000..4ccc6c92 --- /dev/null +++ b/src/main/scala/io/circe/yaml/Parser.scala @@ -0,0 +1,160 @@ +package io.circe.yaml + +import cats.Eval +import cats.data.EitherT +import cats.instances.list._ +import cats.instances.vector._ +import cats.syntax.either._ +import cats.syntax.traverse._ +import io.circe.{Json, JsonNumber, ParsingFailure} +import java.io.{Reader, StringReader} +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.constructor.SafeConstructor +import org.yaml.snakeyaml.nodes.{MappingNode, Node, ScalarNode, SequenceNode, Tag} +import scala.collection.JavaConverters._ +import scala.util.control.NonFatal + +final case class Parser( + useBoolLit: Boolean = true, + useFloatLit: Boolean = true, + useIntLit: Boolean = true, + useTimestampLit: Boolean = true, + useMergeLit: Boolean = true +) { + + /** + * Parse YAML from the given [[Reader]], returning either [[ParsingFailure]] or [[Json]] + * @param yaml + * @return + */ + def parse(reader: Reader): Either[ParsingFailure, Json] = { + val yaml = Parser.createYaml + + parseSingle(yaml)(reader).flatMap(node => convertNode(yaml)(node).value.value) + } + + def parseDocuments(reader: Reader): Stream[Either[ParsingFailure, Json]] = { + val yaml = Parser.createYaml + + parseStream(yaml)(reader).map(_.flatMap(node => convertNode(yaml)(node).value.value)) + } + + def parse(input: String): Either[ParsingFailure, Json] = parse(new StringReader(input)) + def parseDocuments(input: String): Stream[Either[ParsingFailure, Json]] = parseDocuments(new StringReader(input)) + + private[this] def parseSingle(yaml: Parser.CirceYaml)(reader: Reader): Either[ParsingFailure, Node] = try { + Right(yaml.compose(reader)) + } catch { + case NonFatal(err) => Left(ParsingFailure(err.getMessage, err)) + } + + private[this] def parseStream(yaml: Parser.CirceYaml)(reader: Reader): Stream[Either[ParsingFailure, Node]] = { + val iterator = yaml.composeAll(reader).iterator + + new Iterator[Either[ParsingFailure, Node]] { + def hasNext: Boolean = iterator.hasNext + def next(): Either[ParsingFailure, Node] = try Right(iterator.next()) catch { + case NonFatal(err) => Left(ParsingFailure(err.getMessage, err)) + } + }.toStream + } + + private[this] def parseBool(input: String): Either[ParsingFailure, Json] = + if (input == "true") Right(Json.True) else if (input == "false") Right(Json.False) else Left( + ParsingFailure(s"Invalid bool: $input", null) + ) + + private[this] def parseNumber(input: String): Either[ParsingFailure, Json] = JsonNumber.fromString(input) match { + case Some(value) => Right(Json.fromJsonNumber(value)) + case None => Left(ParsingFailure(s"Invalid number: $input", null)) + } + + private[this] def convertScalarNode(yaml: Parser.CirceYaml)(node: ScalarNode): Either[ParsingFailure, Json] = + node.getTag match { + case Tag.BOOL if useBoolLit => yaml.boolLit(node) + case Tag.FLOAT if useFloatLit => yaml.floatLit(node) + case Tag.INT if useIntLit => yaml.intLit(node) + case Tag.TIMESTAMP if useTimestampLit => yaml.timestampLit(node) + case Tag.BOOL => parseBool(node.getValue) + case Tag.FLOAT | Tag.INT => parseNumber(node.getValue) + case Tag.NULL => Right(Json.Null) + case Parser.CustomTag(other) => Right(Json.obj(other.stripPrefix("!") -> Json.fromString(node.getValue))) + case other => Right(Json.fromString(node.getValue)) + } + + private[this] def convertKeyNode(node: Node): Either[ParsingFailure, String] = node match { + case scalar: ScalarNode => Right(scalar.getValue) + case _ => Left(ParsingFailure("Only string keys can be represented in JSON", null)) + } + + private[this] def convertNode(yaml: Parser.CirceYaml)(node: Node): EitherT[Eval, ParsingFailure, Json] = + node match { + case scalar: ScalarNode => EitherT(Eval.now(convertScalarNode(yaml)(scalar))) + case sequence: SequenceNode => + sequence.getValue.iterator.asScala.toVector.traverseU(convertNode(yaml)).map(Json.fromValues) + case mapping: MappingNode => + val m = if (useMergeLit) yaml.mergeNodes(mapping) else mapping + + m.getValue.iterator.asScala.toList.traverseU { pair => + for { + k <- EitherT(Eval.now(convertKeyNode(pair.getKeyNode))) + v <- convertNode(yaml)(pair.getValueNode) + } yield (k, v) + }.map(Json.fromFields) + } +} + +object Parser { + private[yaml] object CustomTag { + def unapply(tag: Tag): Option[String] = if (!tag.startsWith(Tag.PREFIX)) + Some(tag.getValue) + else + None + } + + private[circe] def createYaml: Parser.CirceYaml = new Parser.CirceYaml(new CirceConstructor) + + private[this] final class CirceConstructor extends SafeConstructor { + def constructNode(node: Node): AnyRef = constructObject(node) + def flattenNode(node: MappingNode): MappingNode = { + flattenMapping(node) + node + } + } + + private[yaml] final class CirceYaml(circeConstructor: CirceConstructor) extends Yaml(circeConstructor) { + def mergeNodes(node: MappingNode): MappingNode = { + circeConstructor.flattenNode(node) + node + } + + def boolLit(node: ScalarNode): Either[ParsingFailure, Json] = try { + Right(Json.fromBoolean(circeConstructor.constructNode(node).asInstanceOf[java.lang.Boolean])) + } catch { + case err: ClassCastException => Left(ParsingFailure("Expected int YAML node", err)) + } + + def floatLit(node: ScalarNode): Either[ParsingFailure, Json] = try { + Right(Json.fromDoubleOrString(circeConstructor.constructNode(node).asInstanceOf[java.lang.Double])) + } catch { + case err: ClassCastException => Left(ParsingFailure("Expected double YAML node", err)) + } + + def intLit(node: ScalarNode): Either[ParsingFailure, Json] = try { + circeConstructor.constructNode(node) match { + case value: java.lang.Integer => Right(Json.fromInt(value)) + case value: java.lang.Long => Right(Json.fromLong(value)) + case value: java.math.BigInteger => Right(Json.fromBigInt(new BigInt(value))) + } + } catch { + case err: ClassCastException => Left(ParsingFailure("Expected bool YAML node", err)) + case err: NumberFormatException => Left(ParsingFailure(err.getMessage, err)) + } + + def timestampLit(node: ScalarNode): Either[ParsingFailure, Json] = try { + Right(Json.fromLong(circeConstructor.constructNode(node).asInstanceOf[java.util.Date].getTime)) + } catch { + case err: ClassCastException => Left(ParsingFailure("Expected timestamp YAML node", err)) + } + } +} diff --git a/src/main/scala/io/circe/yaml/package.scala b/src/main/scala/io/circe/yaml/package.scala new file mode 100644 index 00000000..19cdd5fe --- /dev/null +++ b/src/main/scala/io/circe/yaml/package.scala @@ -0,0 +1,5 @@ +package io.circe + +package object yaml { + val parser: Parser = Parser() +} diff --git a/src/main/scala/io/circe/yaml/parser/package.scala b/src/main/scala/io/circe/yaml/parser/package.scala deleted file mode 100644 index d6039819..00000000 --- a/src/main/scala/io/circe/yaml/parser/package.scala +++ /dev/null @@ -1,82 +0,0 @@ -package io.circe.yaml - -import cats.syntax.either._ -import io.circe._ -import java.io.{Reader, StringReader} -import org.yaml.snakeyaml.Yaml -import org.yaml.snakeyaml.nodes._ -import scala.collection.JavaConverters._ - -package object parser { - - - /** - * Parse YAML from the given [[Reader]], returning either [[ParsingFailure]] or [[Json]] - * @param yaml - * @return - */ - def parse(yaml: Reader): Either[ParsingFailure, Json] = for { - parsed <- parseSingle(yaml) - json <- yamlToJson(parsed) - } yield json - - def parse(yaml: String): Either[ParsingFailure, Json] = parse(new StringReader(yaml)) - - def parseDocuments(yaml: Reader): Stream[Either[ParsingFailure, Json]] = parseStream(yaml).map(yamlToJson) - def parseDocuments(yaml: String): Stream[Either[ParsingFailure, Json]] = parseDocuments(new StringReader(yaml)) - - private[this] def parseSingle(reader: Reader) = - Either.catchNonFatal(new Yaml().compose(reader)).leftMap(err => ParsingFailure(err.getMessage, err)) - - private[this] def parseStream(reader: Reader) = - new Yaml().composeAll(reader).asScala.toStream - - private[this] object CustomTag { - def unapply(tag: Tag): Option[String] = if (!tag.startsWith(Tag.PREFIX)) - Some(tag.getValue) - else - None - } - - private[this] def yamlToJson(node: Node): Either[ParsingFailure, Json] = { - - def convertScalarNode(node: ScalarNode) = Either.catchNonFatal(node.getTag match { - case Tag.INT | Tag.FLOAT => JsonNumber.fromString(node.getValue).map(Json.fromJsonNumber).getOrElse { - throw new NumberFormatException(s"Invalid numeric string ${node.getValue}") - } - case Tag.BOOL => Json.fromBoolean(node.getValue.toBoolean) - case Tag.NULL => Json.Null - case CustomTag(other) => - Json.fromJsonObject(JsonObject.singleton(other.stripPrefix("!"), Json.fromString(node.getValue))) - case other => Json.fromString(node.getValue) - }).leftMap { - err => - ParsingFailure(err.getMessage, err) - } - - def convertKeyNode(node: Node) = node match { - case scalar: ScalarNode => Right(scalar.getValue) - case _ => Left(ParsingFailure("Only string keys can be represented in JSON", null)) - } - - node match { - case mapping: MappingNode => - mapping.getValue.asScala.foldLeft(Either.right[ParsingFailure, JsonObject](JsonObject.empty)) { - (objEither, tup) => for { - obj <- objEither - key <- convertKeyNode(tup.getKeyNode) - value <- yamlToJson(tup.getValueNode) - } yield obj.add(key, value) - }.map(Json.fromJsonObject) - case sequence: SequenceNode => - sequence.getValue.asScala.foldLeft(Either.right[ParsingFailure, List[Json]](List.empty[Json])) { - (arrEither, node) => for { - arr <- arrEither - value <- yamlToJson(node) - } yield value :: arr - }.map(arr => Json.fromValues(arr.reverse)) - case scalar: ScalarNode => convertScalarNode(scalar) - } - } - -} diff --git a/src/test/resources/test-yamls/merge-key.json b/src/test/resources/test-yamls/merge-key.json new file mode 100644 index 00000000..dae8042a --- /dev/null +++ b/src/test/resources/test-yamls/merge-key.json @@ -0,0 +1,40 @@ +[ + { + "x" : 1, + "y" : 2 + }, + { + "x" : 0, + "y" : 2 + }, + { + "r" : 1e1 + }, + { + "r" : 1 + }, + { + "x" : 1, + "y" : 2, + "r" : 1e1, + "label" : "center/big" + }, + { + "x" : 1, + "y" : 2, + "r" : 1e1, + "label" : "center/big" + }, + { + "x" : 1, + "y" : 2, + "r" : 1e1, + "label" : "center/big" + }, + { + "r" : 1e1, + "x" : 1, + "y" : 2, + "label" : "center/big" + } +] \ No newline at end of file diff --git a/src/test/resources/test-yamls/merge-key.yml b/src/test/resources/test-yamls/merge-key.yml new file mode 100644 index 00000000..ee4a48fe --- /dev/null +++ b/src/test/resources/test-yamls/merge-key.yml @@ -0,0 +1,27 @@ +--- +- &CENTER { x: 1, y: 2 } +- &LEFT { x: 0, y: 2 } +- &BIG { r: 10 } +- &SMALL { r: 1 } + +# All the following maps are equal: + +- # Explicit keys + x: 1 + y: 2 + r: 10 + label: center/big + +- # Merge one map + << : *CENTER + r: 10 + label: center/big + +- # Merge multiple maps + << : [ *CENTER, *BIG ] + label: center/big + +- # Override + << : [ *BIG, *LEFT, *SMALL ] + x: 1 + label: center/big diff --git a/src/test/scala/io/circe/yaml/ExampleFileTests.scala b/src/test/scala/io/circe/yaml/ExampleFileTests.scala index 97720674..2d622b34 100644 --- a/src/test/scala/io/circe/yaml/ExampleFileTests.scala +++ b/src/test/scala/io/circe/yaml/ExampleFileTests.scala @@ -14,6 +14,8 @@ class ExampleFileTests extends FreeSpec { file => file.getName -> file.getName.replaceFirst("yml$", "json") } + val parser = Parser(useTimestampLit = false) + testFiles foreach { case (yamlFile, jsonFile) => yamlFile in { val jsonStream = getClass.getClassLoader.getResourceAsStream(s"test-yamls/$jsonFile") diff --git a/src/test/scala/io/circe/yaml/SnakeYamlSymmetricSerializationTests.scala b/src/test/scala/io/circe/yaml/SnakeYamlSymmetricSerializationTests.scala index aac5a2f9..3397d01e 100644 --- a/src/test/scala/io/circe/yaml/SnakeYamlSymmetricSerializationTests.scala +++ b/src/test/scala/io/circe/yaml/SnakeYamlSymmetricSerializationTests.scala @@ -8,6 +8,7 @@ import org.typelevel.discipline.scalatest.Discipline class SnakeYamlSymmetricSerializationTests extends FunSuite with Discipline with SymmetricSerializationTests { override val laws: SymmetricSerializationLaws = SymmetricSerializationLaws() + val parser = Parser(useFloatLit = false, useIntLit = false) checkAll("snake.printer", symmetricPrinter[Json](printer.print, parser.parse)) }