Skip to content

Commit

Permalink
Add to circe-yaml-v12
Browse files Browse the repository at this point in the history
  • Loading branch information
sideeffffect committed Oct 17, 2022
1 parent 36f5a9b commit a8672fb
Show file tree
Hide file tree
Showing 20 changed files with 1,025 additions and 0 deletions.
179 changes: 179 additions & 0 deletions circe-yaml-v12/src/main/scala/io/circe/yaml/v12/Parser.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package io.circe.yaml.v12

import cats.syntax.either._
import io.circe._
import java.io.{ Reader, StringReader }
import java.util.Optional
import org.snakeyaml.engine.v2.api.LoadSettings
import org.snakeyaml.engine.v2.composer.Composer
import org.snakeyaml.engine.v2.constructor.StandardConstructor
import org.snakeyaml.engine.v2.nodes._
import org.snakeyaml.engine.v2.scanner.StreamReader
import scala.collection.JavaConverters._

trait Parser {

/**
* Parse YAML from the given [[Reader]], returning either [[ParsingFailure]] or [[Json]]
*
* @param yaml
* @return
*/
def parse(yaml: Reader): Either[ParsingFailure, Json]
def parse(yaml: String): Either[ParsingFailure, Json]
def parseDocuments(yaml: Reader): Stream[Either[ParsingFailure, Json]]
def parseDocuments(yaml: String): Stream[Either[ParsingFailure, Json]]
}

class ParserImpl(settings: LoadSettings) extends 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 asScala[T](ot: Optional[T]): Option[T] =
if (ot.isPresent) Some(ot.get()) else None

private[this] def createComposer(reader: Reader) =
new Composer(settings, new org.snakeyaml.engine.v2.parser.ParserImpl(settings, new StreamReader(settings, reader)))

private[this] def parseSingle(reader: Reader): Either[ParsingFailure, Node] =
Either.catchNonFatal {
val composer = createComposer(reader)
asScala(composer.getSingleNode)
} match {
case Left(err) => Left(ParsingFailure(err.getMessage, err))
case Right(None) => Left(ParsingFailure("no document found", new RuntimeException("no document found")))
case Right(Some(value)) => Right(value)
}

private[this] def parseStream(reader: Reader) =
createComposer(reader).asScala.toStream

private[this] object CustomTag {
def unapply(tag: Tag): Option[String] = if (!tag.getValue.startsWith(Tag.PREFIX))
Some(tag.getValue)
else
None
}

private[this] class FlatteningConstructor(settings: LoadSettings) extends StandardConstructor(settings) {
def flatten(node: MappingNode): MappingNode = {
flattenMapping(node)
node
}

def construct(node: ScalarNode): Object = super.construct(node) // to make the method public
}

private[this] def yamlToJson(node: Node): Either[ParsingFailure, Json] = {
// Isn't thread-safe internally, may hence not be shared
val flattener: FlatteningConstructor = new FlatteningConstructor(settings)

def convertScalarNode(node: ScalarNode) = Either
.catchNonFatal(node.getTag match {
case Tag.INT if node.getValue.startsWith("0x") || node.getValue.contains("_") =>
Json.fromJsonNumber(flattener.construct(node) match {
case int: Integer => JsonLong(int.toLong)
case long: java.lang.Long => JsonLong(long)
case bigint: java.math.BigInteger =>
JsonDecimal(bigint.toString)
case other => throw new NumberFormatException(s"Unexpected number type: ${other.getClass}")
})
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(flattener.construct(node) match {
case b: java.lang.Boolean => b
case _ => throw new IllegalArgumentException(s"Invalid boolean string ${node.getValue}")
})
case Tag.NULL => Json.Null
case CustomTag(other) =>
Json.fromJsonObject(JsonObject.singleton(other.stripPrefix("!"), Json.fromString(node.getValue)))
case _ => 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))
}

if (node == null) {
Right(Json.False)
} else {
node match {
case mapping: MappingNode =>
flattener
.flatten(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)
}
}
}
}

object Parser {
final case class Config(
allowDuplicateKeys: Boolean = false,
allowRecursiveKeys: Boolean = false,
bufferSize: Int = 1024,
label: String = "reader",
maxAliasesForCollections: Int = 50,
parseComments: Boolean = false,
useMarks: Boolean = true
)

def make(config: Config = Config()): Parser = {
import config._
new ParserImpl(
LoadSettings.builder
.setAllowDuplicateKeys(allowDuplicateKeys)
.setAllowRecursiveKeys(allowRecursiveKeys)
.setBufferSize(bufferSize)
.setLabel(label)
.setMaxAliasesForCollections(maxAliasesForCollections)
.setParseComments(parseComments)
.setUseMarks(useMarks)
.build
)
}

lazy val default: Parser = make()
}
172 changes: 172 additions & 0 deletions circe-yaml-v12/src/main/scala/io/circe/yaml/v12/Printer.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package io.circe.yaml.v12

import io.circe._
import io.circe.yaml.v12.Printer._
import java.io.StringWriter
import org.snakeyaml.engine.v2.api.{ DumpSettings, StreamDataWriter }
import org.snakeyaml.engine.v2.common
import org.snakeyaml.engine.v2.emitter.Emitter
import org.snakeyaml.engine.v2.nodes._
import org.snakeyaml.engine.v2.serializer.Serializer
import scala.collection.JavaConverters._

trait Printer {
def pretty(json: Json): String
}

class PrinterImpl(
stringStyle: StringStyle,
preserveOrder: Boolean,
dropNullKeys: Boolean,
mappingStyle: FlowStyle,
sequenceStyle: FlowStyle,
options: DumpSettings
) extends Printer {

import PrinterImpl._

def pretty(json: Json): String = {
val writer = new StreamToStringWriter
val serializer = new Serializer(options, new Emitter(options, writer))
serializer.emitStreamStart()
serializer.serializeDocument(jsonToYaml(json))
serializer.emitStreamEnd()
writer.toString
}

private def isBad(s: String): Boolean = s.indexOf('\u0085') >= 0 || s.indexOf('\ufeff') >= 0
private def hasNewline(s: String): Boolean = s.indexOf('\n') >= 0

private def scalarStyle(value: String): common.ScalarStyle =
if (isBad(value)) common.ScalarStyle.DOUBLE_QUOTED else common.ScalarStyle.PLAIN

private def stringScalarStyle(value: String): common.ScalarStyle =
if (isBad(value)) common.ScalarStyle.DOUBLE_QUOTED
else if (stringStyle == StringStyle.Plain && hasNewline(value)) common.ScalarStyle.LITERAL
else StringStyle.toScalarStyle(stringStyle)

private def scalarNode(tag: Tag, value: String) = new ScalarNode(tag, value, scalarStyle(value))
private def stringNode(value: String) = new ScalarNode(Tag.STR, value, stringScalarStyle(value))
private def keyNode(value: String) = new ScalarNode(Tag.STR, value, scalarStyle(value))

private def jsonToYaml(json: Json): Node = {

def convertObject(obj: JsonObject) = {
val fields = if (preserveOrder) obj.keys else obj.keys.toSet
val m = obj.toMap
val childNodes = fields.flatMap { key =>
val value = m(key)
if (!dropNullKeys || !value.isNull) Some(new NodeTuple(keyNode(key), jsonToYaml(value)))
else None
}
new MappingNode(
Tag.MAP,
childNodes.toList.asJava,
if (mappingStyle == FlowStyle.Flow) common.FlowStyle.FLOW else common.FlowStyle.BLOCK
)
}

json.fold(
scalarNode(Tag.NULL, "null"),
bool => scalarNode(Tag.BOOL, bool.toString),
number => scalarNode(numberTag(number), number.toString),
str => stringNode(str),
arr =>
new SequenceNode(
Tag.SEQ,
arr.map(jsonToYaml).asJava,
if (sequenceStyle == FlowStyle.Flow) common.FlowStyle.FLOW else common.FlowStyle.BLOCK
),
obj => convertObject(obj)
)
}
}

object PrinterImpl {
private def numberTag(number: JsonNumber): Tag =
if (number.toString.contains(".")) Tag.FLOAT else Tag.INT

private class StreamToStringWriter extends StringWriter with StreamDataWriter {
override def flush(): Unit = super.flush() // to fix "conflicting members"
}
}

object Printer {
final case class Config(
preserveOrder: Boolean = false,
dropNullKeys: Boolean = false,
indent: Int = 2,
maxScalarWidth: Int = 80,
splitLines: Boolean = true,
indicatorIndent: Int = 0,
tags: Map[String, String] = Map.empty,
sequenceStyle: FlowStyle = FlowStyle.Block,
mappingStyle: FlowStyle = FlowStyle.Block,
stringStyle: StringStyle = StringStyle.Plain,
lineBreak: LineBreak = LineBreak.Unix,
explicitStart: Boolean = false,
explicitEnd: Boolean = false
)

def make(config: Config = Config()): Printer = {
import config._
new PrinterImpl(
stringStyle,
preserveOrder,
dropNullKeys,
mappingStyle,
sequenceStyle,
DumpSettings
.builder()
.setIndent(indent)
.setWidth(maxScalarWidth)
.setSplitLines(splitLines)
.setIndicatorIndent(indicatorIndent)
.setTagDirective(tags.asJava)
.setDefaultScalarStyle(StringStyle.toScalarStyle(stringStyle))
.setExplicitStart(explicitStart)
.setExplicitEnd(explicitEnd)
.setBestLineBreak {
lineBreak match {
case LineBreak.Unix => "\n"
case LineBreak.Windows => "\r\n"
case LineBreak.Mac => "\r"
}
}
.build()
)
}

lazy val spaces2: Printer = make()
lazy val spaces4: Printer = make(Config(indent = 4))

sealed trait FlowStyle
object FlowStyle {
case object Flow extends FlowStyle
case object Block extends FlowStyle
}

sealed trait StringStyle
object StringStyle {
case object Plain extends StringStyle
case object DoubleQuoted extends StringStyle
case object SingleQuoted extends StringStyle
case object Literal extends StringStyle
case object Folded extends StringStyle

def toScalarStyle(style: StringStyle): common.ScalarStyle = style match {
case StringStyle.Plain => common.ScalarStyle.PLAIN
case StringStyle.DoubleQuoted => common.ScalarStyle.DOUBLE_QUOTED
case StringStyle.SingleQuoted => common.ScalarStyle.SINGLE_QUOTED
case StringStyle.Literal => common.ScalarStyle.LITERAL
case StringStyle.Folded => common.ScalarStyle.FOLDED
}
}

sealed trait LineBreak
object LineBreak {
case object Unix extends LineBreak
case object Windows extends LineBreak
case object Mac extends LineBreak
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.circe.yaml.v12

import io.circe._
import java.io.Reader

package object parser {

/**
* Parse YAML from the given [[Reader]], returning either [[ParsingFailure]] or [[Json]]
* @param yaml
* @return
*/
def parse(yaml: Reader): Either[ParsingFailure, Json] = Parser.default.parse(yaml)

def parse(yaml: String): Either[ParsingFailure, Json] = Parser.default.parse(yaml)

def parseDocuments(yaml: Reader): Stream[Either[ParsingFailure, Json]] = Parser.default.parseDocuments(yaml)
def parseDocuments(yaml: String): Stream[Either[ParsingFailure, Json]] = Parser.default.parseDocuments(yaml)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.circe.yaml.v12

import io.circe.Json

package object printer {

/**
* A default printer implementation using Snake YAML.
*/
def print(tree: Json): String = Printer.spaces2.pretty(tree)
}
Loading

0 comments on commit a8672fb

Please sign in to comment.